M8: 50k-layer zoomed-out renders and faster save/load (#63)

Canvas previews zoomed out to 50% or less draw levels of detail
(crates/render/src/lod.rs): subtrees under 2 device px are splatted as
their averaged paint colours (box filter, no paths or effects), text
under 4 px is greeked into bars, small solid rectangles are splatted and
rectangular clip layers are composited without the blend pipeline.
Image paints and masks keep the exact path. Exports, thumbnails and
every other render stay exact (Options::lod is off by default); render
bands are assembled with a row copy instead of a pixmap draw.

.figcraft documents are serialized straight into the compressor and
parsed straight from the text (a header pass reads version and image
index without building a value tree); layer and text fields at their
default values are left out (63 -> 22 MB of JSON at 50k layers, older
files still load); JSON members use deflate level 3.

At 50k layers: zoomed-to-fit render ~58 -> ~8 ms, save ~1.0 s -> ~0.09 s,
load ~0.86 s -> ~0.16 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 10:27:51 +07:00 committed by GitHub
parent 1184dd4177
commit 5f54fecca2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1148 additions and 95 deletions

View File

@ -129,13 +129,18 @@ dimension measured side by side.
~7.5 ms. The layers panel lays out only the rows in view (every screen expanded at 50k: 39 → ~7.5 ms. The layers panel lays out only the rows in view (every screen expanded at 50k: 39 →
0.6 ms a frame); text layouts are shared instead of re-formatted per lookup; overflowing text is 0.6 ms a frame); text layouts are shared instead of re-formatted per lookup; overflowing text is
culled by its glyphs. At 10k: full 1440×900 renders 7–15 ms, single-layer edits ~10 µs, hit culled by its glyphs. At 10k: full 1440×900 renders 7–15 ms, single-layer edits ~10 µs, hit
tests ~26 µs. *Left:* zoomed-out renders at 50k (~40 ms: tiles or levels of detail), save/load tests ~26 µs. Zoomed out (50% and below) the canvas draws levels of detail: layers under 2 px
at 50k (~0.7 s each), a real-world file corpus, Windows/Linux/browser QA, accessibility. 40–60 h. land as their averaged colour, text under 4 px is greeked into bars, small rectangles and clip
layers skip the path pipeline (exports and closer zooms stay exact): a whole 50k-layer page
renders in ~8 ms instead of ~50 ms. `.figcraft` at 50k layers saves in ~0.09 s instead of ~1 s
and loads in ~0.16 s instead of ~0.86 s (JSON streamed to and from the document without a
value tree, layer fields at their defaults left out: 63 → 22 MB of JSON, old files still
open). *Left:* a real-world file corpus, Windows/Linux/browser QA, accessibility. 30–50 h.
### Where we're going ### Where we're going
- **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers, - **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); responsive
a real-file corpus); responsive presentation. presentation.
- **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8). - **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8).
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs. - **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.

View File

