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"))]