diff --git a/ROADMAP.md b/ROADMAP.md index 400e357..ae80e13 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 → 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 - tests ~26 µs. *Left:* zoomed-out renders at 50k (~40 ms: tiles or levels of detail), save/load - at 50k (~0.7 s each), a real-world file corpus, Windows/Linux/browser QA, accessibility. 40–60 h. + tests ~26 µs. Zoomed out (50% and below) the canvas draws levels of detail: layers under 2 px + 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 -- **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers, - a real-file corpus); responsive presentation. +- **Next:** the fidelity pass (7) and hardening (9: a real-file corpus, platform QA); responsive + presentation. - **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. diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index a7bd3d9..1d955a3 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -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) } -fn render(doc: &Document, page: NodeId, v: &ViewState) -> 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, &Default::default()).is_some() +/// What the canvas renders with: a preview, with levels of detail when zoomed out. +fn canvas() -> figcraft_render::Options { + figcraft_render::Options { lod: true, ..Default::default() } } -/// 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, page: NodeId, v: &ViewState, dx: f64) -> bool { let (ox, oy) = v.origin(); 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 { @@ -99,15 +104,17 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> { table.put(col, "load .figcraft", ms(t)); 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 mut at100 = ViewState { vw: f64::from(VIEW_W), vh: f64::from(VIEW_H), ..Default::default() }; at100.cx = f64::from(VIEW_W) / 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(); 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' // 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). let mut out = fit.clone(); 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)] { let mut r = figcraft_render::Retained::default(); let _ = retained(&mut r, &doc, page, v, 0.0); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index be132cf..2bf0130 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -321,72 +321,72 @@ pub struct Node { pub name: String, #[serde(flatten)] pub kind: NodeKind, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub parent: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub children: Vec, - #[serde(default = "yes")] + #[serde(default = "yes", skip_serializing_if = "is_yes")] pub visible: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub locked: bool, - #[serde(default = "one")] + #[serde(default = "one", skip_serializing_if = "is_one")] pub opacity: f32, - #[serde(default = "pass")] + #[serde(default = "pass", skip_serializing_if = "is_pass")] pub blend: BlendMode, /// 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 width: f64, pub height: f64, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub fills: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub strokes: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub stroke: StrokeProps, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub effects: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub corners: Corners, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub constraints: Constraints, /// Auto layout (frame-like nodes only). - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub layout: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub child_layout: ChildLayout, /// Clip content (frames). - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub clip: bool, /// This node masks the siblings above it. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub is_mask: bool, /// How a mask masks: by its alpha, its outline, or its luminance. #[serde(default, skip_serializing_if = "MaskType::is_alpha")] pub mask_type: MaskType, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub grids: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub exports: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub interactions: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub overflow: Overflow, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub scroll_position: ScrollPosition, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub styles: StyleRefs, /// Variable bindings: field name ("fills/0/color", "width", "cornerRadius", "characters", …) → id. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub bound: BTreeMap, /// Explicit variable modes (collection → mode) set on this container. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub var_modes: BTreeMap, /// Lock aspect ratio when resizing from the panel. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub constrain_proportions: bool, /// Inside an instance: the node of the main component this one mirrors. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub source: Option, /// A nested instance inside a main component whose properties show on the main's instances. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -397,15 +397,15 @@ pub struct Node { pub dev_status: DevStatus, /// Inside an instance: property groups overridden locally ("fills", "characters", …; "slot": /// this slot holds the instance's own content). - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub overrides: BTreeSet, /// Component property references: which property controls this node's /// "visible" (boolean), "characters" (text) or "mainComponent" (instance swap), or which slot /// property this frame is ("slot"). - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub prop_refs: BTreeMap, /// Free-form plugin / agent data. - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub plugin_data: BTreeMap, } @@ -422,6 +422,26 @@ fn identity() -> Affine { 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(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 { /// A node with Figma's defaults for its kind. pub fn new(id: NodeId, kind: NodeKind, name: impl Into, width: f64, height: f64) -> Self { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index f9790b5..1d968f0 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -276,16 +276,25 @@ pub struct TextData { pub characters: String, pub style: TextStyle, /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. + #[serde(skip_serializing_if = "crate::node::is_default")] pub runs: Vec, + #[serde(skip_serializing_if = "crate::node::is_default")] pub auto_resize: TextAutoResize, + #[serde(skip_serializing_if = "crate::node::is_default")] pub align_h: TextAlignH, + #[serde(skip_serializing_if = "crate::node::is_default")] pub align_v: TextAlignV, + #[serde(skip_serializing_if = "crate::node::is_default")] pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] pub list: ListStyle, /// Max lines when truncating (0 = as many as fit). + #[serde(skip_serializing_if = "crate::node::is_default")] pub max_lines: u32, /// OpenType feature toggles (e.g. "liga": false). + #[serde(skip_serializing_if = "crate::node::is_default")] pub features: std::collections::BTreeMap, } diff --git a/crates/format/src/large_tests.rs b/crates/format/src/large_tests.rs new file mode 100644 index 0000000..3ff352a --- /dev/null +++ b/crates/format/src/large_tests.rs @@ -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())); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 78daa77..9bb5763 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1,7 +1,8 @@ //! The native `.figcraft` format. //! //! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version` -//! header), `images/` 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/` for every image the document (or a saved version) uses, an optional //! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id, //! name, description, time, images}]}`, oldest first) plus one `versions/.json` snapshot per //! 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 figcraft_doc::{Document, ImageData}; +use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor}; +use serde::{Deserialize, Serialize, Serializer}; use serde_json::{Value, json}; 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_BYTES: u64 = 1024 * 1024 * 1024; const VERSION_INDEX: &str = "versions/index.json"; +/// Deflate level of JSON members (see [`json_options`]). +const JSON_LEVEL: i64 = 3; #[derive(Debug)] pub enum FormatError { @@ -168,16 +173,43 @@ pub(crate) fn image_data(data: Vec) -> ImageData { 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, + #[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(images: &&BTreeMap, s: S) -> Result { + #[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`. pub fn to_json(doc: &Document) -> Result { - let mut v = serde_json::to_value(doc).map_err(|e| FormatError::Io(e.to_string()))?; - if let Some(o) = v.as_object_mut() { - o.insert("format".into(), json!(FORMAT)); - o.insert("version".into(), json!(VERSION)); - let images: Vec = doc.images.iter().map(|(k, i)| json!({"hash": k, "width": i.width, "height": i.height})).collect(); - o.insert("imageIndex".into(), Value::Array(images)); - } - Ok(v) + serde_json::to_value(DocumentJson::new(doc)).map_err(io_err) +} + +/// Write the document (without images) as `document.json` content to `w`. +pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> { + serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err) } /// Parse `document.json` content. @@ -188,24 +220,62 @@ pub fn from_json(text: &str) -> Result<(Document, Vec), FormatError> { /// The image hashes a `document.json` lists in its `imageIndex` (`None` without one). type ImageIndex = Option>; -/// 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, + images: ImageIndex, +} + +impl<'de> Deserialize<'de> for Header { + fn deserialize>(d: D) -> Result { + 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>(self, mut map: A) -> Result { + let mut h = Header::default(); + while let Some(key) = map.next_key::()? { + match key.as_str() { + "version" => h.version = map.next_value::()?.as_u64(), + "imageIndex" => { + h.images = map + .next_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::()?; + } + } + } + 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, ImageIndex), FormatError> { - let v: Value = serde_json::from_str(text).map_err(|e| FormatError::Corrupt(e.to_string()))?; - if let Some(ver) = v.get("version").and_then(Value::as_u64) + let corrupt = |e: serde_json::Error| FormatError::Corrupt(e.to_string()); + let header: Header = serde_json::from_str(text).map_err(corrupt)?; + if let Some(ver) = header.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 - .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 mut doc: Document = serde_json::from_str(text).map_err(corrupt)?; let issues = doc.validate(); if !issues.is_empty() { doc.repair(); } - Ok((doc, issues, index)) + Ok((doc, issues, header.images)) } fn io_err(e: impl std::fmt::Display) -> FormatError { @@ -218,6 +288,21 @@ fn put(z: &mut zip::ZipWriter, name: &str, opts: zip::write: z.write_all(bytes).map_err(io_err) } +/// Add `doc` as a `document.json`-style member, serialized straight into the compressor. +fn put_json(z: &mut zip::ZipWriter, 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. pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result, FormatError> { 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 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); - 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. let mut written = BTreeSet::new(); 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); match &v.snapshot { - Snapshot::Doc(d) => put(&mut z, &name, deflated, &serde_json::to_vec(&to_json(d)?).map_err(io_err)?)?, - Snapshot::Stored { json, .. } => put(&mut z, &name, deflated, json)?, + Snapshot::Doc(d) => put_json(&mut z, &name, d)?, + Snapshot::Stored { json, .. } => put(&mut z, &name, json_options(), json)?, } 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}); @@ -260,7 +344,7 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve } 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 { library::write(&mut z, lib, &written)?; @@ -393,6 +477,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form load_bytes(&bytes) } +#[cfg(test)] +mod large_tests; #[cfg(test)] mod version_tests; diff --git a/crates/format/src/library.rs b/crates/format/src/library.rs index ae4b980..491fa4c 100644 --- a/crates/format/src/library.rs +++ b/crates/format/src/library.rs @@ -15,7 +15,7 @@ use std::sync::Arc; 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 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/`. pub(crate) fn write(z: &mut zip::ZipWriter, 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); - put(z, MANIFEST, deflated, &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(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?; + put_json(z, SNAPSHOT, &lib.doc)?; for (hash, img) in &lib.doc.images { if !written.contains(hash) && is_valid_hash(hash) { put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?; diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 9c98236..ccfa71a 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -9,8 +9,8 @@ use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign}; use figcraft_geom::{Affine, BezPath, Rect, Shape}; use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform}; -use crate::fx; use crate::paint::{prepare, ts_blend}; +use crate::{fx, lod}; /// Shared state for one render. pub struct Ctx<'a> { @@ -20,6 +20,8 @@ pub struct Ctx<'a> { pub outline_mode: bool, /// Nodes not to draw (e.g. text being edited draws through the editor). 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 { @@ -253,9 +255,15 @@ fn device_rect(xf: Affine, r: Rect) -> Rect { 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). -/// 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 that overflows its box counts where its glyphs are, so it isn't culled while visible; +/// 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) -> Rect { let reach = n.paint_reach(); let mut r = n.local_rect().inflate(reach, reach); 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 { 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)); } } // Text can overflow its box. 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) && 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 { let n = doc.node(id)?; 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)) } @@ -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 (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); // Effects that read outside (blur) need a margin when culling. 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); 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) { draw_content(ctx, pix, n, xf, depth); 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); } 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) => { let Some((layout, dy)) = figcraft_text::layout_node(n) else { return }; 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 outline = node_outline(doc, n); 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 && let Some(tp) = ts_path(path) { fill_with(pix, &tp, &n.fills, w, h, xf, *rule, doc, None); } if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() { - let clips = n.clip && n.kind.is_frame_like(); if clips { draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p)); } 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); let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf); 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; } if let Some(cp) = clip.and_then(ts_path) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 635520d..f3f86ca 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ pub mod draw; pub mod fx; pub mod images; +pub mod lod; pub mod paint; pub mod retained; @@ -47,6 +48,10 @@ pub struct Options { pub outline_mode: bool, /// Nodes to leave out (an editor drawing them itself). pub skip: Vec, + /// 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 { @@ -117,16 +122,18 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands }) .collect(); let mut out = new_pixmap(view.width, view.height)?; + let row = out.width() as usize * 4; for (y0, part) in parts { let Some(part) = part else { continue }; - out.draw_pixmap( - 0, - y0 as i32, - part.as_ref(), - &tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() }, - tiny_skia::Transform::identity(), - None, - ); + // Bands are as wide as the view: their rows go straight into place. + let start = y0 as usize * row; + let dst = out.data_mut(); + let n = part.data().len().min(dst.len().saturating_sub(start)); + if part.width() == view.width.clamp(1, 16_384) + && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n)) + { + d.copy_from_slice(s); + } } Some(out) } @@ -141,7 +148,8 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti if let Some(bg) = 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 Some(p) = doc.node(page) else { return Some(pix) }; 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`. 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 { 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); @@ -314,6 +322,8 @@ pub fn png_insert_chunk(png: Vec, kind: &[u8; 4], data: &[u8]) -> Vec { #[cfg(test)] mod band_tests; #[cfg(test)] +mod lod_tests; +#[cfg(test)] mod retained_tests; #[cfg(test)] mod tests; diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs new file mode 100644 index 0000000..26697c0 --- /dev/null +++ b/crates/render/src/lod.rs @@ -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 { + 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::() / 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, 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, 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, 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; + } + } + } + } +} diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs new file mode 100644 index 0000000..285bb46 --- /dev/null +++ b/crates/render/src/lod_tests.rs @@ -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 { + 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::() / 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::() / (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, 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); + } +} diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index 1003ceb..0fb010f 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -42,6 +42,7 @@ struct Frame { doc: Arc, page: NodeId, outline: bool, + lod: bool, skip: Vec, background: Option, scale: u64, @@ -132,6 +133,7 @@ impl Retained { if let Some(last) = self.last.take() && last.page == page && last.outline == opts.outline_mode + && last.lod == opts.lod && last.skip == opts.skip && last.background == opts.background && last.scale == snapped.scale.to_bits() @@ -172,6 +174,7 @@ impl Retained { doc: doc.clone(), page, outline: opts.outline_mode, + lod: opts.lod, skip: opts.skip.clone(), background: opts.background, scale: view.scale.to_bits(), diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 3919273..8a215af 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -92,7 +92,7 @@ fn check(r: &mut Retained, doc: &Arc, page: NodeId, v: &View) -> Work /// 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 /// 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())); let (mut slight, mut big, mut first) = (0usize, 0usize, None); for (i, (x, y)) in a.data().iter().zip(b.data()).enumerate() { diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index e45093d..aab3cf8 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -43,7 +43,8 @@ pub fn screen_image(pix: &Pixmap, profile: ColorProfile) -> egui::ColorImage { fn run(job: &Job, retained: &mut Retained) -> Option { #[cfg(not(target_arch = "wasm32"))] 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 image = screen_image(pix, job.doc.color_profile); #[cfg(not(target_arch = "wasm32"))]