@ -50,17 +50,22 @@ fn find(doc: &Document, root: NodeId, k: usize, pred: impl Fn(&figcraft_doc::Nod
doc.subtree(root).into_iter().filter(|i| doc.node(*i).is_some_and(&pred)).nth(k) doc.subtree(root).into_iter().filter(|i| doc.node(*i).is_some_and(&pred)).nth(k)
} }
fn render(doc: &Document, page: NodeId, v: &ViewState) -> bool { /// What the canvas renders with: a preview, with levels of detail when zoomed out.
let (ox, oy) = v.origin(); fn canvas() -> figcraft_render::Options {
let view = figcraft_render::View { origin: (ox, oy), scale: v.zoom, width: VIEW_W, height: VIEW_H }; figcraft_render::Options { lod: true, ..Default::default() }
figcraft_render::render_view(doc, page, &view, &Default::default()).is_some()
} }
/// Render `v` panned right by `dx` page units through the retained renderer. fn render(doc: &Document, page: NodeId, v: &ViewState, opts: &figcraft_render::Options) -> bool {
let (ox, oy) = v.origin();
let view = figcraft_render::View { origin: (ox, oy), scale: v.zoom, width: VIEW_W, height: VIEW_H };
figcraft_render::render_view(doc, page, &view, opts).is_some()
}
/// Render `v` panned right by `dx` page units through the retained renderer, as the canvas does.
fn retained(r: &mut figcraft_render::Retained, doc: &std::sync::Arc<Document>, page: NodeId, v: &ViewState, dx: f64) -> bool { fn retained(r: &mut figcraft_render::Retained, doc: &std::sync::Arc<Document>, page: NodeId, v: &ViewState, dx: f64) -> bool {
let (ox, oy) = v.origin(); let (ox, oy) = v.origin();
let view = figcraft_render::View { origin: (ox + dx, oy), scale: v.zoom, width: VIEW_W, height: VIEW_H }; let view = figcraft_render::View { origin: (ox + dx, oy), scale: v.zoom, width: VIEW_W, height: VIEW_H };
r.render(doc, page, &view, &Default::default()).is_some() r.render(doc, page, &view, &canvas()).is_some()
} }
struct Table { struct Table {
@ -99,15 +104,17 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
table.put(col, "load .figcraft", ms(t)); table.put(col, "load .figcraft", ms(t));
drop(back); drop(back);
// Rendering a 1440×900 viewport: 100% on the first screen, and zoomed out to fit the page. // Rendering a 1440×900 viewport as the canvas does: 100% on the first screen, and zoomed out to
// fit the page (also exactly, as an export of the same area would).
let bounds = doc.page_content_bounds(page).ok_or_else(|| failed("empty page"))?; let bounds = doc.page_content_bounds(page).ok_or_else(|| failed("empty page"))?;
let mut at100 = ViewState { vw: f64::from(VIEW_W), vh: f64::from(VIEW_H), ..Default::default() }; let mut at100 = ViewState { vw: f64::from(VIEW_W), vh: f64::from(VIEW_H), ..Default::default() };
at100.cx = f64::from(VIEW_W) / 2.0; at100.cx = f64::from(VIEW_W) / 2.0;
at100.cy = f64::from(VIEW_H) / 2.0; at100.cy = f64::from(VIEW_H) / 2.0;
table.put(col, "render 1440×900 @100%", mean(5, |_| _ = render(&doc, page, &at100))); table.put(col, "render 1440×900 @100%", mean(5, |_| _ = render(&doc, page, &at100, &canvas())));
let mut fit = at100.clone(); let mut fit = at100.clone();
fit.fit(bounds, 1.0); fit.fit(bounds, 1.0);
table.put(col, "render 1440×900 zoomed to fit", mean(3, |_| _ = render(&doc, page, &fit))); table.put(col, "render 1440×900 zoomed to fit", mean(5, |_| _ = render(&doc, page, &fit, &canvas())));
table.put(col, "render 1440×900 zoomed to fit, exact (no LOD)", mean(3, |_| _ = render(&doc, page, &fit, &Default::default())));
// The canvas keeps its last image: a pan renders the uncovered strips, an edit the layers' // The canvas keeps its last image: a pan renders the uncovered strips, an edit the layers'
// areas. // areas.
@ -116,7 +123,7 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> {
// Zoomed out, but with content right up to the edges (a fitted page has margins). // Zoomed out, but with content right up to the edges (a fitted page has margins).
let mut out = fit.clone(); let mut out = fit.clone();
out.zoom *= 2.0; out.zoom *= 2.0;
table.put(col, "render 1440×900 zoomed out (2× fit)", mean(3, |_| _ = render(&doc, page, &out))); table.put(col, "render 1440×900 zoomed out (2× fit)", mean(5, |_| _ = render(&doc, page, &out, &canvas())));
for (name, v) in [("pan 40 px @100% (retained)", &at100), ("pan 40 px zoomed out (retained)", &out)] { for (name, v) in [("pan 40 px @100% (retained)", &at100), ("pan 40 px zoomed out (retained)", &out)] {
let mut r = figcraft_render::Retained::default(); let mut r = figcraft_render::Retained::default();
let _ = retained(&mut r, &doc, page, v, 0.0); let _ = retained(&mut r, &doc, page, v, 0.0);

View File

@ -321,72 +321,72 @@ pub struct Node {
pub name: String, pub name: String,
#[serde(flatten)] #[serde(flatten)]
pub kind: NodeKind, pub kind: NodeKind,
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<NodeId>, pub parent: Option<NodeId>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub children: Vec<NodeId>, pub children: Vec<NodeId>,
#[serde(default = "yes")] #[serde(default = "yes", skip_serializing_if = "is_yes")]
pub visible: bool, pub visible: bool,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub locked: bool, pub locked: bool,
#[serde(default = "one")] #[serde(default = "one", skip_serializing_if = "is_one")]
pub opacity: f32, pub opacity: f32,
#[serde(default = "pass")] #[serde(default = "pass", skip_serializing_if = "is_pass")]
pub blend: BlendMode, pub blend: BlendMode,
/// Transform relative to the parent (the page for top-level nodes). /// Transform relative to the parent (the page for top-level nodes).
#[serde(default = "identity")] #[serde(default = "identity", skip_serializing_if = "is_identity")]
pub transform: Affine, pub transform: Affine,
pub width: f64, pub width: f64,
pub height: f64, pub height: f64,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub fills: Vec<Paint>, pub fills: Vec<Paint>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub strokes: Vec<Paint>, pub strokes: Vec<Paint>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub stroke: StrokeProps, pub stroke: StrokeProps,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub effects: Vec<Effect>, pub effects: Vec<Effect>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub corners: Corners, pub corners: Corners,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub constraints: Constraints, pub constraints: Constraints,
/// Auto layout (frame-like nodes only). /// Auto layout (frame-like nodes only).
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub layout: Option<AutoLayout>, pub layout: Option<AutoLayout>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub child_layout: ChildLayout, pub child_layout: ChildLayout,
/// Clip content (frames). /// Clip content (frames).
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub clip: bool, pub clip: bool,
/// This node masks the siblings above it. /// This node masks the siblings above it.
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub is_mask: bool, pub is_mask: bool,
/// How a mask masks: by its alpha, its outline, or its luminance. /// How a mask masks: by its alpha, its outline, or its luminance.
#[serde(default, skip_serializing_if = "MaskType::is_alpha")] #[serde(default, skip_serializing_if = "MaskType::is_alpha")]
pub mask_type: MaskType, pub mask_type: MaskType,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub grids: Vec<LayoutGrid>, pub grids: Vec<LayoutGrid>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub exports: Vec<ExportSetting>, pub exports: Vec<ExportSetting>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub interactions: Vec<Interaction>, pub interactions: Vec<Interaction>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub overflow: Overflow, pub overflow: Overflow,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub scroll_position: ScrollPosition, pub scroll_position: ScrollPosition,
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub styles: StyleRefs, pub styles: StyleRefs,
/// Variable bindings: field name ("fills/0/color", "width", "cornerRadius", "characters", …) → id. /// Variable bindings: field name ("fills/0/color", "width", "cornerRadius", "characters", …) → id.
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub bound: BTreeMap<String, VariableId>, pub bound: BTreeMap<String, VariableId>,
/// Explicit variable modes (collection → mode) set on this container. /// Explicit variable modes (collection → mode) set on this container.
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub var_modes: BTreeMap<CollectionId, ModeId>, pub var_modes: BTreeMap<CollectionId, ModeId>,
/// Lock aspect ratio when resizing from the panel. /// Lock aspect ratio when resizing from the panel.
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub constrain_proportions: bool, pub constrain_proportions: bool,
/// Inside an instance: the node of the main component this one mirrors. /// Inside an instance: the node of the main component this one mirrors.
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub source: Option<NodeId>, pub source: Option<NodeId>,
/// A nested instance inside a main component whose properties show on the main's instances. /// A nested instance inside a main component whose properties show on the main's instances.
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
@ -397,15 +397,15 @@ pub struct Node {
pub dev_status: DevStatus, pub dev_status: DevStatus,
/// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot": /// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot":
/// this slot holds the instance's own content). /// this slot holds the instance's own content).
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub overrides: BTreeSet<String>, pub overrides: BTreeSet<String>,
/// Component property references: which property controls this node's /// Component property references: which property controls this node's
/// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap), or which slot /// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap), or which slot
/// property this frame is ("slot"). /// property this frame is ("slot").
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub prop_refs: BTreeMap<String, String>, pub prop_refs: BTreeMap<String, String>,
/// Free-form plugin / agent data. /// Free-form plugin / agent data.
#[serde(default)] #[serde(default, skip_serializing_if = "is_default")]
pub plugin_data: BTreeMap<String, String>, pub plugin_data: BTreeMap<String, String>,
} }
@ -422,6 +422,26 @@ fn identity() -> Affine {
Affine::IDENTITY Affine::IDENTITY
} }
// Fields at their default value are left out of the JSON (the file is several times smaller and
// faster to write and read); reading fills them back in from the same defaults.
/// Whether `v` is its type's default (what `#[serde(default)]` reads back for a missing field).
pub(crate) fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
fn is_yes(v: &bool) -> bool {
*v == yes()
}
fn is_one(v: &f32) -> bool {
*v == one()
}
fn is_pass(v: &BlendMode) -> bool {
*v == pass()
}
fn is_identity(v: &Affine) -> bool {
*v == identity()
}
impl Node { impl Node {
/// A node with Figma's defaults for its kind. /// A node with Figma's defaults for its kind.
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self { pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self {

View File

@ -276,16 +276,25 @@ pub struct TextData {
pub characters: String, pub characters: String,
pub style: TextStyle, pub style: TextStyle,
/// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted.
#[serde(skip_serializing_if = "crate::node::is_default")]
pub runs: Vec<StyleRun>, pub runs: Vec<StyleRun>,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub auto_resize: TextAutoResize, pub auto_resize: TextAutoResize,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub align_h: TextAlignH, pub align_h: TextAlignH,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub align_v: TextAlignV, pub align_v: TextAlignV,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_spacing: f64, pub paragraph_spacing: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_indent: f64, pub paragraph_indent: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub list: ListStyle, pub list: ListStyle,
/// Max lines when truncating (0 = as many as fit). /// Max lines when truncating (0 = as many as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32, pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false). /// OpenType feature toggles (e.g. "liga": false).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>, pub features: std::collections::BTreeMap<String, bool>,
} }

View File

@ -0,0 +1,204 @@
//! `document.json` at scale: a large document round-trips exactly, layer fields at their defaults
//! are left out (and come back), files that spell every field out still open, and the header is
//! read without building the document.
use figcraft_doc::color::{BlendMode, Color, Paint};
use figcraft_doc::geom::Affine;
use figcraft_doc::{
AutoLayout, ChildLayout, Document, Effect, EffectKind, ExportSetting, Node, NodeKind, Shadow, Sizing, StrokeAlign, TextAlignH, TextData,
};
use super::*;
/// About `screens` × 25 layers of every common kind, with most layer fields off their defaults
/// somewhere.
fn large(screens: usize) -> Document {
let mut d = Document::new("Large");
let page = d.first_page().unwrap();
let button =
d.create(page, NodeKind::Component { description: "A button".into(), props: Default::default() }, "Button", 0.0, -80.0, 96.0, 32.0).unwrap();
let label = d.create(button, NodeKind::Text(TextData::new("OK")), "Label", 8.0, 8.0, 40.0, 16.0).unwrap();
d.get_mut(label).unwrap().plugin_data.insert("agent".into(), "note".into());
for s in 0..screens {
let screen = d.create(page, NodeKind::Frame, &format!("Screen {s}"), (s % 20) as f64 * 400.0, (s / 20) as f64 * 300.0, 360.0, 240.0).unwrap();
let n = d.get_mut(screen).unwrap();
n.clip = true;
n.layout = Some(AutoLayout { gap: 6.0, padding: [4.0; 4], ..AutoLayout::default() });
n.exports.push(ExportSetting::default());
n.corners.radii = [4.0, 0.0, 4.0, 0.0];
for k in 0..20 {
let kind = match k % 4 {
0 => NodeKind::Rectangle,
1 => NodeKind::Ellipse { start: 0.5, sweep: 3.0, inner: 0.25 },
2 => {
let mut t = TextData::new(format!("Item {k}\nsecond line"));
t.align_h = TextAlignH::Center;
t.paragraph_spacing = 4.0;
t.features.insert("liga".into(), false);
NodeKind::Text(t)
}
_ => NodeKind::Line,
};
let id = d.create(screen, kind, &format!("Layer {k}"), 10.0 + k as f64 * 16.0, 50.0, 14.0, 14.0).unwrap();
let n = d.get_mut(id).unwrap();
match k % 5 {
0 => {
n.opacity = 0.5;
n.blend = BlendMode::Multiply;
}
1 => {
n.visible = false;
n.locked = true;
}
2 => {
n.transform = Affine::rotate(0.3) * n.transform;
n.strokes = vec![Paint::solid(Color::from_hex("336699").unwrap())];
n.stroke.weight = 2.0;
n.stroke.align = StrokeAlign::Center;
n.stroke.dashes = vec![2.0, 1.0];
}
3 => {
n.effects.push(Effect {
kind: EffectKind::DropShadow(Shadow { offset: (1.0, 2.0), radius: 3.0, ..Shadow::default() }),
visible: true,
});
n.child_layout = ChildLayout { sizing_h: Sizing::Fill, min_w: Some(4.0), ..ChildLayout::default() };
n.constrain_proportions = true;
}
_ => {
n.is_mask = k % 2 == 0;
n.fills.clear();
}
}
}
let props = [("Label".to_string(), json!("Go"))].into_iter().collect();
let inst = d.create(screen, NodeKind::Instance { component: button, props }, "Button", 10.0, 200.0, 96.0, 32.0).unwrap();
let n = d.get_mut(inst).unwrap();
n.source = Some(button);
n.overrides.insert("fills".into());
n.prop_refs.insert("visible".into(), "Show".into());
let l = d.create(inst, NodeKind::Text(TextData::new("Go")), "Label", 8.0, 8.0, 40.0, 16.0).unwrap();
d.get_mut(l).unwrap().source = Some(label);
}
d
}
/// Every layer of `a` is in `b`, identical, and vice versa; the document-level fields too.
fn assert_same_doc(a: &Document, b: &Document) {
assert_eq!(a.node_count(), b.node_count());
for n in a.nodes() {
assert_eq!(Some(n), b.node(n.id), "layer {} `{}`", n.id.0, n.name);
}
assert_eq!((&a.name, &a.pages, &a.styles, &a.variables, &a.current_page), (&b.name, &b.pages, &b.styles, &b.variables, &b.current_page));
}
#[test]
fn large_document_roundtrips() {
let d = large(200);
assert!(d.node_count() > 4000);
let bytes = save_bytes(&d, None).unwrap();
let (back, issues) = load_bytes(&bytes).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert_same_doc(&d, &back);
// Saving what was read writes the same document again.
let again = save_bytes(&back, None).unwrap();
let json = |b: &[u8]| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(b)).unwrap();
read_member(&mut z, "document.json", MAX_MEMBER).unwrap()
};
assert_eq!(json(&bytes), json(&again));
// The streamed JSON and the JSON value the document converts to hold the same document (the
// value spells `f32`s out at `f64` precision).
let mut streamed = Vec::new();
write_json(&d, &mut streamed).unwrap();
let (a, _) = from_json(std::str::from_utf8(&streamed).unwrap()).unwrap();
let (b, _) = from_json(&serde_json::to_string(&to_json(&d).unwrap()).unwrap()).unwrap();
assert_same_doc(&a, &b);
assert_same_doc(&d, &a);
}
#[test]
fn defaults_are_left_out_and_come_back() {
let mut d = Document::new("Defaults");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "Plain", 5.0, 0.0, 10.0, 10.0).unwrap();
let t = d.create(p, NodeKind::Text(TextData::new("Hi")), "Text", 0.0, 20.0, 10.0, 10.0).unwrap();
let v = to_json(&d).unwrap();
let node = |id: figcraft_doc::NodeId| v["nodes"][id.0.to_string()].as_object().unwrap().clone();
let plain = node(r);
for field in [
"visible",
"locked",
"opacity",
"children",
"strokes",
"stroke",
"effects",
"corners",
"constraints",
"layout",
"child_layout",
"clip",
"is_mask",
"grids",
"exports",
"interactions",
"overflow",
"scroll_position",
"styles",
"bound",
"var_modes",
"constrain_proportions",
"source",
"overrides",
"prop_refs",
"plugin_data",
] {
assert!(!plain.contains_key(field), "`{field}` is at its default and left out");
}
for field in ["id", "name", "type", "parent", "transform", "width", "height", "fills", "blend"] {
assert!(plain.contains_key(field), "`{field}` is written");
}
let text = node(t);
for field in ["runs", "paragraph_spacing", "paragraph_indent", "list", "max_lines", "features"] {
assert!(!text.contains_key(field), "text `{field}` is at its default and left out");
}
assert!(text.contains_key("characters") && text.contains_key("style"));
let (back, _) = from_json(&serde_json::to_string(&v).unwrap()).unwrap();
assert_same_doc(&d, &back);
}
#[test]
fn files_with_every_field_spelled_out_still_open() {
// A layer as earlier versions wrote it: every field, defaults included.
let old = r#"{"format":"figcraft","version":1,"imageIndex":[],"name":"Old","pages":[1],"next_id":3,"current_page":1,
"nodes":{
"1":{"id":1,"name":"Page 1","type":"page","parent":null,"children":[2],"visible":true,"locked":false,"opacity":1.0,"blend":"passThrough","transform":[1.0,0.0,0.0,1.0,0.0,0.0],"width":0.0,"height":0.0,"fills":[],"strokes":[],"effects":[]},
"2":{"id":2,"name":"Card background","type":"rectangle","parent":1,"children":[],"visible":true,"locked":false,"opacity":1.0,"blend":"normal","transform":[1.0,0.0,0.0,1.0,0.0,0.0],"width":160.0,"height":64.0,"fills":[{"type":"solid","color":{"r":0.93333334,"g":0.95686275,"b":1.0,"a":1.0},"opacity":1.0,"visible":true,"blend":"normal"}],"strokes":[],"stroke":{"weight":1.0,"align":"inside","sides":null,"cap_start":"none","cap_end":"none","join":"miter","miter_limit":4.0,"dashes":[]},"effects":[],"corners":{"radii":[0.0,0.0,0.0,0.0],"smoothing":0.0},"constraints":{"horizontal":"left","vertical":"top"},"layout":null,"child_layout":{"sizing_h":"fixed","sizing_v":"fixed","absolute":false,"min_w":null,"max_w":null,"min_h":null,"max_h":null,"grid_column":null,"grid_row":null,"grid_column_span":0,"grid_row_span":0},"clip":false,"is_mask":false,"grids":[],"exports":[],"interactions":[],"overflow":"none","scroll_position":"scrolls","styles":{"fill":null,"stroke":null,"effect":null,"grid":null,"text":null},"bound":{},"var_modes":{},"constrain_proportions":false,"source":null,"overrides":[],"prop_refs":{},"plugin_data":{}}
}}"#;
let (d, issues) = load_bytes(old.as_bytes()).unwrap();
assert!(issues.is_empty(), "{issues:?}");
let n: &Node = d.node(figcraft_doc::NodeId(2)).unwrap();
assert_eq!((n.name.as_str(), n.width, n.height, n.visible, n.opacity), ("Card background", 160.0, 64.0, true, 1.0));
assert_eq!(n.fills.len(), 1);
// Written again, it's the short form, and reads back the same.
let (again, _) = from_json(&serde_json::to_string(&to_json(&d).unwrap()).unwrap()).unwrap();
assert_same_doc(&d, &again);
}
#[test]
fn the_header_is_read_on_its_own() {
// A newer file is refused before its document is looked at, whatever the version's size.
assert!(matches!(from_json(r#"{"nodes": "not a map", "version": 2}"#), Err(FormatError::Newer(2))));
assert!(matches!(from_json(r#"{"version": 99999999999}"#), Err(FormatError::Newer(u32::MAX))));
// A version that isn't a number is no version; anything but an object is no document.
let d = serde_json::to_string(&to_json(&Document::new("x")).unwrap()).unwrap();
let odd = d.replacen("\"version\":1", "\"version\":\"one\"", 1);
assert!(from_json(&odd).is_ok());
for junk in ["[]", "[2]", "3", "null", "\"doc\"", "{", ""] {
assert!(matches!(from_json(junk), Err(FormatError::Corrupt(_))), "{junk}");
}
// The image index is read from the header too.
let (_, _, index) = parse_json(&d.replacen("\"imageIndex\":[]", "\"imageIndex\":[{\"hash\":\"ab\"},{\"nope\":1}]", 1)).unwrap();
assert_eq!(index, Some(["ab".to_string()].into_iter().collect()));
}

View File

@ -1,7 +1,8 @@
//! The native `.figcraft` format. //! The native `.figcraft` format.
//! //!
//! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version` //! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
//! header), `images/<sha256>` for every image the document (or a saved version) uses, an optional //! header; layer fields at their default values are left out and read back as those defaults),
//! `images/<sha256>` for every image the document (or a saved version) uses, an optional
//! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id, //! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id,
//! name, description, time, images}]}`, oldest first) plus one `versions/<id>.json` snapshot per //! name, description, time, images}]}`, oldest first) plus one `versions/<id>.json` snapshot per
//! version (a `document.json` of its own). A published file also holds its library (see //! version (a `document.json` of its own). A published file also holds its library (see
@ -22,6 +23,8 @@ use std::io::{Read, Seek, Write};
use std::sync::Arc; use std::sync::Arc;
use figcraft_doc::{Document, ImageData}; use figcraft_doc::{Document, ImageData};
use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
use serde::{Deserialize, Serialize, Serializer};
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::Digest; use sha2::Digest;
@ -39,6 +42,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000;
const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
const VERSION_INDEX: &str = "versions/index.json"; const VERSION_INDEX: &str = "versions/index.json";
/// Deflate level of JSON members (see [`json_options`]).
const JSON_LEVEL: i64 = 3;
#[derive(Debug)] #[derive(Debug)]
pub enum FormatError { pub enum FormatError {
@ -168,16 +173,43 @@ pub(crate) fn image_data(data: Vec<u8>) -> ImageData {
ImageData { bytes: Arc::new(data), width: w, height: h } ImageData { bytes: Arc::new(data), width: w, height: h }
} }
/// `document.json`: the format header, the image index and the document's own fields, written
/// straight from the document (no intermediate JSON tree).
#[derive(Serialize)]
struct DocumentJson<'a> {
format: &'static str,
version: u32,
#[serde(rename = "imageIndex", serialize_with = "image_index")]
images: &'a BTreeMap<String, ImageData>,
#[serde(flatten)]
doc: &'a Document,
}
impl<'a> DocumentJson<'a> {
fn new(doc: &'a Document) -> Self {
Self { format: FORMAT, version: VERSION, images: &doc.images, doc }
}
}
/// The `imageIndex`: `[{hash, width, height}]` for every image the document holds.
fn image_index<S: Serializer>(images: &&BTreeMap<String, ImageData>, s: S) -> Result<S::Ok, S::Error> {
#[derive(Serialize)]
struct Entry<'a> {
hash: &'a str,
width: u32,
height: u32,
}
s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height }))
}
/// Serialize the document (without images) to the JSON value stored in `document.json`. /// Serialize the document (without images) to the JSON value stored in `document.json`.
pub fn to_json(doc: &Document) -> Result<Value, FormatError> { pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
let mut v = serde_json::to_value(doc).map_err(|e| FormatError::Io(e.to_string()))?; serde_json::to_value(DocumentJson::new(doc)).map_err(io_err)
if let Some(o) = v.as_object_mut() { }
o.insert("format".into(), json!(FORMAT));
o.insert("version".into(), json!(VERSION)); /// Write the document (without images) as `document.json` content to `w`.
let images: Vec<Value> = doc.images.iter().map(|(k, i)| json!({"hash": k, "width": i.width, "height": i.height})).collect(); pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
o.insert("imageIndex".into(), Value::Array(images)); serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err)
}
Ok(v)
} }
/// Parse `document.json` content. /// Parse `document.json` content.
@ -188,24 +220,62 @@ pub fn from_json(text: &str) -> Result<(Document, Vec<String>), FormatError> {
/// The image hashes a `document.json` lists in its `imageIndex` (`None` without one). /// The image hashes a `document.json` lists in its `imageIndex` (`None` without one).
type ImageIndex = Option<BTreeSet<String>>; type ImageIndex = Option<BTreeSet<String>>;
/// Parse `document.json` content; also returns its [`ImageIndex`]. /// The header of a `document.json` (its format version and image index), read in a pass that
/// skips over everything else without building it.
#[derive(Default)]
struct Header {
version: Option<u64>,
images: ImageIndex,
}
impl<'de> Deserialize<'de> for Header {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct HeaderVisitor;
impl<'de> Visitor<'de> for HeaderVisitor {
type Value = Header;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a FigCraft document object")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Header, A::Error> {
let mut h = Header::default();
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"version" => h.version = map.next_value::<Value>()?.as_u64(),
"imageIndex" => {
h.images = map
.next_value::<Value>()?
.as_array()
.map(|a| a.iter().filter_map(|i| i.get("hash").and_then(Value::as_str).map(str::to_string)).collect());
}
_ => {
map.next_value::<IgnoredAny>()?;
}
}
}
Ok(h)
}
}
d.deserialize_map(HeaderVisitor)
}
}
/// Parse `document.json` content; also returns its [`ImageIndex`]. The header is checked first
/// (a newer file is refused before its document is read), then the document is deserialized
/// straight from the text.
fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatError> { fn parse_json(text: &str) -> Result<(Document, Vec<String>, ImageIndex), FormatError> {
let v: Value = serde_json::from_str(text).map_err(|e| FormatError::Corrupt(e.to_string()))?; let corrupt = |e: serde_json::Error| FormatError::Corrupt(e.to_string());
if let Some(ver) = v.get("version").and_then(Value::as_u64) let header: Header = serde_json::from_str(text).map_err(corrupt)?;
if let Some(ver) = header.version
&& ver > u64::from(VERSION) && ver > u64::from(VERSION)
{ {
return Err(FormatError::Newer(ver as u32)); return Err(FormatError::Newer(u32::try_from(ver).unwrap_or(u32::MAX)));
} }
let index = v let mut doc: Document = serde_json::from_str(text).map_err(corrupt)?;
.get("imageIndex")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|i| i.get("hash").and_then(Value::as_str).map(str::to_string)).collect());
let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let issues = doc.validate(); let issues = doc.validate();
if !issues.is_empty() { if !issues.is_empty() {
doc.repair(); doc.repair();
} }
Ok((doc, issues, index)) Ok((doc, issues, header.images))
} }
fn io_err(e: impl std::fmt::Display) -> FormatError { fn io_err(e: impl std::fmt::Display) -> FormatError {
@ -218,6 +288,21 @@ fn put<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, opts: zip::write:
z.write_all(bytes).map_err(io_err) z.write_all(bytes).map_err(io_err)
} }
/// Add `doc` as a `document.json`-style member, serialized straight into the compressor.
fn put_json<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, doc: &Document) -> Result<(), FormatError> {
z.start_file(name, json_options()).map_err(io_err)?;
// The serializer writes many small pieces: batch them before they reach the compressor.
let mut w = std::io::BufWriter::with_capacity(1 << 16, z);
write_json(doc, &mut w)?;
w.flush().map_err(io_err)
}
/// How JSON members are compressed. Document JSON is very repetitive: a fast level compresses it
/// nearly as well as the default one (about 15% larger) in half the time.
pub(crate) fn json_options() -> zip::write::SimpleFileOptions {
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(JSON_LEVEL))
}
/// Write a `.figcraft` archive to bytes. /// Write a `.figcraft` archive to bytes.
pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result<Vec<u8>, FormatError> { pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result<Vec<u8>, FormatError> {
save_bytes_with_versions(doc, thumbnail_png, &[]) save_bytes_with_versions(doc, thumbnail_png, &[])
@ -228,9 +313,8 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
let mut buf = std::io::Cursor::new(Vec::new()); let mut buf = std::io::Cursor::new(Vec::new());
{ {
let mut z = zip::ZipWriter::new(&mut buf); let mut z = zip::ZipWriter::new(&mut buf);
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
put(&mut z, "document.json", deflated, &serde_json::to_vec(&to_json(doc)?).map_err(io_err)?)?; put_json(&mut z, "document.json", doc)?;
// Every image once, whether the document or a version uses it. // Every image once, whether the document or a version uses it.
let mut written = BTreeSet::new(); let mut written = BTreeSet::new();
for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).flatten() { for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).flatten() {
@ -250,8 +334,8 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
} }
let name = format!("versions/{}.json", v.id); let name = format!("versions/{}.json", v.id);
match &v.snapshot { match &v.snapshot {
Snapshot::Doc(d) => put(&mut z, &name, deflated, &serde_json::to_vec(&to_json(d)?).map_err(io_err)?)?, Snapshot::Doc(d) => put_json(&mut z, &name, d)?,
Snapshot::Stored { json, .. } => put(&mut z, &name, deflated, json)?, Snapshot::Stored { json, .. } => put(&mut z, &name, json_options(), json)?,
} }
let images: Vec<&String> = v.images().keys().collect(); let images: Vec<&String> = v.images().keys().collect();
let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images}); let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images});
@ -260,7 +344,7 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
} }
index.push(entry); index.push(entry);
} }
put(&mut z, VERSION_INDEX, deflated, &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?; put(&mut z, VERSION_INDEX, json_options(), &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?;
} }
if let Some(lib) = &doc.published { if let Some(lib) = &doc.published {
library::write(&mut z, lib, &written)?; library::write(&mut z, lib, &written)?;
@ -393,6 +477,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
load_bytes(&bytes) load_bytes(&bytes)
} }
#[cfg(test)]
mod large_tests;
#[cfg(test)] #[cfg(test)]
mod version_tests; mod version_tests;

