From 4904303fc220cba105e5f436a1be1cdc5de00b8d Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 18:40:26 +0700 Subject: [PATCH] M8: retained canvas correctness (#89) * M8: retained canvas correctness Drags now show live on the canvas. The canvas used to drop every finished render whose request had been superseded, and it asks for the next render before it polls. So while an edit lands every frame (any drag), no image was ever shown until the pointer was released. Holding the button still made it worse: the same move ran the gesture again each frame, built a new document and asked for a new render, which kept the canvas stuck on the pre-drag image. - Canvas: show every finished render (renders finish in order; the image is re-projected to the current view). The generation filter is gone. - Engine: Session::pointer skips a drag move identical to the last one, and Session::preview keeps the document when the preview changes nothing (a handle held still). - Render: clip and layer rectangles snap edges that are within float noise of a pixel boundary before rounding outwards. An edge exactly on a boundary came out a hair to either side depending on the render's origin, so a clip let a whole column through in some renders and not others. - Tests: a seeded random walk compares the retained render with a full render after every step: edits, undo/redo, reparenting, masks, groups, effects, strokes, text, auto layout, instances, variables, styles, pans and zooms. It found the clip-edge divergence; a minimised regression test covers it. Also a headless UI test for a live drag, and engine tests for skipped repeated moves and unchanged previews. Co-Authored-By: Claude Opus 5.5 * M8: retained canvas tests follow the new blur effect type Layer blurs are `LayerBlur(Blur)` since the progressive blur landed; the random walk now also tries progressive layer blurs. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 9 +- crates/engine/src/gesture_preview_tests.rs | 85 ++++++ crates/engine/src/lib.rs | 9 + crates/engine/src/retained_walk_tests.rs | 286 +++++++++++++++++++++ crates/engine/src/tools.rs | 14 +- crates/render/src/draw.rs | 16 +- crates/render/src/lib.rs | 2 + crates/render/src/retained_edit_tests.rs | 67 +++++ crates/ui-egui/src/canvas.rs | 10 +- crates/ui-egui/src/canvas_drag_tests.rs | 58 +++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/render_worker.rs | 9 +- 12 files changed, 548 insertions(+), 19 deletions(-) create mode 100644 crates/engine/src/gesture_preview_tests.rs create mode 100644 crates/engine/src/retained_walk_tests.rs create mode 100644 crates/render/src/retained_edit_tests.rs create mode 100644 crates/ui-egui/src/canvas_drag_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index e4baf182..51d60fbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -212,7 +212,12 @@ dimension measured side by side. 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. + open). The retained canvas is held to a full render: a seeded random walk (edits, undo/redo, + pans and zooms over nested, rotated and clipping frames, groups, masks, booleans, auto layout, + effects, text, instances, variables and styles) checks every step, and found clip edges on a + pixel boundary letting a column through in some renders. Drags show live on the canvas (every + finished render is shown, not only the newest one asked for), and a button held still no + longer rebuilds the preview and re-renders every frame. *Left:* a real-world file corpus, Windows/Linux/browser QA, accessibility. 30–50 h. ### Where we're going @@ -284,7 +289,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Area | Weight | Score | Remaining work | Hours | |---|---|---|---|---| -| Canvas & navigation | 6 | 80% | visual QA at extreme zooms | 1 | +| Canvas & navigation | 6 | 80% | frame labels along a rotated frame's top edge, visual QA at extreme zooms | 1 | | Selection & transform | 8 | 78% | measuring and transform handles checked side by side, rotated groups and mixed selections QA | 2–4 | | Layers panel | 5 | 88% | visual QA of long lists and deep trees | 0.5–1 | | Shapes & vector | 8 | 76% | live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 4–8 | diff --git a/crates/engine/src/gesture_preview_tests.rs b/crates/engine/src/gesture_preview_tests.rs new file mode 100644 index 00000000..b44c87fd --- /dev/null +++ b/crates/engine/src/gesture_preview_tests.rs @@ -0,0 +1,85 @@ +//! A gesture's live preview makes a new document only when it shows something new: a button held +//! still (the same move every frame) or a preview that changes nothing keeps the document, so the +//! canvas doesn't re-render every frame for nothing. + +use std::sync::Arc; + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{Document, NodeId}; + +fn rect(s: &mut Session, parent: Option) -> NodeId { + let mut p = json!({"x": 40, "y": 40, "width": 80, "height": 60}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap() +} + +fn doc(s: &Session) -> Arc { + s.ds().unwrap().doc.clone() +} + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::at(kind, x, y, 1.0) +} + +#[test] +fn a_drag_held_still_keeps_its_preview() { + let mut s = Session::new(); + // A child of a rotated frame, the case the canvas showed stale while dragging. + let f = id_of(&s.execute("frame.create", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap()["id"]).unwrap(); + s.execute("node.set", &json!({"id": f, "rotation": 25})).unwrap(); + let r = rect(&mut s, Some(f)); + // Snapping would pull the layer onto the frame's edges and centre. + s.ds_mut().unwrap().view.snap_objects = false; + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let c = s.doc().unwrap().world_bounds(r).unwrap().center(); + s.pointer(&ev(PointerKind::Down, c.x, c.y)).unwrap(); + s.pointer(&ev(PointerKind::Move, c.x + 60.0, c.y + 30.0)).unwrap(); + let moved = doc(&s); + let rev = s.ds().unwrap().revision; + for _ in 0..5 { + s.pointer(&ev(PointerKind::Move, c.x + 60.0, c.y + 30.0)).unwrap(); + } + assert!(Arc::ptr_eq(&moved, &doc(&s)), "the same move again keeps the document"); + assert_eq!(s.ds().unwrap().revision, rev); + // The layer moved with the pointer, in page space. + let b = s.doc().unwrap().world_bounds(r).unwrap().center(); + assert!((b.x - c.x - 60.0).abs() < 1.0 && (b.y - c.y - 30.0).abs() < 1.0, "{b:?} from {c:?}"); + // Shift (a modifier change without a move) and a real move still update the preview. + s.pointer(&PointerEvent { shift: true, ..ev(PointerKind::Move, c.x + 60.0, c.y + 30.0) }).unwrap(); + assert!(!Arc::ptr_eq(&moved, &doc(&s))); + let shifted = doc(&s); + s.pointer(&ev(PointerKind::Move, c.x + 90.0, c.y + 30.0)).unwrap(); + assert!(!Arc::ptr_eq(&shifted, &doc(&s))); + s.pointer(&ev(PointerKind::Up, c.x + 90.0, c.y + 30.0)).unwrap(); + // A new drag to the same spot as the last move of the previous one is not mistaken for it. + let c = s.doc().unwrap().world_bounds(r).unwrap().center(); + let before = doc(&s); + s.pointer(&ev(PointerKind::Down, c.x, c.y)).unwrap(); + s.pointer(&ev(PointerKind::Move, c.x + 20.0, c.y)).unwrap(); + assert!(!Arc::ptr_eq(&before, &doc(&s))); +} + +#[test] +fn a_preview_that_changes_nothing_keeps_the_document() { + let mut s = Session::new(); + let r = rect(&mut s, None); + s.begin_interaction("Edit").unwrap(); + let set = |s: &mut Session, x: f64| { + let props = serde_json::Map::from_iter([("x".to_string(), json!(x))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + }; + set(&mut s, 10.0); + let first = doc(&s); + set(&mut s, 10.0); + assert!(Arc::ptr_eq(&first, &doc(&s))); + set(&mut s, 12.0); + assert!(!Arc::ptr_eq(&first, &doc(&s))); + s.commit_interaction().unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 12.0); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c6c2e6a..73c51712 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -675,6 +675,11 @@ impl Session { let res = f(&mut doc, &mut sel); if res.is_ok() { normalize(&mut doc, &it.doc); + // The same preview again (a handle held still) keeps the document: a new one would + // re-render the canvas for nothing. + if doc == *ds.doc && sel == ds.selection { + return res; + } ds.doc = Arc::new(doc); ds.selection = sel; ds.clean_selection(); @@ -967,6 +972,8 @@ mod frame_guides_tests; #[cfg(test)] mod frame_tests; #[cfg(test)] +mod gesture_preview_tests; +#[cfg(test)] mod html_codegen_tests; #[cfg(test)] mod import_export_tests; @@ -999,6 +1006,8 @@ mod proto_responsive_tests; #[cfg(test)] mod proto_run_tests; #[cfg(test)] +mod retained_walk_tests; +#[cfg(test)] mod rotated_guides_tests; #[cfg(test)] mod rotated_select_tests; diff --git a/crates/engine/src/retained_walk_tests.rs b/crates/engine/src/retained_walk_tests.rs new file mode 100644 index 00000000..281f05e6 --- /dev/null +++ b/crates/engine/src/retained_walk_tests.rs @@ -0,0 +1,286 @@ +//! A seeded random walk of edits, undo/redo, pans and zooms over a page of nested, rotated and +//! clipping frames, groups, masks, booleans, auto layout, effects, text, instances, variables and +//! styles: after every step the retained canvas render must match a full render of the same view. + +use std::sync::{Arc, Once}; + +use figcraft_doc::{Blur, BlurType, Document, Effect, EffectKind, NodeId, Shadow}; +use figcraft_render::{Options, Pixmap, Retained, View, Work, render_view}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +/// splitmix64: a fixed seed gives the same walk everywhere. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + fn range(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + fn chance(&mut self, p: f64) -> bool { + self.range(0.0, 1.0) < p + } + fn color(&mut self) -> String { + format!("#{:06X}", self.next() & 0xFF_FFFF) + } +} + +fn fonts() { + static ONCE: Once = Once::new(); + ONCE.call_once(figcraft_text::scan_system_fonts); +} + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn run(s: &mut Session, cmd: &str, p: Value) -> Option { + s.execute(cmd, &p).ok() +} + +fn shape(s: &mut Session, kind: &str, parent: Option, r: [f64; 4], fill: &str) -> NodeId { + let mut p = json!({"x": r[0], "y": r[1], "width": r[2], "height": r[3], "props": {"fill": fill}}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(s.execute(kind, &p).unwrap()) +} + +fn shadow(offset: (f64, f64), radius: f64, spread: f64) -> Value { + serde_json::to_value(Effect { kind: EffectKind::DropShadow(Shadow { offset, radius, spread, ..Shadow::default() }), visible: true }).unwrap() +} + +/// The page: shapes on it, a rotated clipping frame holding a rotated nested frame (not clipping) +/// with text, a group with a drop shadow, a masked group, a boolean, an auto layout row with a +/// blurred child, overflowing text, a layer with an inner shadow and an outside stroke, and a +/// component with two instances, a bound colour variable and a paint style. +fn scene() -> (Session, NodeId) { + fonts(); + let mut s = Session::new(); + shape(&mut s, "shape.rectangle", None, [10.0, 10.0, 70.0, 40.0], "#E03030"); + shape(&mut s, "shape.ellipse", None, [120.0, 20.0, 50.0, 50.0], "#3070E0"); + let outer = id(s.execute("frame.create", &json!({"x": 40, "y": 90, "width": 200, "height": 140, "props": {"fill": "#F0F0E0"}})).unwrap()); + s.execute("node.set", &json!({"id": outer, "rotation": 25})).unwrap(); + shape(&mut s, "shape.rectangle", Some(outer), [150.0, 20.0, 90.0, 40.0], "#20A060"); + let inner = + id(s.execute("frame.create", &json!({"x": 20, "y": 60, "width": 90, "height": 60, "parent": outer, "props": {"fill": "#D0E0FF"}})).unwrap()); + s.execute("node.set", &json!({"id": inner, "rotation": -15, "clip": false})).unwrap(); + shape(&mut s, "shape.star", Some(inner), [50.0, 30.0, 60.0, 50.0], "#A040C0"); + s.execute("text.create", &json!({"text": "Nested text", "x": 4, "y": 4, "parent": inner, "props": {"fontSize": 12}})).unwrap(); + let g1 = shape(&mut s, "shape.rectangle", None, [280.0, 30.0, 40.0, 40.0], "#F0B020"); + let g2 = shape(&mut s, "shape.ellipse", None, [300.0, 60.0, 40.0, 30.0], "#208080"); + let g = id(s.execute("object.group", &json!({"ids": [g1, g2]})).unwrap()); + s.execute("node.set", &json!({"id": g, "effects": [shadow((10.0, 12.0), 6.0, 0.0)]})).unwrap(); + let m = shape(&mut s, "shape.ellipse", None, [260.0, 140.0, 60.0, 60.0], "#000000"); + let mr = shape(&mut s, "shape.rectangle", None, [250.0, 150.0, 90.0, 30.0], "#C04040"); + s.execute("object.mask", &json!({"ids": [m, mr]})).unwrap(); + let b1 = shape(&mut s, "shape.rectangle", None, [20.0, 260.0, 50.0, 40.0], "#6060C0"); + let b2 = shape(&mut s, "shape.ellipse", None, [45.0, 270.0, 50.0, 40.0], "#6060C0"); + s.execute("bool.union", &json!({"ids": [b1, b2]})).unwrap(); + let al = id(s.execute("frame.create", &json!({"x": 130, "y": 250, "width": 220, "height": 70, "props": {"fill": "#FFE0B0"}})).unwrap()); + s.execute("node.set", &json!({"id": al, "layout": "horizontal", "gap": 8, "padding": 10})).unwrap(); + for c in ["#E03030", "#3070E0", "#A040C0"] { + shape(&mut s, "shape.rectangle", Some(al), [0.0, 0.0, 40.0, 40.0], c); + } + let blurred = s.doc().unwrap().node(al).unwrap().children[1]; + let blur = serde_json::to_value(Effect { kind: EffectKind::LayerBlur(Blur { radius: 3.0, ..Blur::default() }), visible: true }).unwrap(); + s.execute("node.set", &json!({"id": blurred, "effects": [blur]})).unwrap(); + s.execute("text.create", &json!({"text": "A line of text that overflows its box", "x": 200, "y": 120, "width": 70, "props": {"fontSize": 13, "textAutoResize": "none", "height": 18}})).unwrap(); + let inset = shape(&mut s, "shape.rectangle", None, [190.0, 200.0, 50.0, 30.0], "#FFFFFF"); + let inner_shadow = serde_json::to_value(Effect::inner_shadow()).unwrap(); + s.execute("node.set", &json!({"id": inset, "effects": [inner_shadow], "stroke": "#202020", "strokeWeight": 3, "strokeAlign": "outside"})) + .unwrap(); + // A component with instances (one in the rotated frame), a bound colour variable and a paint + // style: edits to the main, the variable or the style repaint other layers. + let main = id(s.execute("frame.create", &json!({"x": 20, "y": 340, "width": 80, "height": 50, "props": {"fill": "#E0F0E0"}})).unwrap()); + let part = shape(&mut s, "shape.ellipse", Some(main), [10.0, 10.0, 30.0, 30.0], "#C02060"); + let comp = s.execute("component.create", &json!({"ids": [main]})).unwrap()["id"].clone(); + s.execute("instance.create", &json!({"component": comp, "x": 130, "y": 340})).unwrap(); + s.execute("instance.create", &json!({"component": comp, "x": 100, "y": 10, "parent": outer})).unwrap(); + s.execute("variable.createCollection", &json!({"name": "T"})).unwrap(); + s.execute("variable.create", &json!({"collection": "T", "name": "brand", "type": "color", "value": "#FF8000"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [part, g1], "field": "fill", "variable": "brand"})).unwrap(); + let style = s.execute("style.create", &json!({"type": "paint", "name": "Accent", "from": g2})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [mr, blurred], "style": style})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let page = s.ds().unwrap().page; + (s, page) +} + +/// Layers on the page (pages excluded), ascending ids. +fn layers(doc: &Document, page: NodeId) -> Vec { + doc.ids().filter(|i| *i != page && doc.page_of(*i) == Some(page)).collect() +} + +/// One random step; returns what it did. +fn step(s: &mut Session, page: NodeId, view: &mut View, rng: &mut Rng) -> String { + let doc = s.ds().unwrap().doc.clone(); + let all = layers(&doc, page); + let Some(&n) = all.get(rng.below(all.len())) else { return "nothing left".into() }; + let frames: Vec = all.iter().copied().filter(|i| doc.node(*i).is_some_and(|x| x.kind.is_frame_like())).collect(); + let (cmd, p) = match rng.below(24) { + 0 | 1 => ("node.set", json!({"id": n, "x": rng.range(-40.0, 360.0).round(), "y": rng.range(-40.0, 320.0).round()})), + 2 => ("node.set", json!({"id": n, "width": rng.range(4.0, 160.0).round(), "height": rng.range(4.0, 120.0).round()})), + 3 => ("node.set", json!({"id": n, "rotation": rng.range(-90.0, 90.0).round()})), + 4 => ("node.set", json!({"id": n, "opacity": (rng.range(0.1, 1.0) * 100.0).round() / 100.0})), + 5 => ("node.set", json!({"id": n, "visible": !doc.node(n).is_some_and(|x| x.visible)})), + 6 => { + let siblings = doc.node(n).and_then(|x| x.parent).and_then(|p| doc.node(p)).map_or(1, |p| p.children.len()); + ("node.reorder", json!({"id": n, "index": rng.below(siblings)})) + } + 7 if all.len() > 10 => ("edit.delete", json!({"ids": [n]})), + 8 => { + let _ = s.undo(); + return "undo".into(); + } + 9 => { + let _ = s.redo(); + return "redo".into(); + } + 10 => { + let far = rng.chance(0.1); + let reach = if far { 400.0 } else { 90.0 } / view.scale; + // Around the layers: an empty view would only ever be reused. + let (x, y) = (view.origin.0 + rng.range(-reach, reach), view.origin.1 + rng.range(-reach, reach)); + view.origin = (x.clamp(-150.0, 300.0), y.clamp(-120.0, 280.0)); + return format!("pan to {:?}", view.origin); + } + 11 => { + view.scale = [0.4, 0.75, 1.0, 1.0, 1.5, 2.25][rng.below(6)]; + return format!("zoom to {}", view.scale); + } + 12 => ("node.set", json!({"id": n, "fill": rng.color()})), + 13 => { + let fx = match rng.below(3) { + 0 => json!([]), + 1 => json!([shadow( + (rng.range(-20.0, 20.0).round(), rng.range(-20.0, 20.0).round()), + rng.range(0.0, 16.0).round(), + rng.range(0.0, 6.0).round() + )]), + _ => { + // Uniform or progressive (the radius ramping across the layer). + let blur_type = if rng.chance(0.5) { BlurType::Uniform } else { BlurType::Progressive }; + let blur = Blur { radius: rng.range(1.0, 10.0).round(), blur_type, ..Blur::default() }; + json!([serde_json::to_value(Effect { kind: EffectKind::LayerBlur(blur), visible: true }).unwrap()]) + } + }; + ("node.set", json!({"id": n, "effects": fx})) + } + 14 => match frames.get(rng.below(frames.len())) { + Some(f) if rng.chance(0.5) => { + ("node.set", json!({"id": f, "gap": rng.range(0.0, 30.0).round(), "padding": rng.range(0.0, 20.0).round()})) + } + Some(f) => ("node.set", json!({"id": f, "clip": !doc.node(*f).is_some_and(|x| x.clip)})), + None => return "no frame".into(), + }, + 15 => { + let texts: Vec = + all.iter().copied().filter(|i| doc.node(*i).is_some_and(|x| matches!(x.kind, figcraft_doc::NodeKind::Text(_)))).collect(); + let Some(t) = texts.get(rng.below(texts.len())) else { return "no text".into() }; + let words = ["glyph", "a", "longer word", "W", "overflowing text runs on"]; + let text: Vec<&str> = (0..1 + rng.below(4)).map(|_| words[rng.below(words.len())]).collect(); + ("text.set", json!({"id": t, "characters": text.join(" ")})) + } + 17 => match frames.get(rng.below(frames.len())) { + Some(f) if *f != n => ("node.reparent", json!({"ids": [n], "parent": f})), + _ => ("node.reparent", json!({"ids": [n], "parent": page})), + }, + 18 => ("node.set", json!({"id": n, "isMask": !doc.node(n).is_some_and(|x| x.is_mask)})), + 19 => ("node.set", json!({"id": n, "cornerRadius": rng.range(0.0, 30.0).round()})), + 20 => { + let blend = ["normal", "multiply", "screen", "overlay", "difference"][rng.below(5)]; + ("node.set", json!({"id": n, "blend": blend})) + } + 21 => { + let parent = doc.node(n).and_then(|x| x.parent).and_then(|p| doc.node(p)); + let Some(sib) = parent.and_then(|p| p.children.get(rng.below(p.children.len())).copied()) else { return "no sibling".into() }; + if rng.chance(0.5) && sib != n { ("object.group", json!({"ids": [n, sib]})) } else { ("object.ungroup", json!({"ids": [n]})) } + } + 22 => ("variable.set", json!({"variable": "brand", "value": rng.color()})), + 23 => { + let styles = &doc.styles; + let Some(st) = styles.get(rng.below(styles.len())) else { return "no style".into() }; + ("style.update", json!({"style": st.id, "from": n})) + } + _ => { + let align = ["inside", "center", "outside"][rng.below(3)]; + ("node.set", json!({"id": n, "stroke": rng.color(), "strokeWeight": rng.range(0.0, 8.0).round(), "strokeAlign": align})) + } + }; + let ok = run(s, cmd, p.clone()).is_some(); + format!("{cmd} {p}{}", if ok { "" } else { " (refused)" }) +} + +/// Pixels where `a` differs from `b` beyond anti-aliasing, and the first of them. A part rendered +/// on its own sees curves clipped (so flattened) at other places, which moves the coverage of edge +/// pixels a little; where both images are flat around a pixel, any difference is stale or missing +/// paint, and so is a big difference anywhere. +fn stale(a: &Pixmap, b: &Pixmap) -> (usize, Option<(u32, u32)>) { + let (w, h) = (b.width() as usize, b.height() as usize); + let (pa, pb) = (a.data().as_chunks::<4>().0, b.data().as_chunks::<4>().0); + let d = |p: &[u8; 4], q: &[u8; 4]| p.iter().zip(q).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + let edge = |px: &[[u8; 4]], x: usize, y: usize| { + let c = &px[y * w + x]; + (y.saturating_sub(1)..(y + 2).min(h)).any(|ny| (x.saturating_sub(1)..(x + 2).min(w)).any(|nx| d(&px[ny * w + nx], c) > 2)) + }; + let (mut n, mut first) = (0, None); + for (i, (p, q)) in pa.iter().zip(pb).enumerate() { + let diff = d(p, q); + let (x, y) = (i % w, i / w); + if diff > 96 || (diff > 2 && !edge(pa, x, y) && !edge(pb, x, y)) { + n += 1; + first.get_or_insert((x as u32, y as u32)); + } + } + (n, first) +} + +/// Walk `steps` random steps from `seed`; panics with the steps so far on a divergence. +fn walk(seed: u64, steps: usize) -> (usize, usize) { + let (mut s, page) = scene(); + let mut rng = Rng(seed); + let mut view = View { origin: (-20.0, -10.0), scale: 1.0, width: 320, height: 240 }; + let opts = Options { lod: true, ..Default::default() }; + let mut retained = Retained::default(); + let mut log: Vec = Vec::new(); + let mut partial = 0; + for k in 0..=steps { + if k > 0 { + log.push(step(&mut s, page, &mut view, &mut rng)); + } + let doc: Arc = s.ds().unwrap().doc.clone(); + let (shown, pix, work) = retained.render(&doc, page, &view, &opts).unwrap(); + let pix = pix.clone(); + partial += usize::from(matches!(work, Work::Partial { .. })); + let full = render_view(&doc, page, &shown, &opts).unwrap(); + assert_eq!((pix.width(), pix.height()), (full.width(), full.height())); + let (n, first) = stale(&pix, &full); + assert!( + n == 0, + "seed {seed}, step {k} ({work:?}): retained differs from a full render ({n} pixels, first at {first:?})\nsteps:\n{}", + log.join("\n") + ); + } + (steps, partial) +} + +#[test] +fn retained_renders_match_full_renders_along_a_random_walk() { + for seed in [1, 2, 3] { + let (steps, partial) = walk(seed, 120); + // The walk exercises the partial path, not only full renders. + assert!(partial > steps / 5, "seed {seed}: only {partial} partial renders in {steps} steps"); + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3abad4e6..1848be9e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -145,7 +145,7 @@ pub enum PointerKind { } /// A pointer event in page coordinates (and screen coordinates for panning). -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct PointerEvent { pub kind: PointerKind, pub x: f64, @@ -353,6 +353,8 @@ pub struct ToolState { /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the /// selection box that stays put (`tool.scaleAnchor`). pub scale_anchor: (u8, u8), + /// The last drag move handled (a held button repeats it every frame). + pub(crate) last_move: Option, } /// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start, @@ -707,6 +709,16 @@ impl Session { /// Feed one pointer event to the current tool. pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { + // A held button sends the same move every frame: running the gesture again would only + // rebuild the same preview (a new document, and a canvas render, every frame). + if ev.kind != PointerKind::Move { + self.tool.last_move = None; + } else if self.tool.busy() { + if self.tool.last_move.as_ref() == Some(ev) { + return Ok(()); + } + self.tool.last_move = Some(ev.clone()); + } let tool = self.tool.tool; if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { return Ok(()); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 2f49e130..0247960d 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -325,11 +325,19 @@ fn needs_layer(n: &Node) -> bool { n.opacity < 0.999 || !matches!(n.blend, BlendMode::Normal | BlendMode::PassThrough) || n.effects.iter().any(|e| e.visible) } +/// `v` on the whole pixel that only float noise keeps it off: an edge on a pixel boundary comes out +/// a hair either side of it depending on the view's origin, and rounding outwards would then +/// take in a whole extra row or column in one render and not in another. +fn snap_px(v: f64) -> f64 { + let r = v.round(); + if (v - r).abs() < 1e-6 { r } else { v } +} + fn int_rect(r: Rect, limit: (u32, u32)) -> Option { - let x0 = r.x0.floor().max(0.0); - let y0 = r.y0.floor().max(0.0); - let x1 = r.x1.ceil().min(f64::from(limit.0)); - let y1 = r.y1.ceil().min(f64::from(limit.1)); + let x0 = snap_px(r.x0).floor().max(0.0); + let y0 = snap_px(r.y0).floor().max(0.0); + let x1 = snap_px(r.x1).ceil().min(f64::from(limit.0)); + let y1 = snap_px(r.y1).ceil().min(f64::from(limit.1)); if !(x1 > x0 && y1 > y0) { return None; } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fc375a74..08711f47 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -373,6 +373,8 @@ mod lod_tests; #[cfg(test)] mod progressive_tests; #[cfg(test)] +mod retained_edit_tests; +#[cfg(test)] mod retained_tests; #[cfg(test)] mod spread_tests; diff --git a/crates/render/src/retained_edit_tests.rs b/crates/render/src/retained_edit_tests.rs new file mode 100644 index 00000000..38c4c301 --- /dev/null +++ b/crates/render/src/retained_edit_tests.rs @@ -0,0 +1,67 @@ +//! Retained renders after edits inside transformed and clipping containers: every render must match +//! a full render of the same view (the engine's random walk drives whole documents through it). + +use std::sync::Arc; + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind}; +use figcraft_geom::Affine; + +use crate::retained_tests::assert_same; +use crate::{Options, Retained, View, Work, render_view}; + +fn check(r: &mut Retained, doc: &Arc, page: NodeId, v: &View, what: &str) { + let (shown, pix, work) = r.render(doc, page, v, &Options::default()).unwrap(); + let pix = pix.clone(); + let full = render_view(doc, page, &shown, &Options::default()).unwrap(); + assert_same(&pix, &full, &format!("{what} ({work:?})")); +} + +#[test] +fn moving_a_child_of_a_rotated_frame() { + let mut d = Document::new("rot"); + let page = d.first_page().unwrap(); + let f = d.create(page, NodeKind::Frame, "F", 100.0, 100.0, 300.0, 200.0).unwrap(); + d.get_mut(f).unwrap().transform = figcraft_geom::set_rotation_about_center(d.node(f).unwrap().transform, 300.0, 200.0, 25.0); + let c = d.create(f, NodeKind::Rectangle, "C", 40.0, 40.0, 80.0, 60.0).unwrap(); + d.get_mut(c).unwrap().fills = vec![Paint::solid(Color::from_hex("E03030").unwrap())]; + let doc = Arc::new(d); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 500, height: 450 }; + let mut r = Retained::default(); + check(&mut r, &doc, page, &v, "first"); + let mut d2 = (*doc).clone(); + let pw = d2.world_transform(f); + let n = d2.get_mut(c).unwrap(); + n.transform = pw.inverse() * Affine::translate((150.0, 60.0)) * pw * n.transform; + check(&mut r, &Arc::new(d2), page, &v, "moved"); +} + +/// A clipping frame whose right edge lands exactly on a pixel boundary at 40 %: float noise in +/// one render's origin and not another's put the edge a hair past the boundary, and the clip, +/// rounded outwards, let a whole column of the child through. +#[test] +fn a_clip_edge_on_a_pixel_boundary_clips_the_same_in_every_render() { + let mut d = Document::new("clip"); + let page = d.first_page().unwrap(); + let f = d.create(page, NodeKind::Frame, "F", 212.0, 198.0, 220.0, 70.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFE0B0").unwrap())]; + let c = d.create(f, NodeKind::Rectangle, "C", 58.0, 10.0, 40.0, 40.0).unwrap(); + d.get_mut(c).unwrap().fills = vec![Paint::solid(Color::from_hex("3070E0").unwrap())]; + d.get_mut(c).unwrap().effects.push(Effect { kind: EffectKind::LayerBlur(Blur { radius: 3.0, ..Blur::default() }), visible: true }); + let doc = Arc::new(d); + // The frame's new right edge (x 280) is device pixel 172 exactly. + let v = View { origin: (-150.0, 132.8), scale: 0.4, width: 320, height: 240 }; + for lod in [false, true] { + let opts = Options { lod, ..Default::default() }; + let mut r = Retained::default(); + r.render(&doc, page, &v, &opts).unwrap(); + let mut smaller = (*doc).clone(); + smaller.get_mut(f).unwrap().width = 68.0; + smaller.get_mut(f).unwrap().height = 54.0; + let smaller = Arc::new(smaller); + let (shown, pix, work) = r.render(&smaller, page, &v, &opts).unwrap(); + assert!(matches!(work, Work::Partial { .. }), "{work:?}"); + let pix = pix.clone(); + assert_same(&pix, &render_view(&smaller, page, &shown, &opts).unwrap(), &format!("lod {lod}")); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a9df628e..930bf4e9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -18,7 +18,6 @@ pub struct CanvasState { texture: Option, tex_view: Option, submitted: Option, - generation: u64, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, dragging: bool, @@ -38,7 +37,6 @@ impl Default for CanvasState { texture: None, tex_view: None, submitted: None, - generation: 0, render_work: None, dragging: false, space_pan: false, @@ -141,9 +139,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { input(app, ui, &resp); app.sync_preedit(); request_render(app, worker, ppp); - if let Some(done) = worker.poll() - && done.generation == app.canvas.generation - { + // Every finished render is shown, even when a newer one is already on its way: renders finish + // in order, and an edit every frame (a drag) would otherwise never show until it stopped. + if let Some(done) = worker.poll() { // A pixel preview render is magnified with hard pixel edges. let filter = if app.session.ds().ok().and_then(pixel_preview) == Some(done.view.scale) { egui::TextureOptions::NEAREST @@ -261,14 +259,12 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { if app.canvas.submitted.as_ref() == Some(&key) { return; } - app.canvas.generation += 1; let job = Job { doc, page: ds.page, view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) }, outline: ds.view.outlines, skip: vec![], - generation: app.canvas.generation, }; worker.submit(job); app.canvas.submitted = Some(key); diff --git a/crates/ui-egui/src/canvas_drag_tests.rs b/crates/ui-egui/src/canvas_drag_tests.rs new file mode 100644 index 00000000..c90cc509 --- /dev/null +++ b/crates/ui-egui/src/canvas_drag_tests.rs @@ -0,0 +1,58 @@ +//! The canvas while a drag edits the document every frame, driven through egui headless: each +//! finished render shows (a newer one already on its way doesn't hide it), and a button held still +//! stops making new documents to render. + +use std::sync::Arc; +use std::time::Duration; + +use egui::{Event, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// Frames until the render worker has nothing in flight (a few seconds at most). +fn settle(u: &mut Ui) { + for _ in 0..300 { + u.frame(vec![]); + if u.app.worker.as_ref().is_some_and(|w| !w.in_flight) { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn a_drag_shows_on_the_canvas_while_it_moves() { + let mut s = Session::new(); + let id = |v: serde_json::Value| figcraft_engine::params::id_of(&v["id"]).unwrap(); + let f = id(s.execute("frame.create", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + s.execute("node.set", &json!({"id": f, "rotation": 25})).unwrap(); + let r = + id(s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 80, "height": 60, "parent": f, "props": {"fill": "#e03030"}})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + settle(&mut u); + let c = u.app.session.doc().unwrap().world_bounds(r).unwrap().center(); + let p = u.app.to_screen(c.x, c.y); + u.button(p, true); + settle(&mut u); + // A move every frame, each one a new document and a new render request. + u.app.canvas.render_work = None; + let before = u.app.session.ds().unwrap().doc.clone(); + for k in 1..=30 { + u.frame(vec![Event::PointerMoved(p + vec2(k as f32 * 3.0, k as f32))]); + std::thread::sleep(Duration::from_millis(15)); + } + assert!(!Arc::ptr_eq(&before, &u.app.session.ds().unwrap().doc), "the drag moved the layer"); + assert!(u.app.canvas.render_work.is_some(), "renders finished during the drag are shown"); + // Held still: the same move each frame keeps the preview, and the canvas settles. + let held = u.app.session.ds().unwrap().doc.clone(); + for _ in 0..5 { + u.frame(vec![]); + std::thread::sleep(Duration::from_millis(15)); + } + assert!(Arc::ptr_eq(&held, &u.app.session.ds().unwrap().doc)); + assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight), "nothing left to render"); + u.button(p + vec2(90.0, 30.0), false); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3e2b9403..15bec01b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -48,6 +48,8 @@ pub mod vector_panel; pub mod versions; pub mod widgets; +#[cfg(test)] +mod canvas_drag_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index aab3cf86..829e1531 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -1,5 +1,6 @@ -//! Canvas rendering off the UI thread (desktop) or inline (web). The latest request wins; the UI -//! keeps showing the previous image, re-projected to the current view, until the new one arrives. +//! Canvas rendering off the UI thread (desktop) or inline (web). The latest request wins (queued +//! older ones are skipped); the UI shows each finished image, re-projected to the current view, +//! until the next one arrives. //! The renderer keeps its last image ([`figcraft_render::Retained`]): a pan renders only the //! uncovered strips and an edit only the areas of the layers that changed. @@ -16,7 +17,6 @@ pub struct Job { pub view: View, pub outline: bool, pub skip: Vec, - pub generation: u64, } pub struct Done { @@ -24,7 +24,6 @@ pub struct Done { pub image: egui::ColorImage, /// The view the image shows (the requested one snapped outwards to whole device pixels). pub view: View, - pub generation: u64, pub ms: f64, pub work: Work, } @@ -51,7 +50,7 @@ fn run(job: &Job, retained: &mut Retained) -> Option { let ms = t0.elapsed().as_secs_f64() * 1000.0; #[cfg(target_arch = "wasm32")] let ms = 0.0; - Some(Done { image, view, generation: job.generation, ms, work }) + Some(Done { image, view, ms, work }) } #[cfg(not(target_arch = "wasm32"))]