View File

@ -15,7 +15,7 @@ use std::sync::Arc;
use figcraft_doc::{ImageData, LibraryManifest, NodeKind, PublishedLibrary, used_images}; use figcraft_doc::{ImageData, LibraryManifest, NodeKind, PublishedLibrary, used_images};
use crate::{FormatError, MAX_MEMBER, from_json, image_data, io_err, put, read_member, to_json}; use crate::{FormatError, MAX_MEMBER, from_json, image_data, io_err, json_options, put, put_json, read_member};
const MANIFEST: &str = "library/manifest.json"; const MANIFEST: &str = "library/manifest.json";
const SNAPSHOT: &str = "library/document.json"; const SNAPSHOT: &str = "library/document.json";
@ -45,10 +45,9 @@ fn is_valid_hash(h: &str) -> bool {
/// Write the published library's entries. `written` are the images already in `images/`. /// Write the published library's entries. `written` are the images already in `images/`.
pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> { pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> {
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
put(z, MANIFEST, deflated, &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?; put(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
put(z, SNAPSHOT, deflated, &serde_json::to_vec(&to_json(&lib.doc)?).map_err(io_err)?)?; put_json(z, SNAPSHOT, &lib.doc)?;
for (hash, img) in &lib.doc.images { for (hash, img) in &lib.doc.images {
if !written.contains(hash) && is_valid_hash(hash) { if !written.contains(hash) && is_valid_hash(hash) {
put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?; put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?;

View File

@ -9,8 +9,8 @@ use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign};
use figcraft_geom::{Affine, BezPath, Rect, Shape}; use figcraft_geom::{Affine, BezPath, Rect, Shape};
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform}; use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use crate::fx;
use crate::paint::{prepare, ts_blend}; use crate::paint::{prepare, ts_blend};
use crate::{fx, lod};
/// Shared state for one render. /// Shared state for one render.
pub struct Ctx<'a> { pub struct Ctx<'a> {
@ -20,6 +20,8 @@ pub struct Ctx<'a> {
pub outline_mode: bool, pub outline_mode: bool,
/// Nodes not to draw (e.g. text being edited draws through the editor). /// Nodes not to draw (e.g. text being edited draws through the editor).
pub skip: &'a [NodeId], pub skip: &'a [NodeId],
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
pub lod: bool,
} }
pub fn ts_transform(a: Affine) -> Transform { pub fn ts_transform(a: Affine) -> Transform {
@ -253,9 +255,15 @@ fn device_rect(xf: Affine, r: Rect) -> Rect {
figcraft_geom::transform_rect_bbox(xf, r) figcraft_geom::transform_rect_bbox(xf, r)
} }
/// The area text may paint without its glyphs being looked at: its box, generously grown.
fn text_box_bounds(n: &Node) -> Rect {
n.local_rect().inflate(n.height.max(10.0), n.height.max(10.0) * 0.5)
}
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children). /// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible. /// Text that overflows its box counts where its glyphs are, so it isn't culled while visible;
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize) -> Rect { /// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`].
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
let reach = n.paint_reach(); let reach = n.paint_reach();
let mut r = n.local_rect().inflate(reach, reach); let mut r = n.local_rect().inflate(reach, reach);
let clips = n.clip && n.kind.is_frame_like(); let clips = n.clip && n.kind.is_frame_like();
@ -264,13 +272,16 @@ fn local_paint_bounds(doc: &Document, n: &Node, depth: usize) -> Rect {
if !c.visible { if !c.visible {
continue; continue;
} }
let cb = local_paint_bounds(doc, c, depth + 1); let cb = local_paint_bounds(doc, c, depth + 1, greek_scale);
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb)); r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
} }
} }
// Text can overflow its box. // Text can overflow its box.
if let NodeKind::Text(_) = n.kind { if let NodeKind::Text(_) = n.kind {
r = r.inflate(n.height.max(10.0), n.height.max(10.0) * 0.5); r = r.union(text_box_bounds(n).inflate(reach, reach));
if greek_scale.is_some_and(|s| lod::greeked(n, s)) {
return r;
}
if let Some((l, dy)) = figcraft_text::layout_node(n) if let Some((l, dy)) = figcraft_text::layout_node(n)
&& let Some(ink) = l.ink_bounds() && let Some(ink) = l.ink_bounds()
{ {
@ -299,7 +310,7 @@ pub fn effect_margin(n: &Node) -> f64 {
pub fn paint_bounds(doc: &Document, id: NodeId) -> Option<Rect> { pub fn paint_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?; let n = doc.node(id)?;
let m = effect_margin(n).abs(); let m = effect_margin(n).abs();
let local = local_paint_bounds(doc, n, 0); let local = local_paint_bounds(doc, n, 0, None);
Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), local).inflate(m, m)) Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), local).inflate(m, m))
} }
@ -329,7 +340,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
} }
let xf = parent_xf * n.transform; let xf = parent_xf * n.transform;
let (pw, ph) = (pix.width(), pix.height()); let (pw, ph) = (pix.width(), pix.height());
let local = local_paint_bounds(ctx.doc, n, 0); let local = local_paint_bounds(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale));
let dev = device_rect(xf, local); let dev = device_rect(xf, local);
// Effects that read outside (blur) need a margin when culling. // Effects that read outside (blur) need a margin when culling.
let blur_margin = effect_margin(n) * ctx.scale; let blur_margin = effect_margin(n) * ctx.scale;
@ -341,6 +352,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
draw_outline(ctx, pix, n, xf, depth); draw_outline(ctx, pix, n, xf, depth);
return; return;
} }
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) {
lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth);
return;
}
if !needs_layer(n) { if !needs_layer(n) {
draw_content(ctx, pix, n, xf, depth); draw_content(ctx, pix, n, xf, depth);
return; return;
@ -497,6 +512,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
draw_children(ctx, pix, n, xf, depth); draw_children(ctx, pix, n, xf, depth);
} }
NodeKind::Group => draw_children(ctx, pix, n, xf, depth), NodeKind::Group => draw_children(ctx, pix, n, xf, depth),
NodeKind::Text(_) if ctx.lod && lod::greeked(n, ctx.scale) => lod::greek_text(pix, n, xf, text_box_bounds(n)),
NodeKind::Text(t) => { NodeKind::Text(t) => {
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return }; let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
let gxf = xf * Affine::translate((0.0, dy)); let gxf = xf * Affine::translate((0.0, dy));
@ -528,16 +544,19 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize)
let _ = t; let _ = t;
} }
_ => { _ => {
let outline = node_outline(doc, n);
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
if let Some((path, rule)) = &outline let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf);
let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty();
// The outline fills, clips children to rounded corners and strokes booleans.
let outline = if splatted && !(clips && n.corners.uniform() != Some(0.0)) { None } else { node_outline(doc, n) };
if !splatted
&& let Some((path, rule)) = &outline
&& closed && closed
&& let Some(tp) = ts_path(path) && let Some(tp) = ts_path(path)
{ {
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None); fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None);
} }
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() { if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
let clips = n.clip && n.kind.is_frame_like();
if clips { if clips {
draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p)); draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p));
} else { } else {
@ -629,7 +648,11 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
draw_children(ctx, &mut layer, n, lxf, depth); draw_children(ctx, &mut layer, n, lxf, depth);
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf); let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
if rect_clip { if rect_clip {
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); if ctx.lod {
lod::blit_over(pix, lx, ly, &layer);
} else {
pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
return; return;
} }
if let Some(cp) = clip.and_then(ts_path) if let Some(cp) = clip.and_then(ts_path)

View File

@ -8,6 +8,7 @@
pub mod draw; pub mod draw;
pub mod fx; pub mod fx;
pub mod images; pub mod images;
pub mod lod;
pub mod paint; pub mod paint;
pub mod retained; pub mod retained;
@ -47,6 +48,10 @@ pub struct Options {
pub outline_mode: bool, pub outline_mode: bool,
/// Nodes to leave out (an editor drawing them itself). /// Nodes to leave out (an editor drawing them itself).
pub skip: Vec<NodeId>, pub skip: Vec<NodeId>,
/// A preview (the canvas): when zoomed out, approximate what's too small on screen to see in
/// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no
/// effect in outline mode.
pub lod: bool,
} }
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> { fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
@ -117,16 +122,18 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands
}) })
.collect(); .collect();
let mut out = new_pixmap(view.width, view.height)?; let mut out = new_pixmap(view.width, view.height)?;
let row = out.width() as usize * 4;
for (y0, part) in parts { for (y0, part) in parts {
let Some(part) = part else { continue }; let Some(part) = part else { continue };
out.draw_pixmap( // Bands are as wide as the view: their rows go straight into place.
0, let start = y0 as usize * row;
y0 as i32, let dst = out.data_mut();
part.as_ref(), let n = part.data().len().min(dst.len().saturating_sub(start));
&tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() }, if part.width() == view.width.clamp(1, 16_384)
tiny_skia::Transform::identity(), && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n))
None, {
); d.copy_from_slice(s);
}
} }
Some(out) Some(out)
} }
@ -141,7 +148,8 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
if let Some(bg) = bg { if let Some(bg) = bg {
pix.fill(paint::ts_color(bg)); pix.fill(paint::ts_color(bg));
} }
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip }; let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip, lod };
let xf = view.transform(); let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) }; let Some(p) = doc.node(page) else { return Some(pix) };
for c in &p.children { for c in &p.children {
@ -152,7 +160,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
/// Render several nodes (in z-order) into an existing pixmap through `view`. /// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) { pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[] }; let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[], lod: false };
for id in ids { for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0); draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0);
@ -314,6 +322,8 @@ pub fn png_insert_chunk(png: Vec<u8>, kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
#[cfg(test)] #[cfg(test)]
mod band_tests; mod band_tests;
#[cfg(test)] #[cfg(test)]
mod lod_tests;
#[cfg(test)]
mod retained_tests; mod retained_tests;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

378
crates/render/src/lod.rs Normal file
View File

@ -0,0 +1,378 @@
//! Level of detail for canvas previews ([`crate::Options::lod`]): what a zoomed-out view can't
//! show in detail is drawn approximately, so a page of tens of thousands of layers renders in a
//! frame.
//!
//! - A layer whose whole subtree paints into less than [`TINY_PX`] device pixels each way is
//! *splatted*: every visible layer in it adds its paints' colours, weighted by how much of its
//! box they cover, to the pixels its box overlaps (a box filter). No paths, layers or effects.
//! - Text below [`GREEK_PX`] (its largest font size on screen) is *greeked*: one bar per line,
//! across the line's glyphs, at about the ink density of text.
//!
//! - Small axis-aligned rectangles with solid fills are splatted instead of filled as paths, and
//! rectangular clipping layers are composited without a full blending pipeline.
//!
//! Only views at [`MAX_SCALE`] or below are approximated: 1:1 and closer previews are exact, and
//! so are exports, thumbnails and every other render. Whatever LOD can't approximate faithfully
//! (image paints, masks) takes the exact path at any scale.
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::{Document, ListStyle, Node, NodeKind, StrokeAlign, TextAutoResize, TextData};
use figcraft_geom::{Affine, Rect};
use tiny_skia::Pixmap;
use crate::draw::{Ctx, ts_transform};
/// Views zoomed out further than this (device pixels per unit, at most) use LOD: closer views
/// are always exact.
pub const MAX_SCALE: f64 = 0.5;
/// Subtrees smaller than this on screen (device pixels, each way) are splatted.
pub const TINY_PX: f64 = 2.0;
/// Text whose largest font size is below this on screen (device pixels) is greeked.
pub const GREEK_PX: f64 = 4.0;
/// Share of a line's (or a text box's) area that glyph ink covers.
const TEXT_INK: f32 = 0.25;
/// Height of a greeked line's bar, relative to the font size (about the x-height and a bit).
const BAR_HEIGHT: f64 = 0.55;
/// Where a single line's bar sits in its box, as shares of the box height: from about the
/// x-height down to the baseline of an "auto" line height.
const LINE_BAR: (f64, f64) = (0.35, 0.8);
/// Deepest subtree splatted (deeper ones take the exact path).
const MAX_DEPTH: usize = 64;
/// Largest rectangle (device pixels²) whose fills are splatted rather than filled as a path.
const SMALL_AREA: f64 = 1024.0;
/// Corners rounded by less than this (device pixels) are drawn square when splatting.
const SQUARE_CORNER_PX: f64 = 1.0;
/// The largest font size a text uses.
fn max_font_size(t: &TextData) -> f64 {
t.runs.iter().map(|r| r.style.font_size).fold(t.style.font_size, f64::max)
}
/// Whether text node `n` is greeked at `scale` device pixels per unit.
pub fn greeked(n: &Node, scale: f64) -> bool {
matches!(&n.kind, NodeKind::Text(t) if max_font_size(t) * scale < GREEK_PX)
}
/// The paints of a text: the node's and its runs' own.
fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator<Item = &'a Paint> {
n.fills.iter().chain(t.runs.iter().filter_map(|r| r.style.fills.as_ref()).flatten())
}
/// Whether the subtree of `n` can be splatted: no image paints (their colours aren't known
/// without sampling them) and no masks (which change what their siblings show).
pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_));
let own = match &n.kind {
NodeKind::Text(t) => text_paints(n, t).any(image),
_ => n.fills.iter().any(image),
};
if own || n.strokes.iter().any(image) {
return false;
}
n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible).all(|c| !c.is_mask && splattable(doc, c, depth + 1))
}
/// A paint's average colour as straight RGBA (alpha times the paint's opacity); `None` for
/// paints that draw nothing or can't be averaged (images).
fn paint_color(p: &Paint) -> Option<[f32; 4]> {
if !p.visible || p.opacity <= 0.0 {
return None;
}
let op = p.opacity.clamp(0.0, 1.0);
let c = match &p.kind {
PaintKind::Solid { color } => [color.r, color.g, color.b, color.a],
PaintKind::Gradient(g) => {
let stops = g.sorted_stops();
let n = stops.len().max(1) as f32;
stops.iter().fold([0.0; 4], |acc, s| [acc[0] + s.color.r / n, acc[1] + s.color.g / n, acc[2] + s.color.b / n, acc[3] + s.color.a / n])
}
PaintKind::Image(_) => return None,
};
let c = c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
(c[3] * op > 0.0).then_some([c[0], c[1], c[2], c[3] * op])
}
/// The share of a node's box its fill covers.
fn fill_share(n: &Node) -> f32 {
let (w, h) = (n.width.abs(), n.height.abs());
match &n.kind {
NodeKind::Ellipse { sweep, inner, .. } => {
let arc = (sweep.abs() / std::f64::consts::TAU).min(1.0);
let ring = 1.0 - inner.clamp(0.0, 1.0).powi(2);
(std::f64::consts::FRAC_PI_4 * arc * ring) as f32
}
NodeKind::Polygon { .. } => 0.6,
NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::BooleanOp { .. } => 0.5,
_ => {
// Rounded corners cut (4 − π) r² from the box.
let r = (n.corners.radii.iter().sum::<f64>() / 4.0).clamp(0.0, w.min(h) / 2.0);
let area = w * h;
if area > 0.0 { (1.0 - (4.0 - std::f64::consts::PI) * r * r / area).clamp(0.0, 1.0) as f32 } else { 1.0 }
}
}
}
/// The local box a node's strokes paint in, and the share of it they cover.
fn stroke_box(n: &Node) -> (Rect, f32) {
let wt = n.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or(n.stroke.weight).max(0.0);
let out = match n.stroke.align {
StrokeAlign::Inside => 0.0,
StrokeAlign::Center => wt / 2.0,
StrokeAlign::Outside => wt,
};
// Open paths (lines) are stroked along their length; a line's box has no height of its own.
let closed = figcraft_doc::geometry::is_closed(n);
let out = if closed { out } else { wt / 2.0 };
let r = n.local_rect().inflate(out, out);
let (w, h) = (n.width.abs(), n.height.abs());
let length = if closed { 2.0 * (w + h) } else { w.hypot(h) };
let area = r.area().abs();
let share = if area > 0.0 { (length * wt / area).clamp(0.0, 1.0) } else { 0.0 };
(r, share as f32)
}
/// How much of a text box glyph ink covers, estimated from its characters (no layout).
fn text_share(t: &TextData, w: f64, h: f64) -> f32 {
let area = (w * h).abs();
let fs = max_font_size(t).max(0.0);
let chars = t.characters.chars().filter(|c| !c.is_whitespace()).count() as f64;
// An average glyph is about half an em wide and a line about 1.2 em tall.
let ink = chars * 0.5 * fs * 1.2 * fs;
if area > 0.0 && ink.is_finite() { TEXT_INK * (ink / area).clamp(0.0, 1.0) as f32 } else { 0.0 }
}
/// Fill a small rectangle (frame, component, instance…) by splatting: axis-aligned, its corners
/// rounded by less than [`SQUARE_CORNER_PX`] and only solid, normally blended fills, where a box
/// filter is what anti-aliasing computes anyway, without a path. Returns whether it did.
pub fn splat_fills(pix: &mut Pixmap, n: &Node, xf: Affine) -> bool {
use NodeKind as K;
if !matches!(n.kind, K::Rectangle | K::Frame | K::Component { .. } | K::ComponentSet { .. } | K::Instance { .. } | K::Section { .. })
|| !axis_aligned(xf)
{
return false;
}
let c = xf.as_coeffs();
let radius = n.corners.radii.iter().copied().fold(0.0, f64::max);
let dev = figcraft_geom::transform_rect_bbox(xf, n.local_rect());
let simple =
|p: &Paint| !p.visible || (matches!(p.kind, PaintKind::Solid { .. }) && matches!(p.blend, BlendMode::Normal | BlendMode::PassThrough));
if !(radius * c[0].abs().max(c[3].abs()) < SQUARE_CORNER_PX && dev.area().abs() <= SMALL_AREA && n.fills.iter().all(simple)) {
return false;
}
for color in n.fills.iter().filter_map(paint_color) {
splat(pix, dev, color);
}
true
}
/// Splat node `n` (whose transform is already in `xf`) and its subtree onto `pix`. `clip` is the
/// device rectangle clipping frames above leave visible; `alpha` the opacity they multiply in.
pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Option<Rect>, alpha: f32, depth: usize) {
if depth > 256 || ctx.skip.contains(&n.id) || !n.visible || n.is_mask {
return;
}
let alpha = alpha * n.opacity.clamp(0.0, 1.0);
if alpha <= 0.0 {
return;
}
let local = n.local_rect();
let paint = |pix: &mut Pixmap, r: Rect, paints: &mut dyn Iterator<Item = &Paint>, share: f32| {
for c in paints.filter_map(paint_color) {
splat_local(pix, r, xf, clip, [c[0], c[1], c[2], c[3] * alpha * share]);
}
};
match &n.kind {
NodeKind::Page { .. } | NodeKind::Group => {}
NodeKind::Text(t) => paint(pix, local, &mut text_paints(n, t), text_share(t, n.width, n.height)),
_ => {
if figcraft_doc::geometry::is_closed(n) {
paint(pix, local, &mut n.fills.iter(), fill_share(n));
}
}
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
let clip = if n.clip && n.kind.is_frame_like() {
let own = figcraft_geom::transform_rect_bbox(xf, local);
Some(clip.map_or(own, |c| c.intersect(own)))
} else {
clip
};
for c in n.children.iter().filter_map(|c| ctx.doc.node(*c)) {
splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1);
}
}
if n.has_visible_stroke() && !matches!(n.kind, NodeKind::Text(_)) {
let (r, share) = stroke_box(n);
paint(pix, r, &mut n.strokes.iter(), share);
}
}
/// Whether text is one plain line whose box is exactly its extent (auto width, one paragraph, no
/// runs, lists or indents): greeked from the box alone, without laying it out.
fn single_line(t: &TextData) -> bool {
t.auto_resize == TextAutoResize::WidthAndHeight
&& t.runs.is_empty()
&& t.list == ListStyle::None
&& t.paragraph_indent == 0.0
&& !t.characters.contains(['\n', '\r', '\u{2028}', '\u{2029}'])
}
/// Greek text node `n` (its transform in `xf`): a bar per laid-out line, across the line's
/// glyphs, kept inside `limit` (local; what the node's bounds allow it to paint).
pub fn greek_text(pix: &mut Pixmap, n: &Node, xf: Affine, limit: Rect) {
if let NodeKind::Text(t) = &n.kind
&& single_line(t)
{
if !t.characters.trim().is_empty() {
let bar = Rect::new(0.0, n.height * LINE_BAR.0, n.width, n.height * LINE_BAR.1).intersect(limit);
for c in n.fills.iter().filter_map(paint_color) {
fill_local(pix, bar, xf, [c[0], c[1], c[2], c[3] * TEXT_INK]);
}
}
return;
}
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
for line in &layout.lines {
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
let Some(first) = glyphs.first() else { continue };
let size = glyphs.iter().map(|g| g.size.abs()).fold(0.0, f64::max);
let x0 = glyphs.iter().map(|g| g.x.min(g.x + g.advance)).fold(f64::INFINITY, f64::min);
let x1 = glyphs.iter().map(|g| g.x.max(g.x + g.advance)).fold(f64::NEG_INFINITY, f64::max);
let bar = Rect::new(x0, line.baseline + dy - size * BAR_HEIGHT, x1, line.baseline + dy).intersect(limit);
if !(bar.width() > 0.0 && bar.height() > 0.0) {
continue;
}
let fills = layout.styles.get(first.style).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
for c in fills.iter().filter_map(paint_color) {
fill_local(pix, bar, xf, [c[0], c[1], c[2], c[3] * TEXT_INK]);
}
}
}
/// Whether `xf` keeps rectangles axis-aligned (no rotation or skew).
fn axis_aligned(xf: Affine) -> bool {
let c = xf.as_coeffs();
c[1] == 0.0 && c[2] == 0.0
}
/// Fill local rectangle `r` through `xf` with straight RGBA `color`: a box-filtered splat when
/// it stays axis-aligned, an anti-aliased path otherwise.
fn fill_local(pix: &mut Pixmap, r: Rect, xf: Affine, color: [f32; 4]) {
if axis_aligned(xf) {
splat(pix, figcraft_geom::transform_rect_bbox(xf, r), color);
return;
}
let (Some(rect), Some(c)) = (
tiny_skia::Rect::from_ltrb(r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32),
tiny_skia::Color::from_rgba(color[0], color[1], color[2], color[3]),
) else {
return;
};
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(c), anti_alias: true, ..Default::default() };
pix.fill_rect(rect, &paint, ts_transform(xf), None);
}
/// Splat local rectangle `r` through `xf`, inside `clip`: its device bounding box, with the
/// colour thinned by how much of that box the (possibly rotated) rectangle fills.
fn splat_local(pix: &mut Pixmap, r: Rect, xf: Affine, clip: Option<Rect>, color: [f32; 4]) {
let bbox = figcraft_geom::transform_rect_bbox(xf, r);
let c = xf.as_coeffs();
let area = (r.area() * (c[0] * c[3] - c[1] * c[2])).abs();
let bbox_area = bbox.area().abs();
// Zero-area boxes (lines) cover nothing; they're stroked through `stroke_box`.
if bbox_area.is_nan() || bbox_area <= 0.0 {
return;
}
let fill = (area / bbox_area).clamp(0.0, 1.0) as f32;
let target = clip.map_or(bbox, |c| c.intersect(bbox));
splat(pix, target, [color[0], color[1], color[2], color[3] * fill]);
}
/// Composite straight RGBA `color` over device rectangle `r`, each pixel weighted by how much of
/// it `r` covers (a box filter): sub-pixel layers land as their colour averaged over the pixels
/// they straddle, so they neither vanish nor jump from pixel to pixel as the view moves.
pub fn splat(pix: &mut Pixmap, r: Rect, color: [f32; 4]) {
let a = color[3].clamp(0.0, 1.0);
let (w, h) = (f64::from(pix.width()), f64::from(pix.height()));
let (x0, y0, x1, y1) = (r.x0.max(0.0), r.y0.max(0.0), r.x1.min(w), r.y1.min(h));
if !(a > 0.0 && x0.is_finite() && y0.is_finite() && x1 > x0 && y1 > y0) {
return;
}
// Premultiplied source at full coverage, in 0..=255.
let src = [color[0].clamp(0.0, 1.0) * 255.0, color[1].clamp(0.0, 1.0) * 255.0, color[2].clamp(0.0, 1.0) * 255.0, 255.0];
let opaque = src.map(|c| (c + 0.5) as u8);
let mix = |d: &mut [u8], cov: f32| {
if cov >= 1.0 {
// Fully covered by an opaque colour (the inside of most fills).
d.copy_from_slice(&opaque);
} else if cov > 0.0 {
let keep = 1.0 - cov;
// `as u8` saturates; + 0.5 rounds.
for (d, s) in d.iter_mut().zip(src) {
*d = (s * cov + f32::from(*d) * keep + 0.5) as u8;
}
}
};
let stride = pix.width() as usize * 4;
let data = pix.data_mut();
// Within the pixmap: the casts below stay in range.
let (px0, py0, px1, py1) = (x0.floor() as usize, y0.floor() as usize, x1.ceil() as usize, y1.ceil() as usize);
// How much of the first and last columns the rectangle covers (all of the ones between).
let (first, last) = if px1 - px0 == 1 { ((x1 - x0) as f32, 0.0) } else { ((px0 as f64 + 1.0 - x0) as f32, (x1 - (px1 - 1) as f64) as f32) };
for py in py0..py1 {
let cy = (y1.min(py as f64 + 1.0) - y0.max(py as f64)) as f32 * a;
let Some(row) = data.get_mut(py * stride + px0 * 4..py * stride + px1 * 4) else { continue };
let mut pixels = row.chunks_exact_mut(4);
if let Some(d) = pixels.next() {
mix(d, first * cy);
}
if let Some(d) = pixels.next_back() {
mix(d, last * cy);
}
for d in pixels {
mix(d, cy);
}
}
}
/// Composite premultiplied `src` over `dst` with its top-left at `(x, y)` (source-over, the
/// pixels outside `dst` dropped): what `draw_pixmap` does for a plain layer, without its pipeline
/// set-up, which dominates for the many small layers of a zoomed-out page.
pub fn blit_over(dst: &mut Pixmap, x: i32, y: i32, src: &Pixmap) {
let (dw, dh) = (i64::from(dst.width()), i64::from(dst.height()));
let (sw, sh) = (i64::from(src.width()), i64::from(src.height()));
let (x, y) = (i64::from(x), i64::from(y));
// The overlap, in source columns and rows.
let (c0, c1) = ((-x).max(0), sw.min(dw - x));
let (r0, r1) = ((-y).max(0), sh.min(dh - y));
if c1 <= c0 || r1 <= r0 {
return;
}
let (sstride, dstride) = (sw as usize * 4, dw as usize * 4);
let (c0, c1) = (c0 as usize * 4, c1 as usize * 4);
let dx = (x * 4) as isize;
let s = src.data();
let d = dst.data_mut();
for r in r0..r1 {
let srow = r as usize * sstride;
let drow = (r + y) as usize * dstride;
let (Some(sp), Some(dp)) = (s.get(srow + c0..srow + c1), d.get_mut((drow + c0).wrapping_add_signed(dx)..(drow + c1).wrapping_add_signed(dx)))
else {
continue;
};
for (sp, dp) in sp.chunks_exact(4).zip(dp.chunks_exact_mut(4)) {
let a = sp[3];
if a == 255 {
dp.copy_from_slice(sp);
} else if a > 0 {
let keep = 255 - u32::from(a);
for (d, s) in dp.iter_mut().zip(sp) {
*d = (u32::from(*s) + (u32::from(*d) * keep + 127) / 255).min(255) as u8;
}
}
}
}
}

View File

@ -0,0 +1,308 @@
//! Level of detail: only canvas previews (`Options::lod`) at small scales are approximated, the
//! approximation stays close to the exact render (no holes, same colours on average), and exports
//! never see it.
use std::sync::{Arc, Once};
use figcraft_color::{Color, ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind, TextData};
use figcraft_geom::{Affine, Rect};
use crate::retained_tests::assert_same;
use crate::{Options, Pixmap, Retained, View, lod, render_node, render_view, render_view_single};
fn fonts() {
static ONCE: Once = Once::new();
ONCE.call_once(figcraft_text::scan_system_fonts);
}
fn solid(hex: &str) -> Vec<Paint> {
vec![Paint::solid(Color::from_hex(hex).unwrap())]
}
const COLS: usize = 12;
const ROWS: usize = 8;
/// A page of `COLS` × `ROWS` cards (a clipping frame holding a header bar, a rounded rectangle, an
/// ellipse, a group of small squares and two texts), like a zoomed-out page of screens.
fn cards() -> (Document, NodeId) {
fonts();
let mut d = Document::new("lod");
let page = d.first_page().unwrap();
for i in 0..COLS * ROWS {
let (x, y) = ((i % COLS) as f64 * 220.0, (i / COLS) as f64 * 160.0);
let card = d.create(page, NodeKind::Frame, "Card", x, y, 200.0, 140.0).unwrap();
d.get_mut(card).unwrap().clip = true;
let bar = d.create(card, NodeKind::Rectangle, "Bar", 0.0, 0.0, 200.0, 24.0).unwrap();
d.get_mut(bar).unwrap().fills = solid("2D6CDF");
let tile = d.create(card, NodeKind::Rectangle, "Tile", 12.0, 36.0, 60.0, 60.0).unwrap();
let n = d.get_mut(tile).unwrap();
n.fills = solid(["E8505B", "F9A03F", "35B37E"][i % 3]);
n.corners.radii = [8.0; 4];
let dot = d.create(card, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Dot", 150.0, 40.0, 30.0, 30.0).unwrap();
d.get_mut(dot).unwrap().fills = solid("7A3FD1");
let group = d.create(card, NodeKind::Group, "Squares", 84.0, 100.0, 100.0, 12.0).unwrap();
for k in 0..6 {
let s = d.create(group, NodeKind::Rectangle, "Square", k as f64 * 16.0, 0.0, 12.0, 12.0).unwrap();
d.get_mut(s).unwrap().fills = solid("333333");
}
let mut title = TextData::new(format!("Card number {i}"));
title.style.font_size = 16.0;
d.create(card, NodeKind::Text(title), "Title", 84.0, 36.0, 110.0, 20.0).unwrap();
let mut body = TextData::new("Some body text that wraps onto a second line");
body.auto_resize = figcraft_doc::TextAutoResize::Height;
body.style.font_size = 11.0;
d.create(card, NodeKind::Text(body), "Body", 84.0, 60.0, 110.0, 30.0).unwrap();
}
(d, page)
}
fn lod() -> Options {
Options { lod: true, ..Default::default() }
}
fn view(scale: f64) -> View {
View {
origin: (-20.0, -20.0),
scale,
width: ((COLS as f64 * 220.0 + 40.0) * scale) as u32,
height: ((ROWS as f64 * 160.0 + 40.0) * scale) as u32,
}
}
/// The largest difference between the mean colours of `block` × `block` blocks of two renders,
/// and the mean difference over all channels. A hole (a layer missing) shows as a block whose
/// colour is far off; approximations move colours a little.
fn block_diff(a: &Pixmap, b: &Pixmap, block: u32) -> (f64, f64) {
assert_eq!((a.width(), a.height()), (b.width(), b.height()));
let (w, h) = (a.width(), a.height());
let mut worst: f64 = 0.0;
for by in (0..h).step_by(block as usize) {
for bx in (0..w).step_by(block as usize) {
let mut sums = [0.0f64; 8];
let mut count = 0.0;
for y in by..(by + block).min(h) {
for x in bx..(bx + block).min(w) {
let i = ((y * w + x) * 4) as usize;
for c in 0..4 {
sums[c] += f64::from(a.data()[i + c]);
sums[4 + c] += f64::from(b.data()[i + c]);
}
count += 1.0;
}
}
for c in 0..4 {
worst = worst.max((sums[c] - sums[4 + c]).abs() / count);
}
}
}
let mean = a.data().iter().zip(b.data()).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.data().len() as f64;
(worst, mean)
}
/// Mean brightness (0–255) of a render.
fn lum(p: &Pixmap) -> f64 {
p.data().chunks(4).map(|c| f64::from(c[0]) + f64::from(c[1]) + f64::from(c[2])).sum::<f64>() / (p.data().len() / 4 * 3) as f64
}
#[test]
fn lod_is_off_unless_asked_for() {
assert!(!Options::default().lod);
}
#[test]
fn zoomed_out_previews_are_close_to_exact() {
let (d, p) = cards();
for scale in [0.02, 0.05, 0.12, 0.25] {
let v = view(scale);
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
let approx = render_view(&d, p, &v, &lod()).unwrap();
assert_ne!(exact.data(), approx.data(), "scale {scale}: LOD should kick in");
let (worst, mean) = block_diff(&exact, &approx, 4);
// Sub-pixel shapes are anti-aliased a little heavier on the exact path; holes (missing
// layers) would differ by whole colours.
let (a, b) = (lum(&exact), lum(&approx));
assert!(
worst < 48.0 && mean < 8.0 && (a - b).abs() < 4.0,
"scale {scale}: blocks differ by up to {worst:.1}, {mean:.2} on average, brightness {a:.1} vs {b:.1}"
);
}
}
#[test]
fn close_up_previews_are_exact() {
// Above `MAX_SCALE` (1:1 and closer included) previews are exact.
let (d, p) = cards();
for scale in [lod::MAX_SCALE + 0.01, 1.0, 2.0] {
let v = View { origin: (-10.0, -10.0), scale, width: 480, height: 360 };
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
let approx = render_view(&d, p, &v, &lod()).unwrap();
assert_eq!(exact.data(), approx.data(), "scale {scale}");
}
}
#[test]
fn exports_never_use_lod() {
// An export at a tiny scale is the exact render of the same area, not the preview.
let (d, p) = cards();
let card = d.node(p).unwrap().children[0];
let scale = 0.05;
let png = render_node(&d, card, scale).unwrap();
let b = crate::export_bounds(&d, card).unwrap();
let v = View { origin: (b.x0, b.y0), scale, width: png.width(), height: png.height() };
let mut exact = Pixmap::new(v.width, v.height).unwrap();
crate::render_nodes_into(&d, &[card], &v, &mut exact);
assert_eq!(png.data(), exact.data());
let mut preview = Pixmap::new(v.width, v.height).unwrap();
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, skip: &[], lod: true };
let parent = d.world_transform(p);
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
assert_ne!(png.data(), preview.data(), "the preview of the same area is approximated");
}
#[test]
fn outline_mode_ignores_lod() {
let (d, p) = cards();
let v = view(0.05);
let a = render_view(&d, p, &v, &Options { outline_mode: true, ..Default::default() }).unwrap();
let b = render_view(&d, p, &v, &Options { outline_mode: true, lod: true, ..Default::default() }).unwrap();
assert_eq!(a.data(), b.data());
}
#[test]
fn tiny_layers_keep_their_ink() {
// A sub-pixel black square on white darkens the pixels it straddles by its area, wherever it
// sits on the pixel grid (no layer vanishes or jumps as the view moves).
let mut d = Document::new("tiny");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(r).unwrap().fills = solid("000000");
for offset in [0.0, 0.25, 0.5, 0.8] {
let v = View { origin: (-20.0 - offset * 12.5, -20.0), scale: 0.08, width: 6, height: 6 };
let pix = render_view(&d, p, &v, &Options { lod: true, background: Some(Color::WHITE), ..Default::default() }).unwrap();
let ink: f64 = pix.data().chunks(4).map(|c| f64::from(255 - c[0]) / 255.0).sum();
// 0.8 × 0.8 device pixels.
assert!((ink - 0.64).abs() < 0.02, "offset {offset}: ink {ink}");
}
}
#[test]
fn images_and_masks_take_the_exact_path() {
let mut d = Document::new("exact");
let p = d.first_page().unwrap();
let img = image::RgbaImage::from_fn(8, 8, |x, y| image::Rgba([(x * 30) as u8, (y * 30) as u8, 90, 255]));
let mut bytes = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
d.images.insert("img".into(), ImageData { bytes: Arc::new(bytes), width: 8, height: 8 });
let photo = d.create(p, NodeKind::Rectangle, "Photo", 0.0, 0.0, 20.0, 20.0).unwrap();
d.get_mut(photo).unwrap().fills = vec![Paint::image("img", ImageScaleMode::Fill)];
let group = d.create(p, NodeKind::Group, "Masked", 40.0, 0.0, 20.0, 20.0).unwrap();
let mask = d.create(group, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Mask", 0.0, 0.0, 20.0, 20.0).unwrap();
d.get_mut(mask).unwrap().is_mask = true;
let under = d.create(group, NodeKind::Rectangle, "Under", 0.0, 0.0, 20.0, 20.0).unwrap();
d.get_mut(under).unwrap().fills = solid("FF0000");
for n in [photo, group] {
assert!(!lod::splattable(&d, d.node(n).unwrap(), 0));
}
// The photo renders exactly as in an export.
let v = View { origin: (-2.0, -2.0), scale: 0.06, width: 2, height: 2 };
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
let approx = render_view(&d, p, &v, &lod()).unwrap();
assert_eq!(exact.data(), approx.data());
// The masked group is masked (its content may be approximated under the mask).
let v = View { origin: (38.0, -2.0), scale: 0.06, width: 2, height: 2 };
let exact = render_view(&d, p, &v, &Options::default()).unwrap();
let approx = render_view(&d, p, &v, &lod()).unwrap();
assert_same(&exact, &approx, "masked group");
}
#[test]
fn greeked_text_stays_inside_its_bounds() {
fonts();
let mut d = Document::new("greek");
let p = d.first_page().unwrap();
let mut t = TextData::new("Greeked text on a line\nand a second line");
t.style.font_size = 20.0;
let id = d.create(p, NodeKind::Text(t), "T", 100.0, 100.0, 300.0, 60.0).unwrap();
let n = d.node(id).unwrap();
assert!(lod::greeked(n, 0.1) && !lod::greeked(n, 0.5));
let v = View { origin: (0.0, 0.0), scale: 0.1, width: 60, height: 30 };
let pix = render_view(&d, p, &v, &Options { lod: true, background: Some(Color::WHITE), ..Default::default() }).unwrap();
let bounds = crate::paint_bounds(&d, id).unwrap();
let mut inked = 0;
for (i, c) in pix.data().chunks(4).enumerate() {
if c[0] < 250 {
let (x, y) = ((i % 60) as f64 / 0.1, (i / 60) as f64 / 0.1);
assert!(bounds.inflate(10.0, 10.0).contains((x, y)), "ink at {x},{y} outside {bounds:?}");
inked += 1;
}
}
assert!(inked > 10, "greeked text draws its lines ({inked} pixels)");
}
#[test]
fn retained_previews_match_full_ones() {
// Pans and edits of a zoomed-out preview patch the kept image into what a full preview shows.
let (d, p) = cards();
let doc = Arc::new(d);
let mut r = Retained::default();
let at = |x: f64, y: f64| View { origin: (x, y), scale: 0.07, width: 160, height: 90 };
let check = |r: &mut Retained, doc: &Arc<Document>, v: &View| {
let (shown, pix, work) = r.render(doc, p, v, &lod()).unwrap();
let pix = pix.clone();
let full = render_view(doc, p, &shown, &lod()).unwrap();
assert_same(&pix, &full, &format!("{work:?}"));
work
};
check(&mut r, &doc, &at(0.0, 0.0));
for (x, y) in [(70.0, 0.0), (70.0, 45.0), (-30.0, 90.0)] {
assert!(matches!(check(&mut r, &doc, &at(x, y)), crate::Work::Partial { .. }));
}
let card = doc.node(p).unwrap().children[5];
let mut moved = (*doc).clone();
let n = moved.get_mut(card).unwrap();
n.transform = Affine::translate((30.0, 10.0)) * n.transform;
let moved = Arc::new(moved);
assert!(matches!(check(&mut r, &moved, &at(-30.0, 90.0)), crate::Work::Partial { .. }));
// A preview and an exact render never share a kept image.
let (_, _, work) = r.render(&moved, p, &at(-30.0, 90.0), &Options::default()).unwrap();
assert_eq!(work, crate::Work::Full);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn bands_match_one_piece_previews() {
let (d, p) = cards();
let v = view(0.1);
let one = render_view_single(&d, p, &v, &lod()).unwrap();
for bands in [2, 5] {
let banded = crate::render_bands(&d, p, &v, &lod(), bands).unwrap();
assert_same(&one, &banded, &format!("{bands} bands"));
}
}
#[test]
fn splat_and_blit_survive_junk() {
let mut pix = Pixmap::new(4, 4).unwrap();
for r in [
Rect::new(f64::NAN, 0.0, 1.0, 1.0),
Rect::new(-1e300, -1e300, 1e300, 1e300),
Rect::new(10.0, 10.0, 20.0, 20.0),
Rect::new(3.0, 3.0, 1.0, 1.0),
Rect::new(-5.0, 1.5, 0.5, f64::INFINITY),
] {
lod::splat(&mut pix, r, [0.5, 0.5, 0.5, 1.0]);
lod::splat(&mut pix, r, [f32::NAN, 2.0, -1.0, 0.5]);
}
// An exactly covered pixel takes the colour; half a pixel blends half of it.
let mut pix = Pixmap::new(4, 1).unwrap();
lod::splat(&mut pix, Rect::new(1.0, 0.0, 2.0, 1.0), [1.0, 0.0, 0.0, 1.0]);
lod::splat(&mut pix, Rect::new(2.5, 0.0, 3.0, 1.0), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(&pix.data()[4..8], &[255, 0, 0, 255]);
assert_eq!(&pix.data()[8..12], &[0, 0, 128, 128]);
let src = Pixmap::new(3, 3).unwrap();
let mut dst = Pixmap::new(4, 4).unwrap();
for (x, y) in [(-2, -2), (3, 3), (-10, 0), (0, 10), (i32::MIN, i32::MAX), (i32::MAX, i32::MIN)] {
lod::blit_over(&mut dst, x, y, &src);
}
}

View File

@ -42,6 +42,7 @@ struct Frame {
doc: Arc<Document>, doc: Arc<Document>,
page: NodeId, page: NodeId,
outline: bool, outline: bool,
lod: bool,
skip: Vec<NodeId>, skip: Vec<NodeId>,
background: Option<figcraft_color::Color>, background: Option<figcraft_color::Color>,
scale: u64, scale: u64,
@ -132,6 +133,7 @@ impl Retained {
if let Some(last) = self.last.take() if let Some(last) = self.last.take()
&& last.page == page && last.page == page
&& last.outline == opts.outline_mode && last.outline == opts.outline_mode
&& last.lod == opts.lod
&& last.skip == opts.skip && last.skip == opts.skip
&& last.background == opts.background && last.background == opts.background
&& last.scale == snapped.scale.to_bits() && last.scale == snapped.scale.to_bits()
@ -172,6 +174,7 @@ impl Retained {
doc: doc.clone(), doc: doc.clone(),
page, page,
outline: opts.outline_mode, outline: opts.outline_mode,
lod: opts.lod,
skip: opts.skip.clone(), skip: opts.skip.clone(),
background: opts.background, background: opts.background,
scale: view.scale.to_bits(), scale: view.scale.to_bits(),

View File

@ -92,7 +92,7 @@ fn check(r: &mut Retained, doc: &Arc<Document>, page: NodeId, v: &View) -> Work
/// Equal up to anti-aliasing: a part rendered on its own sees its paths translated by a whole /// Equal up to anti-aliasing: a part rendered on its own sees its paths translated by a whole
/// number of pixels, and float rounding can move an edge sample (a fraction of one pixel's /// number of pixels, and float rounding can move an edge sample (a fraction of one pixel's
/// coverage along a curve). Stale or missing areas differ by whole colours, over many pixels. /// coverage along a curve). Stale or missing areas differ by whole colours, over many pixels.
fn assert_same(a: &Pixmap, b: &Pixmap, what: &str) { pub(crate) fn assert_same(a: &Pixmap, b: &Pixmap, what: &str) {
assert_eq!((a.width(), a.height()), (b.width(), b.height())); assert_eq!((a.width(), a.height()), (b.width(), b.height()));
let (mut slight, mut big, mut first) = (0usize, 0usize, None); let (mut slight, mut big, mut first) = (0usize, 0usize, None);
for (i, (x, y)) in a.data().iter().zip(b.data()).enumerate() { for (i, (x, y)) in a.data().iter().zip(b.data()).enumerate() {

View File

@ -43,7 +43,8 @@ pub fn screen_image(pix: &Pixmap, profile: ColorProfile) -> egui::ColorImage {
fn run(job: &Job, retained: &mut Retained) -> Option<Done> { fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), ..Default::default() }; // The canvas is a preview: what's too small to see in detail at this zoom is approximated.
let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), lod: true, ..Default::default() };
let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?;
let image = screen_image(pix, job.doc.color_profile); let image = screen_image(pix, job.doc.color_profile);
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]