diff --git a/README.md b/README.md index 21dd094..7feb045 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@

Vectors without compromise

- Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes, per-side strokes and variable width strokes (width profile presets, and a Variable width tool that drags width points along the path). Rectangles with smooth (squircle) corners, polygons, stars, and arcs and rings shaped with on-canvas sweep, start and ratio handles. + Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes, strokes and open paths to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude) whose operands stay editable on the canvas with the result following every drag, an operation per operand, flatten, outline stroke, arrowheads, dashes, per-side strokes and variable width strokes (width profile presets, and a Variable width tool that drags width points along the path, one side at a time with Alt). Rectangles with smooth (squircle) corners, polygons, stars, and arcs and rings shaped with on-canvas sweep, start and ratio handles or the Design panel's Arc fields.

Paints and effects

diff --git a/ROADMAP.md b/ROADMAP.md index d645e0d..35276ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -14,19 +14,19 @@ start with [`docs/gaps.md`](docs/gaps.md). | | Value | Kind | |---|---|---| | Menu/command breadth | **98.5%** (200 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | -| Feature breadth | **~78%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | +| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~43%** | estimated: weekly areas 66.5% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Mainstream practitioner | **~43%** | estimated: weekly areas 66.7% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~200–365 h** one agent | estimated | -| Remaining to full parity | **~390–745 h** one agent (~95–170 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~385–730 h** one agent (~95–165 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~47% | ~375–705 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | -| Mainstream practitioner | ~43% | ~180–330 h | core editor depth incl. `.fig` import (98–188), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | +| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -56,7 +56,7 @@ the localization layer with Figma's own languages (20–35 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 67%) | 78–146 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 67%) | 71–135 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | @@ -75,7 +75,7 @@ the localization layer with Figma's own languages (20–35 h). | Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) | | Selection & transform | 95% | 68% | 6–10 | [ui-parity](docs/ui-parity.md) | | Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) | -| Shapes & vector | 92% | 66% | 8–14 | [target-app-parity](docs/target-app-parity.md) | +| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) | | Strokes | 90% | 72% | 2–4 | [target-app-parity](docs/target-app-parity.md) | | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | @@ -127,6 +127,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click + selects one, moving, resizing or editing its points redraws the result during the drag with every + operand outlined, per-operand operations from the boolean controls, prefix-cached recompute); + Design panel arc fields; the shape builder on strokes and open paths; one-sided variable widths + (gap 13). Feature breadth ~79%. - **2026-10-10** · WASM plugins (#101: sandboxed wasmi runtime, manifest permissions, Plugins menu, Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without networking (#103: client-scoped ids, change sets, server ordering and validation, client @@ -152,6 +157,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 4e90403..1bbee2a 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -158,6 +158,7 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> { Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy) }; table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0)) * 1000.0); + measure_boolean_drag(table, col); let mut s = Session::with_document(doc); let doc = s.doc().map_err(failed)?.clone(); @@ -240,6 +241,41 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> { Ok(()) } +/// Live boolean editing: one drag frame of an operand of a boolean group of 24 overlapping +/// circles, i.e. the group's result recomputed with the operand somewhere new (each frame a new +/// position, as in a real drag). The top operand recombines one step; the bottom one, all 23. +fn measure_boolean_drag(table: &mut Table, col: usize) { + let mut d = Document::new("booleans"); + let Some(page) = d.first_page() else { return }; + let Ok(b) = d.create(page, NodeKind::BooleanOp { op: figcraft_doc::BoolOp::Union }, "Union", 0.0, 0.0, 0.0, 0.0) else { return }; + let kids: Vec = (0..24) + .filter_map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 24.0; + d.create( + b, + NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, + "Circle", + 200.0 + 120.0 * a.cos(), + 200.0 + 120.0 * a.sin(), + 90.0, + 90.0, + ) + .ok() + }) + .collect(); + let _ = d.fit_to_children(b); + let frame = |which: usize, k: usize| { + let mut moved = d.clone(); + if let Some(n) = kids.get(which).and_then(|id| moved.node_mut(*id)) { + // Distinct offsets per run and column, so no frame is served from the cache. + n.transform = figcraft_doc::geom::Affine::translate((0.37 * (k + 1) as f64 + 1000.0 * col as f64, 0.11 * k as f64)) * n.transform; + } + moved.node(b).and_then(|n| figcraft_render::node_outline(&moved, n)).is_some() + }; + table.put(col, "boolean of 24: drag frame, top operand", mean(20, |k| _ = frame(23, k))); + table.put(col, "boolean of 24: drag frame, bottom operand", mean(5, |k| _ = frame(0, 100 + k))); +} + /// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design /// system, for each instance count in `instances`; print the tables. pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult { diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 60aacde..44dd302 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -166,6 +166,7 @@ node_props! { Styles => styles, Bound => bound, VarModes => var_modes, + BoolOp => bool_op, ConstrainProportions => constrain_proportions, Source => source, Exposed => exposed, diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs new file mode 100644 index 0000000..a7774e0 --- /dev/null +++ b/crates/doc/src/boolean_hit_tests.rs @@ -0,0 +1,64 @@ +//! Clicking into boolean groups: a hit reaches the operand under the pointer, a plain click still +//! picks the group, and a double click goes one level deeper than the selection. + +use crate::geometry::{enter_target, select_target}; +use crate::{BoolOp, Document, NodeId, NodeKind}; +use figcraft_geom::Point; + +fn page(d: &Document) -> NodeId { + d.first_page().unwrap() +} + +/// A frame holding a group holding a boolean group of two overlapping squares. +fn setup() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 300.0).unwrap(); + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); + let b = d.create(g, NodeKind::BooleanOp { op: BoolOp::Subtract }, "Subtract", 0.0, 0.0, 0.0, 0.0).unwrap(); + let r1 = d.create(b, NodeKind::Rectangle, "R1", 10.0, 10.0, 100.0, 100.0).unwrap(); + let r2 = d.create(b, NodeKind::Rectangle, "R2", 60.0, 60.0, 100.0, 100.0).unwrap(); + d.fit_to_children(b).unwrap(); + d.fit_to_children(g).unwrap(); + (d, f, g, b, r1, r2) +} + +#[test] +fn hits_reach_operands_but_clicks_pick_the_group() { + let (d, f, g, b, r1, r2) = setup(); + let p = page(&d); + assert_eq!(d.hit_test(p, Point::new(20.0, 20.0), 0.0), Some(r1)); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0), Some(r2), "the top operand where they overlap"); + // A plain click: the group in the frame; Cmd-click: the operand. + assert_eq!(select_target(&d, r1, &[], false), g); + assert_eq!(select_target(&d, r1, &[], true), r1); + // With the boolean group selected, its siblings resolve as usual; with an operand selected, a + // click on another operand picks that one. + assert_eq!(select_target(&d, r2, &[r1], false), r2); + // "Select layer" still lists the boolean group as one layer. + let listed: Vec = crate::geometry::layers_at(&d, p, Point::new(20.0, 20.0), 0.0).into_iter().map(|(id, _)| id).collect(); + assert_eq!(listed, vec![f, g, b]); +} + +#[test] +fn double_clicks_go_one_level_deeper() { + let (d, f, g, b, r1, _) = setup(); + assert_eq!(enter_target(&d, r1, &[]), g, "nothing selected: the plain click target"); + assert_eq!(enter_target(&d, r1, &[g]), b, "into the group"); + assert_eq!(enter_target(&d, r1, &[b]), r1, "into the boolean group"); + assert_eq!(enter_target(&d, r1, &[r1]), r1, "the leaf itself: nothing deeper, it stays"); + assert_eq!(enter_target(&d, r1, &[f]), g); + // The deepest selected layer that holds it counts. + assert_eq!(enter_target(&d, r1, &[f, b]), r1); +} + +#[test] +fn a_top_level_group_is_entered_too() { + let mut d = Document::new("x"); + let p = page(&d); + let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); + let r = d.create(g, NodeKind::Rectangle, "R", 10.0, 10.0, 20.0, 20.0).unwrap(); + d.fit_to_children(g).unwrap(); + assert_eq!(select_target(&d, r, &[g], false), g, "a click keeps the group"); + assert_eq!(enter_target(&d, r, &[g]), r, "a double click enters it"); +} diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 168e55d..c4b90ea 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -117,15 +117,9 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Opt if clips && !hits_node_itself(doc, id, n, pt, 0.0) { return None; } - if matches!(n.kind, NodeKind::BooleanOp { .. }) { - // A boolean group is hit as one shape (approximated by any child hit). - for c in n.children.iter().rev() { - if hit_rec(doc, *c, pt, tol, depth + 1).is_some() { - return Some(id); - } - } - return None; - } + // A boolean group is hit where an operand is (an approximation of its result). The leaf is the + // operand, so a plain click still selects the group (see `select_target`) while a deep click, + // a double click or a selected sibling operand reaches the operand itself. for c in n.children.iter().rev() { if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1) { return Some(h); @@ -222,6 +216,17 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo top } +/// What a double click on `leaf` selects: one level deeper than the selection, i.e. the layer +/// holding `leaf` just inside the deepest selected layer that holds it (entering groups, frames +/// and boolean groups), or the plain click target when no selected layer holds it. +pub fn enter_target(doc: &Document, leaf: NodeId, selection: &[NodeId]) -> NodeId { + let mut chain: Vec = doc.ancestors(leaf); + chain.reverse(); + chain.push(leaf); + let inner = chain.iter().rposition(|c| selection.contains(c)).and_then(|i| chain.get(i + 1)).copied(); + inner.unwrap_or_else(|| select_target(doc, leaf, selection, false)) +} + /// Nodes on `page` whose world bounds intersect `rect` (marquee): top-level children, or the /// children of top-level frames when the marquee starts inside a frame (`within`). pub fn marquee(doc: &Document, page: NodeId, rect: Rect, within: Option) -> Vec { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f836b26..93f42d9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -993,6 +993,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap) { #[cfg(test)] mod blur_tests; #[cfg(test)] +mod boolean_hit_tests; +#[cfg(test)] mod client_id_tests; #[cfg(test)] mod colors_tests; @@ -1015,4 +1017,6 @@ mod proto_expr_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod width_sides_tests; +#[cfg(test)] mod width_tests; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ebb3d0e..dc33bdf 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -443,6 +443,10 @@ pub struct Node { /// Explicit variable modes (collection → mode) set on this container. #[serde(default, skip_serializing_if = "is_default")] pub var_modes: BTreeMap, + /// In a boolean group: how this operand combines with the operands below it (`None`: the + /// group's operation). Ignored for the bottom operand and outside boolean groups. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bool_op: Option, /// Lock aspect ratio when resizing from the panel. #[serde(default, skip_serializing_if = "is_default")] pub constrain_proportions: bool, @@ -574,6 +578,7 @@ impl Node { styles: StyleRefs::default(), bound: BTreeMap::new(), var_modes: BTreeMap::new(), + bool_op: None, constrain_proportions: false, source: None, exposed: false, diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 98ca553..5dd8ff6 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -112,17 +112,74 @@ impl StrokeProps { } /// One point of a variable width profile. +/// +/// The width can differ on the two sides of the path. Looking along the path on screen (y down), +/// the left side reaches `weight × scale × (1 − bias) / 2` from the centre line and the right side +/// `weight × scale × (1 + bias) / 2`. A side's **side scale** (`scale × (1 ∓ bias)`) is in the units +/// of `scale`: a symmetric point has both side scales equal to `scale`. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct WidthPoint { /// Position along the path by length: 0 at its start, 1 at its end (each subpath on its own). pub t: f64, - /// The stroke's width here as a multiple of the stroke weight. + /// The stroke's width here as a multiple of the stroke weight (the mean of the side scales). pub scale: f64, + /// How much wider the right side is than the left, -1..=1 (0: symmetric; 1: all on the right). + #[serde(default, skip_serializing_if = "is_zero")] + pub bias: f64, +} + +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +/// A side of a path for one-sided widths (looking along the path on screen, y down). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WidthSide { + Left, + Right, +} + +impl WidthSide { + pub fn from_id(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "left" => Some(WidthSide::Left), + "right" => Some(WidthSide::Right), + _ => None, + } + } + + pub fn id(self) -> &'static str { + match self { + WidthSide::Left => "left", + WidthSide::Right => "right", + } + } } impl WidthPoint { + /// A symmetric point. pub const fn new(t: f64, scale: f64) -> Self { - Self { t, scale } + Self { t, scale, bias: 0.0 } + } + + /// A point from its left and right side scales (negative or non-finite sides count as 0). + pub fn from_sides(t: f64, left: f64, right: f64) -> Self { + let clean = |v: f64| if v.is_finite() { v.max(0.0) } else { 0.0 }; + let (l, r) = (clean(left), clean(right)); + let sum = l + r; + let bias = if sum > 1e-12 { ((r - l) / sum).clamp(-1.0, 1.0) } else { 0.0 }; + Self { t, scale: sum / 2.0, bias } + } + + /// The left and right side scales. + pub fn sides(&self) -> (f64, f64) { + (self.scale * (1.0 - self.bias), self.scale * (1.0 + self.bias)) + } + + /// Whether both sides are as wide. + pub fn symmetric(&self) -> bool { + self.bias == 0.0 } } @@ -160,75 +217,127 @@ impl WidthProfile { .iter() .filter(|p| p.t.is_finite() && p.scale.is_finite()) .take(Self::MAX_POINTS) - .map(|p| WidthPoint::new(p.t.clamp(0.0, 1.0), p.scale.clamp(0.0, Self::MAX_SCALE))) + .map(|p| WidthPoint { + t: p.t.clamp(0.0, 1.0), + scale: p.scale.clamp(0.0, Self::MAX_SCALE), + bias: if p.bias.is_finite() { p.bias.clamp(-1.0, 1.0) } else { 0.0 }, + }) .collect(); points.sort_by(|a, b| a.t.total_cmp(&b.t)); Self { points } } - /// The width scale at `t` (0..1 along the path). + /// The width scale at `t` (0..1 along the path): the mean of the two sides' scales. pub fn scale_at(&self, t: f64) -> f64 { + let (l, r) = self.sides_at(t); + (l + r) / 2.0 + } + + /// The left and right side scales at `t`, each side interpolated on its own. + pub fn sides_at(&self, t: f64) -> (f64, f64) { + if self.symmetric() { + let s = self.interp(t, |p| p.scale); + return (s, s); + } + (self.interp(t, |p| p.sides().0), self.interp(t, |p| p.sides().1)) + } + + /// Whether every point is as wide on both sides. + pub fn symmetric(&self) -> bool { + self.points.iter().all(WidthPoint::symmetric) + } + + /// `value` of the points at `t`: monotone cubic between points, held flat before the first and + /// after the last. + fn interp(&self, t: f64, value: impl Fn(&WidthPoint) -> f64) -> f64 { let pts = &self.points; let (Some(first), Some(last)) = (pts.first(), pts.last()) else { return 1.0 }; if !t.is_finite() || t <= first.t { - return first.scale; + return value(first); } if t >= last.t { - return last.scale; + return value(last); } let k = pts.partition_point(|p| p.t <= t).saturating_sub(1); - let (Some(a), Some(b)) = (pts.get(k), pts.get(k + 1)) else { return last.scale }; + let (Some(a), Some(b)) = (pts.get(k), pts.get(k + 1)) else { return value(last) }; let h = b.t - a.t; if h <= 1e-12 { - return b.scale; + return value(b); } let u = (t - a.t) / h; let (u2, u3) = (u * u, u * u * u); - let w = (2.0 * u3 - 3.0 * u2 + 1.0) * a.scale - + (u3 - 2.0 * u2 + u) * h * self.tangent(k) - + (3.0 * u2 - 2.0 * u3) * b.scale - + (u3 - u2) * h * self.tangent(k + 1); + let w = (2.0 * u3 - 3.0 * u2 + 1.0) * value(a) + + (u3 - 2.0 * u2 + u) * h * self.tangent(k, &value) + + (3.0 * u2 - 2.0 * u3) * value(b) + + (u3 - u2) * h * self.tangent(k + 1, &value); w.max(0.0) } - /// Slope of the segment from point `i` to `i + 1` (0 when they share a position). - fn secant(&self, i: usize) -> f64 { + /// Slope of `value` from point `i` to `i + 1` (0 when they share a position). + fn secant(&self, i: usize, value: &impl Fn(&WidthPoint) -> f64) -> f64 { match (self.points.get(i), self.points.get(i + 1)) { - (Some(a), Some(b)) if b.t - a.t > 1e-12 => (b.scale - a.scale) / (b.t - a.t), + (Some(a), Some(b)) if b.t - a.t > 1e-12 => (value(b) - value(a)) / (b.t - a.t), _ => 0.0, } } /// Fritsch–Carlson style tangent at point `i`: one-sided at the ends, the harmonic mean of the /// neighbouring slopes inside, flat at local extremes (keeps the curve monotone between points). - fn tangent(&self, i: usize) -> f64 { + fn tangent(&self, i: usize, value: &impl Fn(&WidthPoint) -> f64) -> f64 { let last = self.points.len().saturating_sub(1); if i == 0 { - return self.secant(0); + return self.secant(0, value); } if i >= last { - return self.secant(last.saturating_sub(1)); + return self.secant(last.saturating_sub(1), value); } - let (d0, d1) = (self.secant(i - 1), self.secant(i)); + let (d0, d1) = (self.secant(i - 1, value), self.secant(i, value)); if d0 * d1 <= 0.0 { 0.0 } else { 2.0 * d0 * d1 / (d0 + d1) } } - /// The largest scale (the interpolation never exceeds its points). + /// The largest side scale (the interpolation never exceeds its points): the widest the + /// stroke reaches from its centre line is `weight × max_scale / 2`. pub fn max_scale(&self) -> f64 { - self.points.iter().map(|p| p.scale).fold(0.0, f64::max) + self.points.iter().map(|p| p.sides().0.max(p.sides().1)).fold(0.0, f64::max) } - /// Add a point (kept sorted); returns its index. + /// Add a symmetric point (kept sorted); returns its index. pub fn insert(&mut self, t: f64, scale: f64) -> usize { - let p = WidthPoint::new( - if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }, - if scale.is_finite() { scale.clamp(0.0, Self::MAX_SCALE) } else { 1.0 }, - ); + self.insert_point(WidthPoint::new(t, if scale.is_finite() { scale } else { 1.0 })) + } + + /// Add a point at `t` as wide on each side as the stroke already is there; returns its index. + pub fn insert_here(&mut self, t: f64) -> usize { + let (l, r) = self.sides_at(t); + self.insert_point(WidthPoint::from_sides(t, l, r)) + } + + fn insert_point(&mut self, mut p: WidthPoint) -> usize { + p.t = if p.t.is_finite() { p.t.clamp(0.0, 1.0) } else { 0.0 }; + p.scale = p.scale.clamp(0.0, Self::MAX_SCALE); let i = self.points.partition_point(|q| q.t <= p.t); self.points.insert(i, p); i } + /// Set one side of point `i` to side scale `scale`, keeping the other side. Returns false when + /// there is no such point. + pub fn set_side(&mut self, i: usize, side: WidthSide, scale: f64) -> bool { + let Some(p) = self.points.get_mut(i) else { return false }; + if !scale.is_finite() { + return true; + } + let s = scale.clamp(0.0, 2.0 * Self::MAX_SCALE); + let (l, r) = p.sides(); + let q = match side { + WidthSide::Left => WidthPoint::from_sides(p.t, s, r), + WidthSide::Right => WidthPoint::from_sides(p.t, l, s), + }; + p.scale = q.scale.min(Self::MAX_SCALE); + p.bias = q.bias; + true + } + /// Move point `i` along the path (kept between its neighbours, so indices stay put) and/or /// change its scale. Returns false when there is no such point. pub fn set_point(&mut self, i: usize, t: Option, scale: Option) -> bool { @@ -238,8 +347,10 @@ impl WidthProfile { if let Some(t) = t.filter(|t| t.is_finite()) { p.t = t.clamp(lo, hi.max(lo)); } + // A new width makes the point symmetric again. if let Some(s) = scale.filter(|s| s.is_finite()) { p.scale = s.clamp(0.0, Self::MAX_SCALE); + p.bias = 0.0; } true } diff --git a/crates/doc/src/width_sides_tests.rs b/crates/doc/src/width_sides_tests.rs new file mode 100644 index 0000000..bd754eb --- /dev/null +++ b/crates/doc/src/width_sides_tests.rs @@ -0,0 +1,106 @@ +//! One-sided variable widths: width points whose sides differ, interpolated side by side, edited +//! one side at a time, sanitized, and saved without changing files that don't use them. + +use crate::{StrokeProps, WidthPoint, WidthPreset, WidthProfile, WidthSide}; + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn a_point_is_built_from_and_reports_its_sides() { + let p = WidthPoint::from_sides(0.5, 1.0, 3.0); + assert!(near(p.scale, 2.0) && near(p.bias, 0.5), "{p:?}"); + let (l, r) = p.sides(); + assert!(near(l, 1.0) && near(r, 3.0)); + assert!(!p.symmetric()); + // All on one side, nothing at all, and junk. + assert_eq!(WidthPoint::from_sides(0.0, 0.0, 2.0).sides(), (0.0, 2.0)); + assert_eq!(WidthPoint::from_sides(0.0, 0.0, 0.0), WidthPoint::new(0.0, 0.0)); + assert_eq!(WidthPoint::from_sides(0.0, f64::NAN, -4.0), WidthPoint::new(0.0, 0.0)); + assert!(WidthPoint::from_sides(0.0, 2.0, 2.0).symmetric()); + assert_eq!(WidthSide::from_id("LEFT"), Some(WidthSide::Left)); + assert_eq!(WidthSide::from_id("up"), None); + assert_eq!(WidthSide::Right.id(), "right"); +} + +#[test] +fn sides_interpolate_on_their_own_and_symmetric_profiles_are_unchanged() { + // Left stays 1 while the right swells to 3 and back. + let p = WidthProfile { points: vec![WidthPoint::new(0.0, 1.0), WidthPoint::from_sides(0.5, 1.0, 3.0), WidthPoint::new(1.0, 1.0)] }.sanitized(); + assert!(!p.symmetric()); + for i in 0..=20 { + let t = f64::from(i) / 20.0; + let (l, r) = p.sides_at(t); + assert!(near(l, 1.0), "the left side holds at {t}: {l}"); + assert!((1.0..=3.0 + 1e-9).contains(&r), "{t}: {r}"); + assert!(near(p.scale_at(t), (l + r) / 2.0)); + } + assert!(near(p.sides_at(0.5).1, 3.0)); + // The widest side bounds the stroke. + assert!(near(p.max_scale(), 3.0)); + // A symmetric profile interpolates exactly as before. + let eye = WidthPreset::Eye.profile().unwrap(); + assert!(eye.symmetric()); + for i in 0..=10 { + let t = f64::from(i) / 10.0; + let (l, r) = eye.sides_at(t); + assert_eq!(l, r); + assert_eq!(eye.scale_at(t), l); + } +} + +#[test] +fn one_side_is_edited_and_a_new_width_makes_a_point_symmetric_again() { + let mut p = WidthProfile::uniform(); + assert!(p.set_side(1, WidthSide::Right, 4.0)); + assert_eq!(p.points[1].sides(), (1.0, 4.0)); + assert!(p.set_side(1, WidthSide::Left, 0.0)); + assert_eq!(p.points[1].sides(), (0.0, 4.0)); + assert!(!p.set_side(9, WidthSide::Left, 1.0), "no such point"); + assert!(p.set_side(1, WidthSide::Left, f64::NAN), "junk leaves it as it was"); + assert_eq!(p.points[1].sides(), (0.0, 4.0)); + // A point added between keeps both sides as wide as the stroke is there. + let i = p.insert_here(0.5); + let (l, r) = p.points[i].sides(); + let (wl, wr) = p.sides_at(0.5); + assert!(near(l, wl) && near(r, wr), "{l} {r} vs {wl} {wr}"); + assert!(l < r); + // Setting the width (both sides) makes it symmetric. + assert!(p.set_point(i, None, Some(2.0))); + assert!(p.points[i].symmetric()); + assert_eq!(p.points[i].scale, 2.0); +} + +#[test] +fn hostile_biases_are_sanitized() { + let p = WidthProfile { + points: vec![ + WidthPoint { t: 0.2, scale: 1.0, bias: f64::NAN }, + WidthPoint { t: 0.8, scale: 1.0, bias: 7.0 }, + WidthPoint { t: 0.5, scale: 2.0, bias: -1e9 }, + ], + } + .sanitized(); + assert_eq!(p.points.iter().map(|q| q.bias).collect::>(), vec![0.0, -1.0, 1.0]); + for i in 0..=10 { + let (l, r) = p.sides_at(f64::from(i) / 10.0); + assert!(l.is_finite() && r.is_finite() && l >= 0.0 && r >= 0.0); + } +} + +#[test] +fn files_keep_their_shape() { + // Symmetric points save exactly as before (no bias key); one-sided ones round-trip. + let sym = serde_json::to_value(WidthPoint::new(0.5, 2.0)).unwrap(); + assert_eq!(sym, serde_json::json!({"t": 0.5, "scale": 2.0})); + let old: WidthPoint = serde_json::from_value(serde_json::json!({"t": 0.25, "scale": 3.0})).unwrap(); + assert_eq!(old, WidthPoint::new(0.25, 3.0)); + let one = WidthPoint::from_sides(0.5, 1.0, 3.0); + let back: WidthPoint = serde_json::from_value(serde_json::to_value(one).unwrap()).unwrap(); + assert_eq!(back, one); + let stroke = StrokeProps { width_profile: Some(WidthProfile { points: vec![one] }), ..Default::default() }; + let back: StrokeProps = serde_json::from_str(&serde_json::to_string(&stroke).unwrap()).unwrap(); + assert_eq!(back, stroke); + assert!(near(back.max_weight(), 3.0)); +} diff --git a/crates/engine/src/arc_fields_tests.rs b/crates/engine/src/arc_fields_tests.rs new file mode 100644 index 0000000..8570210 --- /dev/null +++ b/crates/engine/src/arc_fields_tests.rs @@ -0,0 +1,68 @@ +//! The Design panel's Arc fields drive the same `node.set` keys as the canvas handles: Start in +//! degrees, Sweep and Ratio as percentages, and number variables bound to them (sweep and ratio +//! variables hold percentages, like the fields). + +use std::f64::consts::{PI, TAU}; + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn arc(s: &Session, id: NodeId) -> (f64, f64, f64) { + match s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Ellipse { start, sweep, inner } => (start, sweep, inner), + _ => panic!("not an ellipse"), + } +} + +fn ellipse(s: &mut Session) -> NodeId { + id_of(&ex(s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap() +} + +#[test] +fn fields_write_degrees_and_percentages() { + let mut s = Session::new(); + let e = ellipse(&mut s); + // What the fields send for Start 90°, Sweep 75% and Ratio 40%. + ex(&mut s, "node.set", json!({"id": e, "props": {"arcStart": 90, "arcSweep": 75.0 * 3.6, "arcInner": 0.4}})); + let (start, sweep, inner) = arc(&s, e); + assert!((start - PI / 2.0).abs() < 1e-9 && (sweep - 0.75 * TAU).abs() < 1e-9 && (inner - 0.4).abs() < 1e-9); + // A negative sweep runs the other way; past a full turn it holds at one. + ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": -25.0 * 3.6}})); + assert!((arc(&s, e).1 + 0.25 * TAU).abs() < 1e-9); + ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": 900}})); + assert!((arc(&s, e).1 - TAU).abs() < 1e-9); + // Inspect reports the arc the fields show. + ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": 180}})); + let n = ex(&mut s, "node.get", json!({"id": e})); + assert!((n["arc"]["sweep"].as_f64().unwrap() - 180.0).abs() < 1e-9); + // Arc keys are for ellipses only. + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"]).unwrap(); + assert!(s.execute("node.set", &json!({"id": r, "props": {"arcSweep": 90}})).is_err()); +} + +#[test] +fn variables_bound_to_the_arc_hold_what_the_fields_show() { + let mut s = Session::new(); + let e = ellipse(&mut s); + ex(&mut s, "variable.createCollection", json!({"name": "Shape", "modes": ["A", "B"]})); + ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "start", "type": "number", "value": 45})); + ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "sweep", "type": "number", "value": 50})); + ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "ratio", "type": "number", "value": 25})); + for (field, var) in [("arcStart", "start"), ("arcSweep", "sweep"), ("arcInner", "ratio")] { + ex(&mut s, "variable.bind", json!({"ids": [e], "field": field, "variable": var})); + } + let (start, sweep, inner) = arc(&s, e); + assert!((start - PI / 4.0).abs() < 1e-9, "degrees: {start}"); + assert!((sweep - PI).abs() < 1e-9, "50% of a turn: {sweep}"); + assert!((inner - 0.25).abs() < 1e-9, "25%: {inner}"); + // A small ratio stays a percentage. + ex(&mut s, "variable.set", json!({"variable": "ratio", "value": 1})); + assert!((arc(&s, e).2 - 0.01).abs() < 1e-9, "{:?}", arc(&s, e)); +} diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/booleans.rs new file mode 100644 index 0000000..53553d9 --- /dev/null +++ b/crates/engine/src/booleans.rs @@ -0,0 +1,130 @@ +//! Live boolean editing: a boolean group's operands stay layers of their own. +//! +//! - **Selecting operands.** A click selects the boolean group; a double click (or Cmd/Ctrl-click, +//! or a click while a sibling operand is selected) selects the operand under the pointer, and a +//! double click on a selected shape operand enters vector edit mode on it. Enter selects every +//! operand. +//! - **Live results.** Moving, resizing or editing the points of an operand changes the document +//! on every pointer move, so the group's result is redrawn during the drag (one undo step per +//! gesture); a dragged operand stays in its group. The renderer caches every prefix of the group's fold, so only the operands from the +//! edited one up are recombined ([`figcraft_render::boolean`]). While an operand is selected or +//! edited, the canvas outlines every operand of its group ([`outlined_operands`]). +//! - **Per-operand operations.** Each operand above the bottom one combines with the result of the +//! operands below it by its own operation (`Node::bool_op`), or by the group's. Choosing an +//! operation with operands selected sets theirs; with one boolean group selected it sets the +//! group's (and every operand follows it again); otherwise the selection is wrapped in a new +//! boolean group ([`apply`]). + +use figcraft_doc::{BoolOp, Document, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::params::req_targets; +use crate::{Result, Session}; + +/// Whether `id` is a boolean group. +pub fn is_group(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::BooleanOp { .. })) +} + +/// The boolean group `id` is an operand of. +pub fn group_of(doc: &Document, id: NodeId) -> Option { + let parent = doc.node(id)?.parent?; + is_group(doc, parent).then_some(parent) +} + +/// How operand `id` combines with the operands below it: its own operation or its group's +/// (`None` outside boolean groups). +pub fn operand_op(doc: &Document, id: NodeId) -> Option { + let g = group_of(doc, id)?; + let NodeKind::BooleanOp { op } = doc.node(g)?.kind else { return None }; + Some(doc.node(id)?.bool_op.unwrap_or(op)) +} + +/// An operand above the bottom one whose operation differs from its group's (what the layers +/// panel marks with the operation's icon). +pub fn distinct_op(doc: &Document, id: NodeId) -> Option { + let g = doc.node(group_of(doc, id)?)?; + let NodeKind::BooleanOp { op } = g.kind else { return None }; + let own = doc.node(id)?.bool_op.filter(|o| *o != op)?; + (g.children.first() != Some(&id)).then_some(own) +} + +/// The operation the boolean controls show for `ids`: the group's for a lone boolean group, the +/// operands' when they share one (`None` otherwise). +pub fn shown_op(doc: &Document, ids: &[NodeId]) -> Option { + if let [one] = ids + && let Some(NodeKind::BooleanOp { op }) = doc.node(*one).map(|n| &n.kind) + { + return Some(*op); + } + let mut ops = ids.iter().map(|i| operand_op(doc, *i)); + let first = ops.next()??; + ops.all(|o| o == Some(first)).then_some(first) +} + +/// The operands to outline on the canvas while layers inside a boolean group are selected or +/// edited (`editing`: the layer in vector edit mode): every operand of the nearest boolean group +/// holding each of them, in layer order, without repeats. +pub fn outlined_operands(doc: &Document, selection: &[NodeId], editing: Option) -> Vec { + let mut out = Vec::new(); + for id in selection.iter().copied().chain(editing) { + let Some(g) = doc.ancestors(id).into_iter().find(|a| is_group(doc, *a)) else { continue }; + for c in doc.node(g).map(|n| n.children.clone()).unwrap_or_default() { + if doc.node(c).is_some_and(|n| n.visible) && !out.contains(&c) { + out.push(c); + } + } + } + out +} + +/// `bool.union` / `bool.subtract` / `bool.intersect` / `bool.exclude`: see the module docs. +pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result { + let ids = req_targets(cmd, s, p)?; + let doc = s.doc()?; + let lone_group = match ids.as_slice() { + [one] if is_group(doc, *one) => Some(*one), + _ => None, + }; + if let Some(g) = lone_group { + let doc = s.doc_mut()?; + if let NodeKind::BooleanOp { op: o } = &mut doc.get_mut(g)?.kind { + *o = op; + } + doc.get_mut(g)?.name = renamed(&doc.get(g)?.name, op); + for c in doc.get(g)?.children.clone() { + if doc.get(c)?.bool_op.is_some() { + doc.get_mut(c)?.bool_op = None; + } + } + return Ok(json!({"id": g, "operation": op})); + } + if ids.iter().all(|i| group_of(doc, *i).is_some()) { + let doc = s.doc_mut()?; + for i in &ids { + let group_op = match group_of(doc, *i).and_then(|g| doc.node(g)).map(|g| &g.kind) { + Some(NodeKind::BooleanOp { op }) => *op, + _ => continue, + }; + doc.get_mut(*i)?.bool_op = (op != group_op).then_some(op); + } + return Ok(json!({"ids": ids, "operation": op})); + } + let out = crate::cmd::structure::wrap_boolean(s, p, op)?; + // Operands of a new group follow its operation. + if let Some(g) = crate::params::id_of(&out["id"]) { + let doc = s.doc_mut()?; + for c in doc.get(g)?.children.clone() { + if doc.get(c)?.bool_op.is_some() { + doc.get_mut(c)?.bool_op = None; + } + } + } + Ok(out) +} + +/// A boolean group's default name follows its operation; other names stay. +fn renamed(name: &str, op: BoolOp) -> String { + let default = [BoolOp::Union, BoolOp::Subtract, BoolOp::Intersect, BoolOp::Exclude].iter().any(|o| o.label() == name); + if default { op.label().to_string() } else { name.to_string() } +} diff --git a/crates/engine/src/booleans_tests.rs b/crates/engine/src/booleans_tests.rs new file mode 100644 index 0000000..0687195 --- /dev/null +++ b/crates/engine/src/booleans_tests.rs @@ -0,0 +1,215 @@ +//! Live boolean editing: selecting operands on the canvas, results that follow an operand during a +//! drag or a point edit (one undo step each), per-operand operations from the boolean commands +//! and `node.set`, the operands outlined on the canvas, flatten, inspect and junk parameters. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::booleans::{distinct_op, operand_op, outlined_operands, shown_op}; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{BoolOp, NodeId, NodeKind}; +use figcraft_geom::Shape; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent { + PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) } +} + +fn click(s: &mut Session, x: f64, y: f64, cmd: bool) { + s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap(); + s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap(); +} + +/// A layer's filled area (a boolean group's result; none of these have holes). +fn area(s: &Session, id: NodeId) -> f64 { + let doc = s.doc().unwrap(); + figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs() +} + +/// A 100 × 100 square at the origin minus one at (50, 50): (group, bottom, top), nothing +/// selected, snapping off. +fn subtract(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = id_of(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + let b = id_of(&ex(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"]).unwrap(); + let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap(); + ex(s, "select.none", json!({})); + let ds = s.ds_mut().unwrap(); + ds.view.snap_objects = false; + ds.view.snap_pixel = false; + (g, a, b) +} + +#[test] +fn clicks_select_the_group_and_reach_operands() { + let mut s = Session::new(); + let (g, a, b) = subtract(&mut s); + assert!((area(&s, g) - 7500.0).abs() < 1.0); + click(&mut s, 20.0, 20.0, false); + assert_eq!(s.selection(), vec![g], "a click selects the group"); + click(&mut s, 20.0, 20.0, true); + assert_eq!(s.selection(), vec![a], "Cmd-click selects the operand"); + // With an operand selected, a click on another picks it. + click(&mut s, 120.0, 120.0, false); + assert_eq!(s.selection(), vec![b]); + // A double click goes into the group, then into vector edit mode on the operand. + ex(&mut s, "select.set", json!({"ids": [g]})); + s.pointer(&ev(PointerKind::DoubleClick, 120.0, 120.0, false)).unwrap(); + assert_eq!(s.selection(), vec![b], "double click: the operand under the pointer"); + s.pointer(&ev(PointerKind::DoubleClick, 120.0, 120.0, false)).unwrap(); + let ve = s.ds().unwrap().vector_edit.clone().expect("vector edit on the operand"); + assert_eq!(ve.id, b); + assert!(matches!(s.doc().unwrap().node(b).unwrap().kind, NodeKind::Vector { .. })); + assert_eq!(s.doc().unwrap().node(b).unwrap().parent, Some(g), "still an operand"); + assert!((area(&s, g) - 7500.0).abs() < 1.0, "converting it changes nothing"); + // The canvas outlines every operand of the group while one is edited. + assert_eq!(outlined_operands(s.doc().unwrap(), &s.selection(), Some(b)), vec![a, b]); + assert!(outlined_operands(s.doc().unwrap(), &[g], None).is_empty(), "not for the group itself"); +} + +#[test] +fn moving_an_operand_updates_the_result_during_the_drag() { + let mut s = Session::new(); + let (g, a, _) = subtract(&mut s); + ex(&mut s, "select.set", json!({"ids": [a]})); + s.pointer(&ev(PointerKind::Down, 20.0, 20.0, false)).unwrap(); + s.pointer(&ev(PointerKind::Move, 30.0, 20.0, false)).unwrap(); + s.pointer(&ev(PointerKind::Move, 40.0, 20.0, false)).unwrap(); + // Mid-drag: A spans 20..120, so the cut is 70 × 50. + assert!((area(&s, g) - 6500.0).abs() < 1.0, "live: {}", area(&s, g)); + s.pointer(&ev(PointerKind::Up, 40.0, 20.0, false)).unwrap(); + let doc = s.doc().unwrap(); + assert!((area(&s, g) - 6500.0).abs() < 1.0, "after: {} {:?} {:?}", area(&s, g), doc.world_bounds(a), doc.node(g).unwrap().children); + // The group's box follows its operands. + let gb = s.doc().unwrap().world_bounds(g).unwrap(); + assert!((gb.x0 - 20.0).abs() < 1e-6, "{gb:?}"); + // One undo step for the drag. + s.undo().unwrap(); + assert!((area(&s, g) - 7500.0).abs() < 1.0); + assert!((s.doc().unwrap().world_bounds(a).unwrap().x0).abs() < 1e-6); +} + +#[test] +fn editing_an_operands_points_updates_the_result_live() { + let mut s = Session::new(); + let (g, a, _) = subtract(&mut s); + ex(&mut s, "vector.edit.begin", json!({"id": a})); + // Drag A's top-left corner 50 to the left: A becomes a 12,500 trapezoid, the cut stays. + s.pointer(&ev(PointerKind::Down, 0.0, 0.0, false)).unwrap(); + s.pointer(&ev(PointerKind::Move, -25.0, 0.0, false)).unwrap(); + s.pointer(&ev(PointerKind::Move, -50.0, 0.0, false)).unwrap(); + assert!((area(&s, g) - 10000.0).abs() < 1.0, "live: {}", area(&s, g)); + s.pointer(&ev(PointerKind::Up, -50.0, 0.0, false)).unwrap(); + assert!((area(&s, g) - 10000.0).abs() < 1.0); + s.undo().unwrap(); + assert!((area(&s, g) - 7500.0).abs() < 1.0, "one undo step: {}", area(&s, g)); +} + +#[test] +fn boolean_commands_set_operand_and_group_operations() { + let mut s = Session::new(); + let (g, a, b) = subtract(&mut s); + let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 50}))["id"]).unwrap(); + ex(&mut s, "node.reparent", json!({"ids": [c], "parent": g})); + assert_eq!(s.doc().unwrap().node(c).unwrap().parent, Some(g)); + // Subtracting the top operand removes it; uniting it adds its 2,500. + assert!((area(&s, g) - 7500.0).abs() < 1.0); + let r = ex(&mut s, "bool.union", json!({"ids": [c]})); + assert_eq!(r["operation"], json!("union")); + assert_eq!(operand_op(s.doc().unwrap(), c), Some(BoolOp::Union)); + assert_eq!(distinct_op(s.doc().unwrap(), c), Some(BoolOp::Union)); + assert!((area(&s, g) - 10000.0).abs() < 1.0, "{}", area(&s, g)); + assert_eq!(s.ds().unwrap().doc.node(g).unwrap().children.len(), 3, "no new group"); + // Undo is one step; the operand follows the group again when set to its operation. + s.undo().unwrap(); + assert!((area(&s, g) - 7500.0).abs() < 1.0); + s.redo().unwrap(); + ex(&mut s, "bool.subtract", json!({"ids": [c]})); + assert_eq!(s.doc().unwrap().node(c).unwrap().bool_op, None); + // The controls show the selection's operation. + ex(&mut s, "bool.intersect", json!({"ids": [b]})); + assert_eq!(shown_op(s.doc().unwrap(), &[b]), Some(BoolOp::Intersect)); + assert_eq!(shown_op(s.doc().unwrap(), &[b, c]), None, "mixed"); + assert_eq!(shown_op(s.doc().unwrap(), &[g]), Some(BoolOp::Subtract)); + // (A ∩ B) − C: the 2,500 overlap. + assert!((area(&s, g) - 2500.0).abs() < 1.0, "{}", area(&s, g)); + // One boolean group selected: its operation changes and every operand follows it. + ex(&mut s, "bool.union", json!({"ids": [g]})); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(g).unwrap().kind, NodeKind::BooleanOp { op: BoolOp::Union })); + assert_eq!(doc.node(g).unwrap().name, "Union", "a default name follows the operation"); + assert!([a, b, c].iter().all(|i| doc.node(*i).unwrap().bool_op.is_none())); + assert!((area(&s, g) - 20000.0).abs() < 1.0, "{}", area(&s, g)); + // Anything else is wrapped in a new group, whose operands follow it. + let d = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10}))["id"]).unwrap(); + let r = ex(&mut s, "bool.exclude", json!({"ids": [g, d]})); + let outer = id_of(&r["id"]).unwrap(); + assert_ne!(outer, g); + assert_eq!(s.doc().unwrap().node(g).unwrap().parent, Some(outer)); + // Flatten bakes the mixed result. + ex(&mut s, "bool.subtract", json!({"ids": [c]})); + let before = area(&s, g); + let r = ex(&mut s, "bool.flatten", json!({"ids": [g]})); + let flat = id_of(&r["id"]).unwrap(); + assert!((before - 17500.0).abs() < 1.0, "(A ∪ B) − C, C off to the side: {before}"); + assert!((area(&s, flat) - before).abs() < 2.0, "{} vs {before}", area(&s, flat)); +} + +#[test] +fn node_set_and_inspect_carry_operand_operations() { + let mut s = Session::new(); + let (g, a, b) = subtract(&mut s); + ex(&mut s, "node.set", json!({"id": b, "props": {"booleanOperation": "intersect"}})); + assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, Some(BoolOp::Intersect)); + let n = ex(&mut s, "node.get", json!({"id": b})); + assert_eq!(n["operandOperation"], json!("intersect")); + ex(&mut s, "node.set", json!({"id": b, "props": {"operandOperation": null}})); + assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, None); + assert!(ex(&mut s, "node.get", json!({"id": b})).get("operandOperation").is_none()); + // On the group, booleanOperation is its own. + ex(&mut s, "node.set", json!({"id": g, "props": {"booleanOperation": "exclude"}})); + assert!(matches!(s.doc().unwrap().node(g).unwrap().kind, NodeKind::BooleanOp { op: BoolOp::Exclude })); + // Errors: outside boolean groups, bad names. + let lone = id_of(&ex(&mut s, "shape.ellipse", json!({"x": 300, "y": 300, "width": 10, "height": 10}))["id"]).unwrap(); + assert!(s.execute("node.set", &json!({"id": lone, "props": {"booleanOperation": "union"}})).is_err()); + assert!(s.execute("node.set", &json!({"id": a, "props": {"operandOperation": "sideways"}})).is_err()); + // Ungrouping releases the operands' own operations. + ex(&mut s, "node.set", json!({"id": b, "props": {"operandOperation": "union"}})); + ex(&mut s, "object.ungroup", json!({"ids": [g]})); + assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, None); +} + +#[test] +fn boolean_commands_survive_junk() { + let junk = [ + json!({}), + json!(null), + json!({"ids": "x"}), + json!({"ids": [999_999]}), + json!({"ids": [1e300, -1]}), + json!({"id": {"nested": true}}), + json!({"props": {"booleanOperation": [1, 2]}}), + ]; + for cmd in ["bool.union", "bool.subtract", "bool.intersect", "bool.exclude", "bool.flatten", "node.set"] { + for j in &junk { + let mut s = Session::new(); + let (g, _, b) = subtract(&mut s); + ex(&mut s, "select.set", json!({"ids": [b]})); + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + if let Some(n) = s.doc().unwrap().node(g) { + let _ = figcraft_render::node_outline(s.doc().unwrap(), n); + } + } + } + // node.set with junk operations on an operand. + let mut s = Session::new(); + let (_, _, b) = subtract(&mut s); + for v in [json!(7), json!({"op": "union"}), json!(""), json!(true)] { + assert!(s.execute("node.set", &json!({"id": b, "props": {"operandOperation": v}})).is_err(), "{v}"); + } +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 0462c9b..885473d 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -21,7 +21,8 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l (widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ -arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}"; +arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ +group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; /// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values). pub fn snake(v: &Value) -> Value { @@ -117,30 +118,72 @@ fn four(v: &Value) -> Option<[f64; 4]> { } } -pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)], ...] or [{t, scale}], {preset} | {points}, or null (uniform)"; +const BOOL_OP_HELP: &str = "booleanOperation / operandOperation: union|subtract|intersect|exclude (null: the group's, for operands)"; -/// A width profile from a preset name, points (`[[t, scale], ...]` or `[{t, scale}]`), `{preset}` -/// / `{points}`, or null. `Some(None)` = uniform; `None` = not a profile. +/// An operand's own boolean operation (`null`: its group's). +fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Result<()> { + if crate::booleans::group_of(doc, id).is_none() { + return Err(bad(cmd, "not a boolean group or a layer in one")); + } + doc.get_mut(id)?.bool_op = if v.is_null() { None } else { Some(enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?) }; + Ok(()) +} + +pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)"; + +/// A width profile from a preset name, points (`[[t, scale] | [t, left, right], ...]` or +/// `[{t, scale} | {t, left, right}]`), `{preset}` / `{points}`, or null. `Some(None)` = uniform; +/// `None` = not a profile. pub fn width_profile_of(v: &Value) -> Option> { match v { Value::Null => Some(None), Value::String(s) => WidthPreset::from_id(s).map(WidthPreset::profile), Value::Array(a) => { - let pts = a + let num = |x: Option<&Value>| x.and_then(f64_of); + let points = a .iter() .map(|p| match p { - Value::Array(xy) => Some((xy.first().and_then(f64_of)?, xy.get(1).and_then(f64_of)?)), - Value::Object(o) => Some((o.get("t").and_then(f64_of)?, o.get("scale").or_else(|| o.get("width")).and_then(f64_of)?)), + Value::Array(xy) => match (num(xy.first()), num(xy.get(1)), num(xy.get(2))) { + (Some(t), Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)), + (Some(t), Some(s), None) => Some(WidthPoint::new(t, s)), + _ => None, + }, + Value::Object(o) => { + let t = num(o.get("t"))?; + match (num(o.get("left")), num(o.get("right"))) { + (Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)), + _ => Some(WidthPoint::new(t, num(o.get("scale").or_else(|| o.get("width")))?)), + } + } _ => None, }) .collect::>>()?; - Some(Some(WidthProfile::new(pts)).filter(|p| !p.points.is_empty())) + Some(Some(WidthProfile { points }.sanitized()).filter(|p| !p.points.is_empty())) } Value::Object(o) => o.get("points").or_else(|| o.get("preset")).and_then(width_profile_of), _ => None, } } +/// A width point as [`width_profile_of`] reads it back: `[t, scale]`, or `[t, left, right]` when +/// its sides differ. +pub fn width_point_json(p: &WidthPoint) -> Value { + if p.symmetric() { + json!([p.t, p.scale]) + } else { + let (l, r) = p.sides(); + json!([p.t, l, r]) + } +} + +/// A profile's points as [`width_profile_of`] reads them back (`null` without points). +pub fn width_points_json(profile: Option<&WidthProfile>) -> Value { + match profile.filter(|p| !p.points.is_empty()) { + Some(p) => Value::Array(p.points.iter().map(width_point_json).collect()), + None => Value::Null, + } +} + const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]"; /// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`, @@ -597,10 +640,13 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map match &mut doc.get_mut(id)?.kind { - NodeKind::BooleanOp { op } => *op = enum_of(v).ok_or_else(|| bad(cmd, "booleanOperation: union|subtract|intersect|exclude"))?, - _ => return Err(bad(cmd, "not a boolean group")), - }, + "booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?, + "booleanOperation" => { + if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind { + *op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?; + } + } + "operandOperation" => set_operand_op(doc, id, v, cmd)?, "sectionContentsHidden" => { if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { *contents_hidden = v.as_bool().unwrap_or(false); diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 22e34fc..3eed877 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -37,10 +37,10 @@ pub fn specs() -> Vec { } ok() }), - spec!("bool.union", "Union selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Union))), - spec!("bool.subtract", "Subtract selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Subtract))), - spec!("bool.intersect", "Intersect selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Intersect))), - spec!("bool.exclude", "Exclude selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Exclude))), + spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)), + spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)), + spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)), + spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)), spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten), spec!("vector.flatten", "Flatten", "{ids?}", true, flatten), spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke), @@ -103,6 +103,14 @@ enum Wrap { /// Room a section made around layers leaves on each side. const SECTION_PAD: f64 = 40.0; +/// What the boolean commands do with what they're given. +const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; + +/// Wrap the selection in a new boolean group doing `op`. +pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { + wrap(s, p, Wrap::Bool(op)) +} + /// Wrap the selection in a new container at the topmost selected layer's place. fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { let ids = req_targets("object.group", s, p)?; @@ -178,6 +186,10 @@ fn ungroup(s: &mut Session, p: &Value) -> Result { let op = n.opacity; for (k, c) in kids.iter().enumerate() { doc.reparent(*c, parent, Some(idx + k))?; + // Released operands no longer combine with anything. + if doc.get(*c)?.bool_op.is_some() { + doc.get_mut(*c)?.bool_op = None; + } if op < 1.0 { let cn = doc.get_mut(*c)?; cn.opacity *= op; diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 1419ba1..ce31bb0 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -681,6 +681,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr VariableValue::Color(c) => json!(c.to_css_hex()), // Opacity variables hold percentages. VariableValue::Number(x) if field == "opacity" => json!((x / 100.0).clamp(0.0, 1.0)), + // Arc sweep and ratio variables hold percentages too (what the Arc fields show). + VariableValue::Number(x) if field == "arcSweep" => json!(x * 3.6), + VariableValue::Number(x) if field == "arcInner" => json!(x / 100.0), VariableValue::Number(x) => json!(x), VariableValue::String(x) => json!(x), VariableValue::Boolean(b) => json!(b), diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 063a1a7..a2ab838 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -689,6 +689,8 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT fresh.dev_changed = root.dev_changed; fresh.dev_note = root.dev_note.clone(); fresh.constrain_proportions = root.constrain_proportions; + // How the instance combines in a boolean group it sits in is its own. + fresh.bool_op = root.bool_op; // A swapped nested instance keeps the name its layer was given in the outer main. let renamed = nested_copy && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index dc77f10..09445aa 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -53,7 +53,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("dashes".into(), json!(n.stroke.dashes)); } if let Some(p) = n.stroke.profile() { - let points: Vec<[f64; 2]> = p.points.iter().map(|q| [q.t, q.scale]).collect(); + let points = crate::cmd::props::width_points_json(Some(p)); o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); } } @@ -145,6 +145,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if let Some(slot) = crate::components::slot_name(doc, n.id) { o.insert("slot".into(), json!(slot)); } + // An operand combining by an operation of its own. + if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { + o.insert("operandOperation".into(), json!(op)); + } } match &n.kind { NodeKind::Text(t) => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 02eff05..a3ca427 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; +pub mod booleans; pub mod catalog; pub mod cmd; pub mod codegen; @@ -1123,12 +1124,16 @@ mod annotation_categories_tests; #[cfg(test)] mod annotations_tests; #[cfg(test)] +mod arc_fields_tests; +#[cfg(test)] mod arc_handles_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod booleans_tests; +#[cfg(test)] mod collab_ids_tests; #[cfg(test)] mod collab_live_tests; @@ -1215,6 +1220,8 @@ mod section_tests; #[cfg(test)] mod selection_depth_tests; #[cfg(test)] +mod shapebuilder_strokes_tests; +#[cfg(test)] mod shapebuilder_tests; #[cfg(test)] mod slot_tests; diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs index 690c911..b3fe126 100644 --- a/crates/engine/src/shapebuilder.rs +++ b/crates/engine/src/shapebuilder.rs @@ -6,6 +6,13 @@ //! way every shape covering a picked region loses that area (it becomes a vector, or is deleted //! when nothing is left), so the rest of the drawing stays exactly where it was. //! +//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its +//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and +//! the band a stroke paints is a region too. When a stroked shape takes part in a merge or a +//! removal, its stroke becomes an outlined vector (filled with the stroke's paints) just above it, +//! so both keep looking the same; open paths, which have no fill, are replaced by that outline. A +//! region merged from a stroke is filled with the stroke's paints. +//! //! With the Shape builder tool (Shift+M) the canvas highlights the region under the pointer; a //! click or a drag across regions merges them, and Alt (or the tool's Remove mode) removes them. //! Each gesture is one command, so one undo step. The arrangement is cached per document version @@ -25,6 +32,13 @@ use crate::params::*; use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId}; use crate::{EngineError, Result, Session, bad, spec}; +/// One input of the arrangement: a shape's fill area, or its stroke's outlined area. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)] +pub struct Part { + pub id: NodeId, + pub stroke: bool, +} + /// One region of the arrangement, in page coordinates. #[derive(Clone, Debug)] pub struct Region { @@ -32,6 +46,8 @@ pub struct Region { pub path: BezPath, /// The shapes covering it, top-most first. pub sources: Vec, + /// The fills and strokes covering it, top-most first. + pub parts: Vec, pub bounds: Rect, /// The outline flattened to polygons (outer first), for drawing. pub polys: Vec>, @@ -47,6 +63,8 @@ pub struct Arrangement { ids: Vec, /// The shapes taking part, bottom to top. pub sources: Vec, + /// Their fills and strokes, bottom to top (a shape's stroke above its fill). + pub parts: Vec, pub regions: Vec, } @@ -64,11 +82,11 @@ impl Arrangement { } } -/// Can `id` take part? Visible, unlocked shapes, boolean groups and text with an area, outside -/// instances and boolean groups (whose structure is fixed). +/// Can `id` take part? Visible, unlocked shapes (lines and open paths through their strokes), +/// boolean groups and text, outside instances and boolean groups (whose structure is fixed). fn usable(doc: &Document, id: NodeId) -> bool { let Some(n) = doc.node(id) else { return false }; - let kind_ok = (n.kind.is_shape() && !matches!(n.kind, NodeKind::Line)) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_)); + let kind_ok = n.kind.is_shape() || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_)); kind_ok && n.visible && !n.locked @@ -98,11 +116,19 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec { out } -/// A shape's fill area in page coordinates and whether it fills even-odd. -fn world_outline(doc: &Document, id: NodeId) -> Option<(BezPath, bool)> { - let n = doc.node(id)?; - let (mut p, rule) = figcraft_render::node_outline(doc, n)?; - p.apply_affine(doc.world_transform(id)); +/// A part's area in page coordinates and whether it fills even-odd: the shape's fill area (closed +/// shapes, boolean groups and text) or its visible stroke's outlined area. +fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> { + let n = doc.node(part.id)?; + let (mut p, rule) = if part.stroke { + figcraft_render::stroke_fill(doc, n).map(|(p, rule)| ((*p).clone(), rule))? + } else { + if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) { + return None; + } + figcraft_render::node_outline(doc, n)? + }; + p.apply_affine(doc.world_transform(part.id)); (!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd)) } @@ -125,11 +151,20 @@ fn polygons(path: &BezPath, bounds: Rect) -> Vec> { fn compute(doc: Arc, uid: u64, ids: &[NodeId]) -> Result { let mut sources = Vec::new(); + let mut parts = Vec::new(); let mut inputs = Vec::new(); for id in eligible(&doc, ids) { - if let Some(o) = world_outline(&doc, id) { + let before = parts.len(); + for part in [Part { id, stroke: false }, Part { id, stroke: true }] { + if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS + && let Some(o) = world_area(&doc, part) + { + parts.push(part); + inputs.push(o); + } + } + if parts.len() > before { sources.push(id); - inputs.push(o); } } let faces = figcraft_pathops::faces(&inputs).map_err(|e| EngineError::Other(format!("shape builder: {e}")))?; @@ -138,8 +173,16 @@ fn compute(doc: Arc, uid: u64, ids: &[NodeId]) -> Result .filter(|f| f.path.area().abs() > 1e-4) .map(|f| { let bounds = f.path.bounding_box(); + let covering: Vec = f.cover.iter().rev().filter_map(|i| parts.get(*i).copied()).collect(); + let mut by: Vec = Vec::new(); + for p in &covering { + if !by.contains(&p.id) { + by.push(p.id); + } + } Region { - sources: f.cover.iter().rev().filter_map(|i| sources.get(*i).copied()).collect(), + sources: by, + parts: covering, polys: polygons(&f.path, bounds), point: figcraft_doc::vector_faces::interior_point(&f.path), bounds, @@ -149,7 +192,7 @@ fn compute(doc: Arc, uid: u64, ids: &[NodeId]) -> Result .collect(); // Reading order: top to bottom, then left to right. regions.sort_by(|a, b| a.bounds.y0.total_cmp(&b.bounds.y0).then(a.bounds.x0.total_cmp(&b.bounds.x0))); - Ok(Arrangement { doc, uid, ids: ids.to_vec(), sources, regions }) + Ok(Arrangement { doc, uid, ids: ids.to_vec(), sources, parts, regions }) } /// The arrangement of `ids` (default: the selection), from the cache when the document and the @@ -230,9 +273,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> { crate::vectoredit::refit(doc, id) } -/// A new vector for the page-space area `area`, styled like `style`, just above it. -fn create_merged(doc: &mut Document, style: NodeId, area: &BezPath) -> Result { - let src = doc.get(style)?.clone(); +/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled +/// with the stroke's paints), just above its shape. +fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result { + let mut src = doc.get(style.id)?.clone(); + if style.stroke { + src.fills = std::mem::take(&mut src.strokes); + src.stroke = StrokeProps::default(); + src.styles.fill = src.styles.stroke.take(); + } + let style = style.id; let parent = src.parent.ok_or_else(|| EngineError::Other("no parent".into()))?; let pw = doc.world_transform(parent); if !(pw.determinant().is_finite() && pw.determinant().abs() > 1e-12) { @@ -266,7 +316,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { return Err(bad(cmd, "no region picked: pass `regions` (indexes from shapeBuilder.regions) or `points` inside regions")); } let regions: Vec<&Region> = pick.iter().filter_map(|i| a.regions.get(*i)).collect(); - let touched: BTreeSet = regions.iter().flat_map(|r| r.sources.iter().copied()).collect(); + let touched_parts: BTreeSet = regions.iter().flat_map(|r| r.parts.iter().copied()).collect(); + let touched: BTreeSet = touched_parts.iter().map(|p| p.id).collect(); // Merging every region of a single shape would only replace it with a copy of itself. let whole_shape = touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x))); @@ -276,40 +327,73 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { let doc = s.doc_mut()?; let created = if merge { let area = figcraft_pathops::union_faces(regions.iter().map(|r| &r.path)).map_err(|e| EngineError::Other(e.to_string()))?; - let style = regions.first().and_then(|r| r.sources.first().copied()).ok_or_else(|| bad(cmd, "the region has no shape"))?; + let style = regions.first().and_then(|r| r.parts.first().copied()).ok_or_else(|| bad(cmd, "the region has no shape"))?; Some(create_merged(doc, style, &area)?) } else { None }; - let mut removed = Vec::new(); - let mut changed = Vec::new(); - for src in a.sources.iter().copied().filter(|i| touched.contains(i)) { + // What part `part` keeps: the regions it covers that weren't picked, as one area (`None`: + // nothing). + let kept = |part: Part| -> Result> { let keep: Vec<&BezPath> = - a.regions.iter().enumerate().filter(|(i, r)| !pick.contains(i) && r.sources.contains(&src)).map(|(_, r)| &r.path).collect(); + a.regions.iter().enumerate().filter(|(i, r)| !pick.contains(i) && r.parts.contains(&part)).map(|(_, r)| &r.path).collect(); if keep.is_empty() { - doc.remove(src)?; - removed.push(src); - continue; + return Ok(None); } let area = figcraft_pathops::union_faces(keep).map_err(|e| EngineError::Other(e.to_string()))?; - if area.elements().is_empty() { - doc.remove(src)?; - removed.push(src); + Ok((!area.elements().is_empty()).then_some(area)) + }; + let mut removed = Vec::new(); + let mut changed = Vec::new(); + let mut outlined: Vec<(NodeId, NodeId)> = Vec::new(); + for src in a.sources.iter().copied().filter(|i| touched.contains(i)) { + let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true }); + // The stroke becomes an outline of its own (what's left of it), the shape loses it. + if a.parts.contains(&stroke) { + if let Some(area) = kept(stroke)? { + outlined.push((src, create_merged(doc, stroke, &area)?)); + } + let n = doc.get_mut(src)?; + n.strokes.clear(); + n.styles.stroke = None; + } + let left = if !a.parts.contains(&fill) { + None + } else if touched_parts.contains(&fill) { + kept(fill)? } else { - reshape(doc, src, &area)?; + // The fill wasn't picked: the shape stays as it is. changed.push(src); + continue; + }; + match left { + Some(area) => { + reshape(doc, src, &area)?; + changed.push(src); + } + None => { + doc.remove(src)?; + removed.push(src); + } } } - // The selection stays (minus what went) and gains the new shape unless a selected group - // already holds it, so the tool keeps working on the same shapes. + // The selection stays (minus what went, plus the outlines of selected strokes) and gains the + // new shape unless a selected group already holds it, so the tool keeps working on the same + // shapes. let mut sel: Vec = ids.iter().copied().filter(|i| !removed.contains(i)).collect(); + for (src, o) in &outlined { + if ids.contains(src) { + sel.push(*o); + } + } if let Some(c) = created && !doc.ancestors(c).iter().any(|x| sel.contains(x)) { sel.push(c); } s.set_selection(sel); - Ok(json!({"id": created, "changed": changed, "removed": removed, "regions": pick})) + let outlines: Vec = outlined.iter().map(|(_, o)| *o).collect(); + Ok(json!({"id": created, "changed": changed, "removed": removed, "outlines": outlines, "regions": pick})) } pub fn specs() -> Vec { @@ -317,7 +401,7 @@ pub fn specs() -> Vec { spec!( "shapeBuilder.regions", "Shape builder regions", - "{ids?} → {sources (bottom to top), regions: [{index, path (SVG, page), sources (top-most first), bounds [x, y, w, h], point [x, y] inside}]}", + "{ids?} → {sources (bottom to top), regions: [{index, path (SVG, page), sources (top-most first), parts: [{id, stroke}] (the fills and outlined strokes covering it, top-most first), bounds [x, y, w, h], point [x, y] inside}]} — visible strokes take part as their outlined areas, so stroked lines and open paths split regions", false, |s, p| { let ids = targets(s, p); @@ -331,6 +415,7 @@ pub fn specs() -> Vec { "index": i, "path": r.path.to_svg(), "sources": r.sources, + "parts": r.parts, "bounds": [r.bounds.x0, r.bounds.y0, r.bounds.width(), r.bounds.height()], "point": r.point.map(|q| [q.x, q.y]), }) @@ -342,14 +427,14 @@ pub fn specs() -> Vec { spec!( "shapeBuilder.merge", "Merge regions", - "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} — one new shape from the regions, styled like the top-most shape over the first; the shapes lose those areas", + "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)", true, |s, p| apply(s, "shapeBuilder.merge", p, true) ), spec!( "shapeBuilder.remove", "Remove regions", - "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} — the shapes covering the regions lose them", + "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {changed, removed, outlines, regions} — the shapes (and strokes) covering the regions lose them", true, |s, p| apply(s, "shapeBuilder.remove", p, false) ), diff --git a/crates/engine/src/shapebuilder_strokes_tests.rs b/crates/engine/src/shapebuilder_strokes_tests.rs new file mode 100644 index 0000000..259dedb --- /dev/null +++ b/crates/engine/src/shapebuilder_strokes_tests.rs @@ -0,0 +1,156 @@ +//! The shape builder with strokes and open paths: strokes take part as their outlined areas, open +//! paths split the regions they cross, merging a stroke's region fills it with the stroke's paints, +//! and the strokes of the shapes involved become outlined vectors. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_geom::Shape; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +/// A layer's filled area in page units. +fn area(s: &Session, id: NodeId) -> f64 { + let doc = s.doc().unwrap(); + let (mut p, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap(); + p.apply_affine(doc.world_transform(id)); + p.area().abs() +} + +fn fill_hex(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex() +} + +/// A red 100 × 100 square and, above it, a black 10 wide open path across its middle from x = -20 +/// to 120; both selected. +fn square_and_path(s: &mut Session) -> (NodeId, NodeId) { + let r = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}}))); + let l = id(&ex(s, "vector.create", json!({"points": [[-20, 50], [120, 50]], "closed": false}))); + ex(s, "node.set", json!({"id": l, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}})); + s.set_selection(vec![r, l]); + (r, l) +} + +#[test] +fn a_stroked_open_path_splits_the_regions_it_crosses() { + let mut s = Session::new(); + let (r, l) = square_and_path(&mut s); + let out = ex(&mut s, "shapeBuilder.regions", json!({})); + assert_eq!(out["sources"], json!([r, l])); + let regions = out["regions"].as_array().unwrap(); + // Above and below the path, the band across the square, and the path's two ends outside it. + assert_eq!(regions.len(), 5, "{out}"); + let covered = |x: f64, y: f64| -> Value { + regions + .iter() + .find(|g| { + let b = &g["bounds"]; + let (x0, y0) = (b[0].as_f64().unwrap(), b[1].as_f64().unwrap()); + x >= x0 && y >= y0 && x <= x0 + b[2].as_f64().unwrap() && y <= y0 + b[3].as_f64().unwrap() + }) + .map(|g| g["parts"].clone()) + .unwrap_or(Value::Null) + }; + assert_eq!(covered(50.0, 20.0), json!([{"id": r, "stroke": false}])); + assert_eq!(covered(50.0, 50.0), json!([{"id": l, "stroke": true}, {"id": r, "stroke": false}]), "the stroke is above the fill"); + assert_eq!(covered(-10.0, 50.0), json!([{"id": l, "stroke": true}])); + // A path without a visible stroke paints nothing and takes no part. + ex(&mut s, "node.set", json!({"id": l, "props": {"strokes": []}})); + let out = ex(&mut s, "shapeBuilder.regions", json!({})); + assert_eq!(out["sources"], json!([r])); + assert_eq!(out["regions"].as_array().unwrap().len(), 1); +} + +#[test] +fn merging_a_stroke_region_fills_it_with_the_stroke() { + let mut s = Session::new(); + let (r, l) = square_and_path(&mut s); + let out = ex(&mut s, "shapeBuilder.merge", json!({"points": [[50, 50]]})); + let m = id(&out); + // The band, black (the stroke's paint), no stroke of its own. + assert!((area(&s, m) - 1000.0).abs() < 1.0, "{}", area(&s, m)); + assert_eq!(fill_hex(&s, m), "000000"); + assert!(s.doc().unwrap().node(m).unwrap().strokes.is_empty()); + // The square lost the band; the path (no fill) became an outline of what was left of its + // stroke: the two ends outside the square. + assert!((area(&s, r) - 9000.0).abs() < 1.0, "{}", area(&s, r)); + assert_eq!(out["removed"], json!([l])); + let o = id_of(&out["outlines"][0]).unwrap(); + assert!((area(&s, o) - 400.0).abs() < 1.0, "{}", area(&s, o)); + assert_eq!(fill_hex(&s, o), "000000"); + assert!(matches!(s.doc().unwrap().node(o).unwrap().kind, NodeKind::Vector { .. })); + // The selection keeps working: the square, the outline and the new shape. + let sel = s.selection(); + assert!(sel.contains(&r) && sel.contains(&o) && sel.contains(&m), "{sel:?}"); + // One undo step. + s.undo().unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.contains(l) && !doc.contains(m) && !doc.contains(o)); + assert!((area(&s, r) - 10000.0).abs() < 1.0); +} + +#[test] +fn removing_regions_spares_strokes_that_arent_involved() { + let mut s = Session::new(); + let (r, l) = square_and_path(&mut s); + let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 20]]})); + // The square keeps the band and the part below; the path isn't touched. + assert!((area(&s, r) - 5500.0).abs() < 1.0, "{}", area(&s, r)); + assert_eq!(out["outlines"], json!([])); + let n = s.doc().unwrap().node(l).unwrap(); + assert!(!n.strokes.is_empty() && matches!(n.kind, NodeKind::Vector { .. })); + // Removing an end of the path leaves the rest of its stroke as an outline. + let out = ex(&mut s, "shapeBuilder.remove", json!({"ids": [r, l], "points": [[-10, 50]]})); + assert_eq!(out["removed"], json!([l])); + let o = id_of(&out["outlines"][0]).unwrap(); + assert!((area(&s, o) - (1000.0 + 200.0)).abs() < 1.0, "the band and the other end: {}", area(&s, o)); +} + +#[test] +fn a_stroked_shape_gives_its_ring_and_keeps_its_look() { + let mut s = Session::new(); + // An inside stroke 10 wide: a ring inside the square's edge. + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}}))); + ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#0000ff"], "strokeWeight": 10, "strokeAlign": "inside"}})); + s.set_selection(vec![r]); + let out = ex(&mut s, "shapeBuilder.regions", json!({})); + assert_eq!(out["regions"].as_array().unwrap().len(), 2, "the middle and the ring: {out}"); + // Removing the middle leaves the fill under the ring and the ring as a blue outline above it. + let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 50]]})); + assert_eq!(out["changed"], json!([r])); + assert!((area(&s, r) - 3600.0).abs() < 1.0, "{}", area(&s, r)); + assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty()); + let o = id_of(&out["outlines"][0]).unwrap(); + assert!((area(&s, o) - 3600.0).abs() < 1.0); + assert_eq!(fill_hex(&s, o), "0000FF"); + let doc = s.doc().unwrap(); + let parent = doc.node(r).unwrap().parent.unwrap(); + let kids = &doc.node(parent).unwrap().children; + assert!(kids.iter().position(|k| *k == o) > kids.iter().position(|k| *k == r), "the outline sits above the shape"); +} + +#[test] +fn stroke_regions_survive_junk() { + for j in [ + json!({"points": [[f64::MAX, 0]]}), + json!({"regions": [-1, 1e20, "x"]}), + json!({"ids": [123_456], "points": [[50, 50]]}), + json!({"points": "x"}), + ] { + for cmd in ["shapeBuilder.regions", "shapeBuilder.merge", "shapeBuilder.remove"] { + let mut s = Session::new(); + let _ = square_and_path(&mut s); + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} diff --git a/crates/engine/src/shapebuilder_tests.rs b/crates/engine/src/shapebuilder_tests.rs index 305d756..3b0cc7e 100644 --- a/crates/engine/src/shapebuilder_tests.rs +++ b/crates/engine/src/shapebuilder_tests.rs @@ -225,7 +225,7 @@ fn groups_rotated_and_ineligible_layers() { let line = id(&ex(&mut s, "shape.line", json!({"x": 0, "y": 5, "width": 20}))); let frame = id(&ex(&mut s, "frame.create", json!({"x": 100, "y": 100, "width": 50, "height": 50}))); let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": [g, line, frame]})); - assert_eq!(r["sources"], json!([a, b]), "groups give their shapes; lines and frames don't take part"); + assert_eq!(r["sources"], json!([a, b, line]), "groups give their shapes; lines take part through their strokes; frames don't"); // The merged shape lands in the group, which stays selected. s.set_selection(vec![g]); let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"points": [[7, 7]]}))); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 8b3e9a5..8da52bc 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1016,9 +1016,10 @@ impl Session { let p = ev.p(); let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); // Absolute children of an auto layout frame stay in it, and so do the layers of - // an instance (outside its slots: they only move); the rest may change parent. + // an instance (outside its slots: they only move) and the operands of a boolean + // group (its result follows them); the rest may change parent. let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) - && !home.is_some_and(|h| crate::components::structure_locked(doc, h)); + && !home.is_some_and(|h| crate::components::structure_locked(doc, h) || crate::booleans::is_group(doc, h)); let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); if let (true, Some(mode)) = (flow, mode) { @@ -1410,9 +1411,11 @@ impl Session { let before = ds.doc.clone(); let d = ds.doc_mut(); for m in &moving { - // Instance layers stay put (outside slots): they only move. + // Instance layers (outside slots) and boolean operands stay put: they only move. let from = d.node(*m).and_then(|n| n.parent); - if from.is_some_and(|f| crate::components::structure_locked(d, f)) || crate::components::structure_locked(d, target) { + if from.is_some_and(|f| crate::components::structure_locked(d, f) || crate::booleans::is_group(d, f)) + || crate::components::structure_locked(d, target) + { continue; } // A layer can't go into itself; it stays where it is. @@ -1506,9 +1509,8 @@ impl Session { if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { return crate::vectoredit::begin(self, target); } - // Groups/frames: select one level deeper (the child under the pointer). - let deeper = - select_target(&doc, leaf, &[select_target(&doc, leaf, &sel, false)].into_iter().chain(sel.iter().copied()).collect::>(), false); + // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). + let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); if deeper != target || !sel.contains(&deeper) { self.set_selection(vec![deeper]); } diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index 1be3c19..56155ba 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -16,6 +16,8 @@ pub mod transform; mod transform_tests; mod width; #[cfg(test)] +mod width_sides_tests; +#[cfg(test)] mod width_tests; use figcraft_doc::*; diff --git a/crates/engine/src/vectoredit/width.rs b/crates/engine/src/vectoredit/width.rs index 602547f..ba51b04 100644 --- a/crates/engine/src/vectoredit/width.rs +++ b/crates/engine/src/vectoredit/width.rs @@ -3,6 +3,10 @@ //! dragging a point slides it along the path, dragging one of its handles changes its width; //! Delete removes the selected point. Every gesture is one undo step. //! +//! **One-sided widths:** with Alt held, dragging a handle (or the path) changes the width on that +//! side of the path only; a plain handle drag makes both sides as wide again. The sides are left +//! and right looking along the path (see [`WidthPoint`]). +//! //! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are //! fractions of each subpath's length). @@ -29,10 +33,11 @@ const TOL_PX: f64 = 6.0; pub enum WHit { /// A width point on the path (drag slides it along). Point { index: usize }, - /// One of a width point's handles (drag sets its width). - Handle { index: usize }, - /// The path between points (drag adds a point there); `t` is the position along it. - Path { t: f64 }, + /// One of a width point's handles (drag sets its width; with Alt, that side's only). + Handle { index: usize, side: WidthSide }, + /// The path between points (drag adds a point there); `t` is the position along it and `side` + /// the side of the path the pointer is on. + Path { t: f64, side: WidthSide }, } /// A width point as drawn: on the path and its two handles, in page coordinates, per subpath. @@ -41,9 +46,12 @@ pub enum WHit { pub struct WidthHandle { pub index: usize, pub at: [f64; 2], + /// The handles on the right and the left of the path. pub sides: [[f64; 2]; 2], /// The stroke's full width there (local units). pub width: f64, + /// Whether the two sides differ. + pub one_sided: bool, } /// An in-progress Variable width gesture. @@ -52,7 +60,7 @@ pub(crate) enum WidthGesture { /// Slide point `index` along the path. Slide { index: usize, moved: bool }, /// Set the width of point `index`; `added` = the gesture added it at `t`. - Size { index: usize, added: Option, moved: bool }, + Size { index: usize, added: Option, moved: bool, side: WidthSide }, } /// The edited node's stroke path flattened (local coordinates) with its weight and profile. @@ -68,24 +76,31 @@ fn stroked(doc: &Document, id: NodeId) -> Option { Some(Stroked { lines: polylines(&path)?, weight: n.stroke.weight, profile: n.stroke.profile().cloned() }) } -/// Width handles for `(index, t, scale)` on every subpath, as drawn at `zoom` (page coordinates). -fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator + Clone, zoom: f64) -> Vec { +/// Width handles for `(index, t, (left, right) side scales)` on every subpath, as drawn at `zoom` +/// (page coordinates). +fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator + Clone, zoom: f64) -> Vec { let w = doc.world_transform(id); let min = HANDLE_MIN_PX / zoom.max(1e-6); let mut out = Vec::new(); for line in &st.lines { - for (index, t, scale) in pts.clone() { + for (index, t, (left, right)) in pts.clone() { let Some((at, n)) = line.at(t) else { continue }; - let half = 0.5 * st.weight * scale; + let (hl, hr) = (0.5 * st.weight * left, 0.5 * st.weight * right); let c = w * at; let dir = w * (at + n) - c; let dir = if dir.hypot() > 1e-12 { dir / dir.hypot() } else { Vec2::new(0.0, 1.0) }; - let reach = ((w * (at + n * half)) - c).hypot().max(min); - let side = |s: f64| { + let side = |half: f64, s: f64| { + let reach = ((w * (at + n * half)) - c).hypot().max(min); let q = c + dir * (reach * s); [q.x, q.y] }; - out.push(WidthHandle { index, at: [c.x, c.y], sides: [side(1.0), side(-1.0)], width: 2.0 * half }); + out.push(WidthHandle { + index, + at: [c.x, c.y], + sides: [side(hr, 1.0), side(hl, -1.0)], + width: hl + hr, + one_sided: (left - right).abs() > 1e-9, + }); } } out @@ -95,14 +110,14 @@ fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator Vec { let Some(st) = stroked(doc, ve.id) else { return vec![] }; let Some(profile) = st.profile.clone() else { return vec![] }; - handles(doc, ve.id, &st, profile.points.iter().enumerate().map(|(i, p)| (i, p.t, p.scale)), zoom) + handles(doc, ve.id, &st, profile.points.iter().enumerate().map(|(i, p)| (i, p.t, p.sides())), zoom) } /// Where a width point added at `t` would go, with the width there (the hover preview). pub fn width_preview(doc: &Document, ve: &super::VectorEdit, t: f64, zoom: f64) -> Vec { let Some(st) = stroked(doc, ve.id) else { return vec![] }; - let scale = st.profile.as_ref().map_or(1.0, |p| p.scale_at(t)); - handles(doc, ve.id, &st, std::iter::once((usize::MAX, t, scale)), zoom) + let sides = st.profile.as_ref().map_or((1.0, 1.0), |p| p.sides_at(t)); + handles(doc, ve.id, &st, std::iter::once((usize::MAX, t, sides)), zoom) } /// Hit test the Variable width tool at page point `p`: handles, then points, then the path. @@ -114,7 +129,10 @@ pub fn width_hit(doc: &Document, ve: &super::VectorEdit, p: Point, zoom: f64) -> handles.iter().map(|h| (h.index, pick(h))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) }; if let Some(index) = best(&|h| near(h.sides[0]).min(near(h.sides[1]))) { - return Some(WHit::Handle { index }); + // The nearer of its handles (across subpaths). + let d = |k: usize| handles.iter().filter(|h| h.index == index).map(|h| near(h.sides[k])).fold(f64::INFINITY, f64::min); + let side = if d(0) <= d(1) { WidthSide::Right } else { WidthSide::Left }; + return Some(WHit::Handle { index, side }); } if let Some(index) = best(&|h| near(h.at)) { return Some(WHit::Point { index }); @@ -122,15 +140,21 @@ pub fn width_hit(doc: &Document, ve: &super::VectorEdit, p: Point, zoom: f64) -> // Anywhere on the painted stroke counts, or near its centre line when it's thin. let st = stroked(doc, ve.id)?; let w = doc.world_transform(ve.id); - let (sub, t, _) = nearest(&st.lines, w.inverse() * p)?; - let (q, _) = st.lines.get(sub)?.at(t)?; - let half = 0.5 * st.weight * st.profile.as_ref().map_or(1.0, |pr| pr.scale_at(t)) * w.determinant().abs().sqrt(); - ((w * q - p).hypot() <= tol.max(half)).then_some(WHit::Path { t }) + let lp = w.inverse() * p; + let (sub, t, _) = nearest(&st.lines, lp)?; + let (q, n) = st.lines.get(sub)?.at(t)?; + let side = if (lp - q).dot(n) >= 0.0 { WidthSide::Right } else { WidthSide::Left }; + let scale = st.profile.as_ref().map_or(1.0, |pr| { + let (l, r) = pr.sides_at(t); + if side == WidthSide::Right { r } else { l } + }); + let half = 0.5 * st.weight * scale * w.determinant().abs().sqrt(); + ((w * q - p).hypot() <= tol.max(half)).then_some(WHit::Path { t, side }) } /// Write node `id`'s width profile (through `node.set`'s key, so instances record the override). fn put_profile(doc: &mut Document, id: NodeId, profile: Option<&WidthProfile>) -> Result<()> { - let v = profile.filter(|p| !p.points.is_empty()).map_or(Value::Null, |p| json!(p.points.iter().map(|q| [q.t, q.scale]).collect::>())); + let v = crate::cmd::props::width_points_json(profile); crate::cmd::props::apply("vector.edit.widthPoint", doc, id, &serde_json::Map::from_iter([("widthProfile".to_string(), v)])) } @@ -139,10 +163,25 @@ fn profile_of(doc: &Document, id: NodeId) -> WidthProfile { doc.node(id).and_then(|n| n.stroke.profile()).cloned().unwrap_or_else(WidthProfile::uniform) } +/// Local point `lp` seen from point `t` of the path (the nearest subpath's point there): how far +/// out it is along the normal (negative to the left) and the side it is on. +fn pointer_at(st: &Stroked, t: f64, lp: Point) -> Option<(f64, WidthSide)> { + let (q, n) = st.lines.iter().filter_map(|l| l.at(t)).min_by(|a, b| (a.0 - lp).hypot().total_cmp(&(b.0 - lp).hypot()))?; + let along = (lp - q).dot(n); + Some((along, if along >= 0.0 { WidthSide::Right } else { WidthSide::Left })) +} + /// The width scale for a handle dragged to local point `lp` from point `t` (the nearest -/// subpath's point there counts). -fn scale_at_pointer(st: &Stroked, t: f64, lp: Point) -> Option { - let d = st.lines.iter().filter_map(|l| l.at(t)).map(|(q, _)| (q - lp).hypot()).fold(f64::INFINITY, f64::min); +/// subpath's point there counts). With `side`, the scale of that side only: how far the pointer +/// is out to that side (0 past the centre line). +fn scale_at_pointer(st: &Stroked, t: f64, lp: Point, side: Option) -> Option { + let d = match side { + None => st.lines.iter().filter_map(|l| l.at(t)).map(|(q, _)| (q - lp).hypot()).fold(f64::INFINITY, f64::min), + Some(s) => { + let (along, _) = pointer_at(st, t, lp)?; + (along * if s == WidthSide::Right { 1.0 } else { -1.0 }).max(0.0) + } + }; (d.is_finite() && st.weight > 0.0).then(|| 2.0 * d / st.weight) } @@ -151,7 +190,13 @@ fn width_point(s: &mut Session, p: &Value) -> Result { let ve = editing(s)?; let doc = s.doc()?; let weight = doc.get(ve.id)?.stroke.weight; - let scale = num(p, "scale").or_else(|| num(p, "width").filter(|_| weight > 0.0).map(|w| w / weight)); + let side = match string(p, "side") { + Some(v) => Some(WidthSide::from_id(&v).ok_or_else(|| bad(cmd, "side: left|right"))?), + None => None, + }; + // A side's width runs from the centre line to that edge: half of what both sides make. + let per_px = if side.is_some() { 2.0 } else { 1.0 }; + let scale = num(p, "scale").or_else(|| num(p, "width").filter(|_| weight > 0.0).map(|w| per_px * w / weight)); let action = string(p, "action").unwrap_or_else(|| if get(p, "index").is_some() { "set".into() } else { "add".into() }); let mut prof = profile_of(doc, ve.id); if action != "add" && doc.get(ve.id)?.stroke.profile().is_none() { @@ -160,12 +205,22 @@ fn width_point(s: &mut Session, p: &Value) -> Result { let selected = match action.as_str() { "add" => { let t = req_num(cmd, p, "t")?; - let sc = scale.unwrap_or_else(|| prof.scale_at(t)); - Some(prof.insert(t, sc)) + let i = match (scale, side) { + (Some(sc), None) => prof.insert(t, sc), + _ => prof.insert_here(t), + }; + if let (Some(sc), Some(sd)) = (scale, side) { + prof.set_side(i, sd, sc); + } + Some(i) } "set" | "move" => { let i = req_index(cmd, p, "index")?; - if !prof.set_point(i, num(p, "t"), scale) { + let ok = match side { + Some(sd) => prof.set_point(i, num(p, "t"), None) && scale.is_none_or(|sc| prof.set_side(i, sd, sc)), + None => prof.set_point(i, num(p, "t"), scale), + }; + if !ok { return Err(bad(cmd, "no such width point")); } Some(i) @@ -189,7 +244,7 @@ pub(crate) fn specs() -> Vec { vec![spec!( "vector.edit.widthPoint", "Width point", - "{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`", + "{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)", true, width_point )] @@ -211,17 +266,17 @@ impl Session { return Ok(()); } let g = match h { - Some(WHit::Handle { index }) => WidthGesture::Size { index, added: None, moved: false }, + Some(WHit::Handle { index, side }) => WidthGesture::Size { index, added: None, moved: false, side }, Some(WHit::Point { index }) => WidthGesture::Slide { index, moved: false }, - Some(WHit::Path { t }) => { + Some(WHit::Path { t, side }) => { self.begin_interaction("Variable width")?; let mut index = 0; self.preview(|doc, _| { let mut prof = profile_of(doc, id); - index = prof.insert(t, prof.scale_at(t)); + index = prof.insert_here(t); put_profile(doc, id, Some(&prof)) })?; - WidthGesture::Size { index, added: Some(t), moved: false } + WidthGesture::Size { index, added: Some(t), moved: false, side } } None => { return set_state(self, |e| e.width_point = None); @@ -249,18 +304,31 @@ impl Session { })?; WidthGesture::Slide { index, moved: true } } - WidthGesture::Size { index, added, .. } => { + WidthGesture::Size { index, added, side, .. } => { + // A handle keeps its side; a point being added widens the side the pointer is on. + let mut side = side; self.preview(|doc, _| { let Some(st) = stroked(doc, id) else { return Ok(()) }; let mut prof = profile_of(doc, id); if let Some(t) = added { - prof.insert(t, prof.scale_at(t)); + prof.insert_here(t); } let Some(t) = prof.points.get(index).map(|q| q.t) else { return Ok(()) }; - prof.set_point(index, None, scale_at_pointer(&st, t, lp)); + if added.is_some() + && let Some((_, s)) = pointer_at(&st, t, lp) + { + side = s; + } + // Alt: that side only; otherwise both sides as wide. + let one_side = ev.alt.then_some(side); + let scale = scale_at_pointer(&st, t, lp, one_side); + match (one_side, scale) { + (Some(s), Some(sc)) => prof.set_side(index, s, sc), + _ => prof.set_point(index, None, scale), + }; put_profile(doc, id, Some(&prof)) })?; - WidthGesture::Size { index, added, moved: true } + WidthGesture::Size { index, added, moved: true, side } } }; self.tool.gesture = Gesture::Vector(super::VGesture::Width(g)); diff --git a/crates/engine/src/vectoredit/width_sides_tests.rs b/crates/engine/src/vectoredit/width_sides_tests.rs new file mode 100644 index 0000000..bf2dbeb --- /dev/null +++ b/crates/engine/src/vectoredit/width_sides_tests.rs @@ -0,0 +1,167 @@ +//! One-sided widths with the Variable width tool: Alt-dragging a handle or the path changes one +//! side, a plain drag evens them out, the width point command's `side`, `widthProfile` points with +//! two sides, and what outline stroke and export make of them. + +use serde_json::{Value, json}; + +use super::*; +use crate::EngineError; +use crate::tools::{PointerEvent, PointerKind}; + +fn ex(s: &mut Session, c: &str, p: Value) -> Value { + s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}")) +} + +fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent { + PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) } +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: bool) { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt)).unwrap(); + s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, alt)).unwrap(); + s.pointer(&ev(PointerKind::Move, to.0, to.1, alt)).unwrap(); + s.pointer(&ev(PointerKind::Up, to.0, to.1, alt)).unwrap(); +} + +/// An open path from (0, 50) to (100, 50) (its right side is below it) with a 10 wide centred +/// stroke, in vector edit mode with the Variable width tool. +fn path(s: &mut Session) -> NodeId { + let r = ex(s, "vector.create", json!({"points": [[0, 50], [100, 50]], "closed": false})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(s, "node.set", json!({"id": id, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}})); + ex(s, "vector.edit.begin", json!({"id": id})); + ex(s, "vector.edit.tool", json!({"tool": "width"})); + id +} + +/// The side scales of width point `i`, rounded. +fn sides(s: &Session, id: NodeId, i: usize) -> (f64, f64) { + let p = s.doc().unwrap().node(id).unwrap().stroke.profile().unwrap().points[i]; + let (l, r) = p.sides(); + ((l * 1e6).round() / 1e6, (r * 1e6).round() / 1e6) +} + +fn ve(s: &Session) -> VectorEdit { + s.ds().unwrap().vector_edit.clone().unwrap() +} + +#[test] +fn alt_dragging_a_handle_changes_that_side_only() { + let mut s = Session::new(); + let id = path(&mut s); + // A point 40 wide in the middle: 20 either side. + ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 40})); + assert_eq!(sides(&s, id, 1), (4.0, 4.0)); + s.pointer(&ev(PointerKind::Hover, 50.0, 70.0, true)).unwrap(); + assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1, side: WidthSide::Right })); + // Alt-drag the right handle in to 10 from the centre: the left keeps its 20. + drag(&mut s, (50.0, 70.0), (50.0, 60.0), true); + assert_eq!(sides(&s, id, 1), (4.0, 2.0)); + let hs = width_handles(s.doc().unwrap(), &ve(&s), 1.0); + let h = hs.iter().find(|h| h.index == 1).unwrap(); + assert!(h.one_sided && (h.sides[0][1] - 60.0).abs() < 1e-6 && (h.sides[1][1] - 30.0).abs() < 1e-6, "{h:?}"); + assert!((h.width - 30.0).abs() < 1e-6); + // The painted stroke follows: 20 above, 10 below in the middle. + let b = s.doc().unwrap().world_render_bounds(id).unwrap(); + assert!(b.y0 <= 30.0 + 1e-6, "{b:?}"); + // One undo step; a plain drag makes both sides as wide again. + s.undo().unwrap(); + assert_eq!(sides(&s, id, 1), (4.0, 4.0)); + s.redo().unwrap(); + drag(&mut s, (50.0, 30.0), (50.0, 35.0), false); + assert_eq!(sides(&s, id, 1), (3.0, 3.0)); + // Alt past the centre line leaves that side at nothing. + drag(&mut s, (50.0, 35.0), (50.0, 55.0), true); + assert_eq!(sides(&s, id, 1), (0.0, 3.0)); +} + +#[test] +fn alt_dragging_the_path_adds_a_point_widening_the_side_the_pointer_goes() { + let mut s = Session::new(); + let id = path(&mut s); + // From the path at a quarter of the way, up (the left side) to 15 out. + drag(&mut s, (25.0, 50.0), (25.0, 35.0), true); + let pts = s.doc().unwrap().node(id).unwrap().stroke.profile().unwrap().points.clone(); + assert_eq!(pts.len(), 3); + assert_eq!(sides(&s, id, 1), (3.0, 1.0), "{pts:?}"); + s.undo().unwrap(); + assert!(s.doc().unwrap().node(id).unwrap().stroke.profile().is_none(), "adding and sizing is one step"); +} + +#[test] +fn the_width_point_command_sets_one_side() { + let mut s = Session::new(); + let id = path(&mut s); + ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 20})); + // `width` with a side: the distance from the centre line to that edge. + ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "side": "left", "width": 15})); + assert_eq!(sides(&s, id, 1), (3.0, 2.0)); + ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "side": "right", "scale": 0.5})); + assert_eq!(sides(&s, id, 1), (3.0, 0.5)); + // Adding with a side widens that side of what is there. + let r = ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.9, "side": "right", "width": 25})); + let i = r["index"].as_u64().unwrap() as usize; + let (l, rr) = sides(&s, id, i); + assert!((rr - 5.0).abs() < 1e-6 && l > 0.9 && l < 3.0, "{l} {rr}"); + // State and inspect report two-sided points as [t, left, right]. + let n = ex(&mut s, "node.get", json!({"id": id})); + let p1: Vec = n["widthProfile"]["points"][1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert!(p1.len() == 3 && [0.5, 3.0, 0.5].iter().zip(&p1).all(|(a, b)| (a - b).abs() < 1e-9), "{p1:?}"); + // A width without a side evens the point out again. + ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "width": 10})); + assert_eq!(sides(&s, id, 1), (1.0, 1.0)); + assert!(s.execute("vector.edit.widthPoint", &json!({"index": 1, "side": "up", "width": 3})).is_err()); +} + +#[test] +fn profiles_with_two_sides_round_trip_and_outline() { + let mut s = Session::new(); + let id = path(&mut s); + ex(&mut s, "vector.edit.end", json!({})); + // [t, left, right] and {t, left, right}: all of the width below the path. + ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": [[0, 0, 2], {"t": 1, "left": 0, "right": 2}]}})); + assert_eq!(sides(&s, id, 0), (0.0, 2.0)); + assert_eq!(sides(&s, id, 1), (0.0, 2.0)); + let back = ex(&mut s, "node.get", json!({"id": id}))["widthProfile"].clone(); + ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": null}})); + ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": back}})); + assert_eq!(sides(&s, id, 1), (0.0, 2.0)); + // Exports render it; outline stroke makes a vector 10 tall, all below the path. + let doc = s.doc().unwrap().clone(); + for f in [ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Png] { + assert!(!crate::cmd::file::export_bytes(&doc, &[id], f, 1.0).unwrap().is_empty()); + } + let r = ex(&mut s, "vector.outlineStroke", json!({"ids": [id]})); + let v = crate::params::id_of(&r["ids"][0]).unwrap(); + let b = s.doc().unwrap().world_bounds(v).unwrap(); + assert!((b.y0 - 50.0).abs() < 0.05 && (b.y1 - 60.0).abs() < 0.05 && (b.width() - 100.0).abs() < 0.05, "{b:?}"); +} + +#[test] +fn one_sided_widths_survive_junk() { + let junk = [ + json!({"index": 1, "side": 7, "width": 1}), + json!({"index": 1, "side": "left", "width": "NaN"}), + json!({"index": 1, "side": "right", "scale": 1e308}), + json!({"t": 0.5, "side": "left", "scale": -1e9}), + json!({"index": 99, "side": "left", "width": 1}), + ]; + for j in &junk { + let mut s = Session::new(); + let id = path(&mut s); + ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 20})); + if let Err(EngineError::Internal { cmd, msg }) = s.execute("vector.edit.widthPoint", j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + let p = s.doc().unwrap().node(id).unwrap().stroke.profile().cloned().unwrap(); + assert!(p.points.iter().all(|q| q.scale.is_finite() && q.bias.is_finite() && (-1.0..=1.0).contains(&q.bias)), "{j}: {p:?}"); + } + for v in [json!([[0, 1, "x"]]), json!([{"t": 0, "left": 1}]), json!([[0, -5, 1e308], [1, f64::NAN, 2]])] { + let mut s = Session::new(); + let id = path(&mut s); + let _ = s.execute("node.set", &json!({"id": id, "props": {"widthProfile": v}})); + if let Some(p) = s.doc().unwrap().node(id).unwrap().stroke.profile() { + assert!(p.points.iter().all(|q| q.scale.is_finite() && q.scale >= 0.0 && q.bias.is_finite())); + } + } +} diff --git a/crates/engine/src/vectoredit/width_tests.rs b/crates/engine/src/vectoredit/width_tests.rs index a9e5e10..54b4b0e 100644 --- a/crates/engine/src/vectoredit/width_tests.rs +++ b/crates/engine/src/vectoredit/width_tests.rs @@ -79,7 +79,7 @@ fn width_tool_drags_add_slide_and_size_points_in_one_step_each() { assert_eq!(ve(&s).tool, VTool::Width); // Hovering the stroke offers to add a point there. s.pointer(&ev(PointerKind::Hover, 50.0, 52.0)).unwrap(); - assert!(matches!(s.tool.overlay.width_hover, Some(WHit::Path { t }) if (t - 0.5).abs() < 1e-9)); + assert!(matches!(s.tool.overlay.width_hover, Some(WHit::Path { t, side: WidthSide::Right }) if (t - 0.5).abs() < 1e-9)); // Dragging from the path adds a point and sets its width: 20 from the centre = 40 wide. drag(&mut s, (50.0, 50.0), (50.0, 70.0)); assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 4.0), (1.0, 1.0)]); @@ -94,7 +94,7 @@ fn width_tool_drags_add_slide_and_size_points_in_one_step_each() { let h = hs.iter().find(|h| h.index == 1).unwrap(); assert!((h.sides[0][1] - 70.0).abs() < 1e-6 || (h.sides[1][1] - 70.0).abs() < 1e-6, "{h:?}"); s.pointer(&ev(PointerKind::Hover, 50.0, 30.0)).unwrap(); - assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1 })); + assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1, side: WidthSide::Left })); drag(&mut s, (50.0, 30.0), (50.0, 45.0)); assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 1.0), (1.0, 1.0)]); // Dragging the point slides it along the path. diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index d417b2c..6df0685 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -6,6 +6,10 @@ //! dashes, caps, joins, arrowheads and variable width profiles ([`width`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(test)] +mod ops_tests; +#[cfg(test)] +mod sides_tests; pub mod width; #[cfg(test)] mod width_tests; @@ -79,44 +83,37 @@ pub fn simplify(path: &BezPath) -> Result { binary(path, &BezPath::new(), linesweeper::BinaryOp::Union) } +/// Combine `acc` (the operands so far) with the next operand `p` by `op`. +pub fn boolean_step(acc: &BezPath, p: &BezPath, op: BoolOp) -> Result { + let op = match op { + BoolOp::Union => linesweeper::BinaryOp::Union, + BoolOp::Intersect => linesweeper::BinaryOp::Intersection, + BoolOp::Exclude => linesweeper::BinaryOp::Xor, + BoolOp::Subtract => linesweeper::BinaryOp::Difference, + }; + binary(acc, p, op) +} + /// Apply a boolean operation to `paths` in order (bottom to top, like a boolean group's children): /// Subtract removes every later shape from the first one. pub fn boolean(paths: &[BezPath], op: BoolOp) -> Result { - let mut it = paths.iter(); - let Some(first) = it.next() else { return Ok(BezPath::new()) }; - let mut acc = first.clone(); - match op { - BoolOp::Union => { - for p in it { - acc = binary(&acc, p, linesweeper::BinaryOp::Union)?; - } - } - BoolOp::Intersect => { - for p in it { - acc = binary(&acc, p, linesweeper::BinaryOp::Intersection)?; - } - } - BoolOp::Exclude => { - for p in it { - acc = binary(&acc, p, linesweeper::BinaryOp::Xor)?; - } - } - BoolOp::Subtract => { - let rest: Vec = it.cloned().collect(); - if rest.is_empty() { - return Ok(acc); - } - let mut cut = BezPath::new(); - for p in &rest { - cut = binary(&cut, p, linesweeper::BinaryOp::Union)?; - } - acc = binary(&acc, &cut, linesweeper::BinaryOp::Difference)?; - } + boolean_ops(paths.iter().map(|p| (p, op))) +} + +/// Fold `operands` bottom to top, each combined with the result of those below it by its own +/// operation (the bottom operand's operation is unused): what a boolean group whose operands carry +/// their own operations paints. A lone operand comes out simplified. +pub fn boolean_ops<'a>(operands: impl IntoIterator) -> Result { + let mut it = operands.into_iter(); + let Some((first, _)) = it.next() else { return Ok(BezPath::new()) }; + let mut acc: Option = None; + for (p, op) in it { + acc = Some(boolean_step(acc.as_ref().unwrap_or(first), p, op)?); } - if paths.len() == 1 { - return simplify(&acc); + match acc { + Some(a) => Ok(a), + None => simplify(first), } - Ok(acc) } /// Per-input winding numbers for [`faces`] (index = input path; trailing zeros trimmed so equal diff --git a/crates/pathops/src/ops_tests.rs b/crates/pathops/src/ops_tests.rs new file mode 100644 index 0000000..f3b474f --- /dev/null +++ b/crates/pathops/src/ops_tests.rs @@ -0,0 +1,83 @@ +//! Boolean groups whose operands carry their own operations: the fold bottom to top, its +//! agreement with a uniform operation, and single steps. + +use figcraft_doc::BoolOp; +use figcraft_geom::Rect; +use figcraft_geom::kurbo::{BezPath, Shape}; + +use crate::{boolean, boolean_ops, boolean_step}; + +fn sq(x: f64, y: f64, s: f64) -> BezPath { + Rect::new(x, y, x + s, y + s).to_path(0.1) +} + +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +#[test] +fn each_operand_combines_with_the_result_below_it() { + let (a, b, c) = (sq(0.0, 0.0, 10.0), sq(5.0, 0.0, 10.0), sq(0.0, 0.0, 15.0)); + // (A ∪ B) ∩ C: the union spans x 0..15, y 0..10 (150), all inside C. + let r = boolean_ops([(&a, BoolOp::Union), (&b, BoolOp::Union), (&c, BoolOp::Intersect)]).unwrap(); + assert!((area(&r) - 150.0).abs() < 0.5, "{}", area(&r)); + // (A ∪ B) − a 5 × 5 square in the middle. + let hole = sq(5.0, 2.5, 5.0); + let r = boolean_ops([(&a, BoolOp::Union), (&b, BoolOp::Union), (&hole, BoolOp::Subtract)]).unwrap(); + assert!((area_eo(&r) - 125.0).abs() < 0.5, "{}", area_eo(&r)); + // The bottom operand's own operation is unused. + let r2 = boolean_ops([(&a, BoolOp::Exclude), (&b, BoolOp::Union), (&hole, BoolOp::Subtract)]).unwrap(); + assert!((area_eo(&r2) - 125.0).abs() < 0.5); +} + +/// Even-odd area: contours nested in another count negative (holes). +fn area_eo(p: &BezPath) -> f64 { + let mut total = 0.0; + let subs: Vec = split(p); + for (i, s) in subs.iter().enumerate() { + let inside = subs.iter().enumerate().filter(|(j, o)| *j != i && o.bounding_box().contains_rect(s.bounding_box())).count(); + total += if inside % 2 == 0 { area(s) } else { -area(s) }; + } + total +} + +fn split(p: &BezPath) -> Vec { + use figcraft_geom::kurbo::PathEl; + let mut out: Vec = Vec::new(); + for el in p.elements() { + if matches!(el, PathEl::MoveTo(_)) { + out.push(BezPath::new()); + } + if let Some(last) = out.last_mut() { + last.push(*el); + } + } + out +} + +#[test] +fn a_uniform_fold_matches_the_group_operation() { + let (a, b, c) = (sq(0.0, 0.0, 10.0), sq(5.0, 5.0, 10.0), sq(8.0, 0.0, 4.0)); + for op in [BoolOp::Union, BoolOp::Subtract, BoolOp::Intersect, BoolOp::Exclude] { + let all = boolean(&[a.clone(), b.clone(), c.clone()], op).unwrap(); + let folded = boolean_ops([(&a, op), (&b, op), (&c, op)]).unwrap(); + assert!((area_eo(&all) - area_eo(&folded)).abs() < 1e-6, "{op:?}"); + } + // Subtract removes every later operand from the first. + let s = boolean(&[a.clone(), b.clone(), c.clone()], BoolOp::Subtract).unwrap(); + assert!((area_eo(&s) - (100.0 - 25.0 - 8.0)).abs() < 0.5, "{}", area_eo(&s)); +} + +#[test] +fn single_steps_and_edge_cases() { + let (a, b) = (sq(0.0, 0.0, 10.0), sq(5.0, 5.0, 10.0)); + assert!((area(&boolean_step(&a, &b, BoolOp::Intersect).unwrap()) - 25.0).abs() < 0.5); + assert!((area(&boolean_step(&a, &b, BoolOp::Subtract).unwrap()) - 75.0).abs() < 0.5); + // Nothing, and a lone operand (simplified). + assert!(boolean_ops(std::iter::empty()).unwrap().elements().is_empty()); + assert!((area(&boolean_ops([(&a, BoolOp::Subtract)]).unwrap()) - 100.0).abs() < 1e-6); + let mut bad = BezPath::new(); + bad.move_to((f64::NAN, 0.0)); + bad.line_to((1.0, 1.0)); + assert!(boolean_ops([(&a, BoolOp::Union), (&bad, BoolOp::Intersect)]).is_err()); +} diff --git a/crates/pathops/src/sides_tests.rs b/crates/pathops/src/sides_tests.rs new file mode 100644 index 0000000..f0c9622 --- /dev/null +++ b/crates/pathops/src/sides_tests.rs @@ -0,0 +1,70 @@ +//! One-sided variable widths in stroke outlines: each side offset by its own width, lopsided +//! caps around the middle of the end, dashes and closed paths. + +use figcraft_doc::{StrokeAlign, StrokeCap, StrokeProps, WidthPoint, WidthProfile}; +use figcraft_geom::kurbo::{BezPath, Shape}; + +use crate::stroke_outline; + +/// A horizontal line from (0, 0) to (100, 0): looking along it, its right side is +y (y down). +fn line() -> BezPath { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((100.0, 0.0)); + p +} + +fn props(points: Vec) -> StrokeProps { + StrokeProps { weight: 10.0, align: StrokeAlign::Center, width_profile: Some(WidthProfile { points }.sanitized()), ..Default::default() } +} + +#[test] +fn each_side_is_offset_by_its_own_width() { + // All on the right: 0 to the left, 10 to the right (side scale 2 × weight 10 / 2). + let p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]); + let b = stroke_outline(&line(), &p, false).bounding_box(); + assert!(b.y0.abs() < 1e-6 && (b.y1 - 10.0).abs() < 1e-6 && b.x0.abs() < 1e-6 && (b.x1 - 100.0).abs() < 1e-6, "{b:?}"); + assert!((stroke_outline(&line(), &p, false).area().abs() - 1000.0).abs() < 1.0); + // Lopsided: 2 on the left, 8 on the right. + let p = props(vec![WidthPoint::from_sides(0.5, 0.4, 1.6)]); + let b = stroke_outline(&line(), &p, false).bounding_box(); + assert!((b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 8.0).abs() < 1e-6, "{b:?}"); + // The right side swells in the middle while the left stays straight. + let p = props(vec![WidthPoint::new(0.0, 1.0), WidthPoint::from_sides(0.5, 1.0, 4.0), WidthPoint::new(1.0, 1.0)]); + let b = stroke_outline(&line(), &p, false).bounding_box(); + assert!((b.y0 + 5.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn caps_wrap_the_middle_of_a_lopsided_end() { + // 0 on the left, 10 on the right: a round cap is a half circle of radius 5 around (100, 5). + let mut p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]); + p.cap_start = StrokeCap::Round; + p.cap_end = StrokeCap::Round; + let b = stroke_outline(&line(), &p, false).bounding_box(); + assert!((b.x1 - 105.0).abs() < 0.05 && (b.x0 + 5.0).abs() < 0.05, "{b:?}"); + assert!(b.y0 > -0.05 && (b.y1 - 10.0).abs() < 0.05, "{b:?}"); + p.cap_end = StrokeCap::Square; + let b = stroke_outline(&line(), &p, false).bounding_box(); + assert!((b.x1 - 105.0).abs() < 1e-6 && b.y0 > -1e-6, "{b:?}"); +} + +#[test] +fn dashes_and_closed_paths_keep_their_sides() { + let mut p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]); + p.dashes = vec![10.0, 10.0]; + let s = stroke_outline(&line(), &p, false); + let b = s.bounding_box(); + assert!(b.y0.abs() < 1e-6 && (b.y1 - 10.0).abs() < 1e-6, "{b:?}"); + assert!((s.area().abs() - 500.0).abs() < 5.0, "{}", s.area()); + // A closed square drawn clockwise on screen: its right side is the inside. + let mut sq = BezPath::new(); + sq.move_to((0.0, 0.0)); + sq.line_to((100.0, 0.0)); + sq.line_to((100.0, 100.0)); + sq.line_to((0.0, 100.0)); + sq.close_path(); + let p = props(vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)]); + let b = stroke_outline(&sq, &p, true).bounding_box(); + assert!(b.x0 > -1e-6 && b.y0 > -1e-6 && b.x1 < 100.0 + 1e-6 && b.y1 < 100.0 + 1e-6, "all inside: {b:?}"); +} diff --git a/crates/pathops/src/width.rs b/crates/pathops/src/width.rs index e9a4df2..1adb131 100644 --- a/crates/pathops/src/width.rs +++ b/crates/pathops/src/width.rs @@ -2,9 +2,10 @@ //! follows a [`WidthProfile`] along each subpath. //! //! The path is flattened into polylines (sampled densely enough for the width to vary smoothly), -//! each point is offset along the polyline's normals by the interpolated half-width, the sides are -//! joined (round, miter or bevel on the outer side, through the centre on the inner side) and -//! capped. The result may overlap itself; fill it with the non-zero rule. +//! each point is offset along the polyline's normals by the interpolated half-width of that side +//! (the two sides can differ: one-sided widths), the sides are joined (round, miter or bevel on the +//! outer side, through the centre on the inner side) and capped. The result may overlap itself; +//! fill it with the non-zero rule. use figcraft_doc::{StrokeCap, StrokeJoin, StrokeProps, WidthProfile}; use figcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2}; @@ -132,13 +133,22 @@ pub fn nearest(lines: &[Polyline], p: Point) -> Option<(usize, f64, f64)> { lines.iter().enumerate().filter_map(|(i, l)| l.nearest(p).map(|(t, d)| (i, t, d))).min_by(|a, b| a.2.total_cmp(&b.2)) } +/// Half-widths at a sample: `[along the normal, against it]`. The normal is the direction turned +/// clockwise on screen (y down), so the first is the right side looking along the path. +type Half = [f64; 2]; + +/// The half-width of `h` on side `sigma` (+1 = along the normal). +fn on(h: Half, sigma: f64) -> f64 { + if sigma > 0.0 { h[0] } else { h[1] } +} + /// One straight piece of an offset polyline: its ends, the half-widths there, its direction and /// normal. struct Seg { a: Point, b: Point, - ha: f64, - hb: f64, + ha: Half, + hb: Half, d: Vec2, n: Vec2, } @@ -179,8 +189,9 @@ fn arc(out: &mut Vec, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64) /// The corner between segments `p` and `c` on side `sigma` (+1 = the normal's side). fn join(out: &mut Vec, p: &Seg, c: &Seg, sigma: f64, style: Style) { - let pe = p.b + p.n * (sigma * p.hb); - let cs = c.a + c.n * (sigma * c.ha); + let (phb, cha) = (on(p.hb, sigma), on(c.ha, sigma)); + let pe = p.b + p.n * (sigma * phb); + let cs = c.a + c.n * (sigma * cha); out.push(pe); let (cross, dot) = (p.d.cross(c.d), p.d.dot(c.d)); if cross.abs() < 1e-12 && dot > 0.0 { @@ -193,12 +204,12 @@ fn join(out: &mut Vec, p: &Seg, c: &Seg, sigma: f64, style: Style) { if outer { let theta = if cross.abs() < 1e-12 { std::f64::consts::PI } else { cross.atan2(dot) }; match style.join { - StrokeJoin::Round => arc(out, v, pe - v, theta, p.hb, c.ha), + StrokeJoin::Round => arc(out, v, pe - v, theta, phb, cha), StrokeJoin::Miter => { let half = (theta / 2.0).cos(); let bis = p.n + c.n; if half > 1e-6 && 1.0 / half <= style.miter_limit && bis.hypot() > 1e-9 { - out.push(v + bis * (sigma * (p.hb + c.ha) / 2.0 / half / bis.hypot())); + out.push(v + bis * (sigma * (phb + cha) / 2.0 / half / bis.hypot())); } } StrokeJoin::Bevel => {} @@ -217,25 +228,26 @@ fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec { let prev = if j == 0 { closed.then(|| segs.last()).flatten() } else { segs.get(j - 1) }; match prev { Some(p) => join(&mut out, p, s, sigma, style), - None => out.push(s.a + s.n * (sigma * s.ha)), + None => out.push(s.a + s.n * (sigma * on(s.ha, sigma))), } } if !closed && let Some(l) = segs.last() { - out.push(l.b + l.n * (sigma * l.hb)); + out.push(l.b + l.n * (sigma * on(l.hb, sigma))); } out } -/// A cap at `c` facing `dir` (unit, outwards) with half-width `h`: from the `dir.turn_90()` side -/// to the other one. -fn cap(out: &mut Vec, c: Point, dir: Vec2, h: f64, kind: StrokeCap) { +/// A cap at `c` facing `dir` (unit, outwards) from the `dir.turn_90()` side, `from` wide, to the +/// other side, `to` wide. Lopsided ends are capped around the middle of the end. +fn cap(out: &mut Vec, c: Point, dir: Vec2, from: f64, to: f64, kind: StrokeCap) { let n = dir.turn_90(); + let (m, r) = (c + n * ((from - to) / 2.0), (from + to) / 2.0); match cap_shape(kind) { StrokeCap::Square => { - out.push(c + n * h + dir * h); - out.push(c - n * h + dir * h); + out.push(m + n * r + dir * r); + out.push(m - n * r + dir * r); } - StrokeCap::Round => arc(out, c, n, -std::f64::consts::PI, h, h), + StrokeCap::Round => arc(out, m, n, -std::f64::consts::PI, r, r), _ => {} } } @@ -251,7 +263,7 @@ fn push_contour(path: &mut BezPath, pts: impl IntoIterator) { } /// Outline one piece (a subpath or a dash) with its half-widths. -fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, style: Style) { +fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, style: Style) { let segs: Vec = pts .windows(2) .zip(hw.windows(2)) @@ -266,11 +278,12 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, st .collect(); let (Some(first), Some(last)) = (segs.first(), segs.last()) else { // A zero-length piece is a dot with round caps. - if let (Some(p), Some(h)) = (pts.first(), hw.first()) + let r = hw.first().map_or(0.0, |h| (h[0] + h[1]) / 2.0); + if let Some(p) = pts.first() && cap_shape(style.cap_start) == StrokeCap::Round - && *h > 0.0 + && r > 0.0 { - path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, *h), FLATTEN_TOL)); + path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, r), FLATTEN_TOL)); } return; }; @@ -282,18 +295,18 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[f64], closed: bool, st return; } let mut ring = left; - cap(&mut ring, last.b, last.d, last.hb, style.cap_end); + cap(&mut ring, last.b, last.d, last.hb[0], last.hb[1], style.cap_end); ring.extend(right.into_iter().rev()); - cap(&mut ring, first.a, -first.d, first.ha, style.cap_start); + cap(&mut ring, first.a, -first.d, first.ha[1], first.ha[0], style.cap_start); push_contour(path, ring); } /// Split a sampled polyline into dashes (`pattern` alternates dash and gap lengths). -fn dashes(pts: &[Point], hw: &[f64], pattern: &[f64]) -> Vec<(Vec, Vec)> { +fn dashes(pts: &[Point], hw: &[Half], pattern: &[f64]) -> Vec<(Vec, Vec)> { let mut out = Vec::new(); let Some(&first) = pattern.first() else { return out }; let (mut idx, mut left, mut on) = (0usize, first, true); - let mut cur: (Vec, Vec) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect()); + let mut cur: (Vec, Vec) = (pts.iter().take(1).copied().collect(), hw.iter().take(1).copied().collect()); for (p, h) in pts.windows(2).zip(hw.windows(2)) { let (a, b, ha, hb) = (p[0], p[1], h[0], h[1]); let len = (b - a).hypot(); @@ -301,7 +314,7 @@ fn dashes(pts: &[Point], hw: &[f64], pattern: &[f64]) -> Vec<(Vec, Vec left { used += left; let f = if len > 0.0 { used / len } else { 0.0 }; - let (q, hq) = (a.lerp(b, f), ha + (hb - ha) * f); + let (q, hq) = (a.lerp(b, f), [ha[0] + (hb[0] - ha[0]) * f, ha[1] + (hb[1] - ha[1]) * f]); if on { cur.0.push(q); cur.1.push(hq); @@ -381,7 +394,13 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile continue; } let (pts, lens) = resample(line, len / SAMPLES_PER_SUBPATH, &mut budget)?; - let hw: Vec = lens.iter().map(|s| 0.5 * width * profile.scale_at(s / len)).collect(); + let hw: Vec = lens + .iter() + .map(|s| { + let (left, right) = profile.sides_at(s / len); + [0.5 * width * right, 0.5 * width * left] + }) + .collect(); match &pattern { Some(pat) if len / pat.iter().sum::() * (pat.len() as f64) < MAX_DASHES as f64 => { for (p, h) in dashes(&pts, &hw, pat) { diff --git a/crates/render/src/boolean.rs b/crates/render/src/boolean.rs new file mode 100644 index 0000000..f95be6c --- /dev/null +++ b/crates/render/src/boolean.rs @@ -0,0 +1,121 @@ +//! Boolean group results for drawing, cached so editing one operand stays cheap. +//! +//! A boolean group's result is its operands folded bottom to top, each combined with the result +//! of those below it by its own operation (`Node::bool_op`, else the group's). Every prefix of the +//! fold is cached under a rolling hash of the operands it covers, so when an operand changes (a +//! drag, a point edit) the fold restarts from the longest unchanged prefix: the operands below the +//! edited one are never recombined, and groups whose operands didn't change aren't recomputed at +//! all. The caches drop their least recently used half when full, so a long drag doesn't evict +//! every other group's result. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::{Arc, Mutex, OnceLock}; + +use figcraft_doc::BoolOp; +use figcraft_geom::BezPath; + +use crate::draw::hash_path; + +/// A bounded map of computed paths; the least recently used half goes when it is full. +pub(crate) struct PathCache { + map: HashMap, u64)>, + tick: u64, + cap: usize, +} + +impl PathCache { + pub(crate) fn new(cap: usize) -> Self { + Self { map: HashMap::new(), tick: 0, cap: cap.max(2) } + } + + pub(crate) fn get(&mut self, key: u64) -> Option> { + self.tick += 1; + let tick = self.tick; + self.map.get_mut(&key).map(|(p, used)| { + *used = tick; + p.clone() + }) + } + + pub(crate) fn insert(&mut self, key: u64, path: Arc) { + if self.map.len() >= self.cap { + let mut ticks: Vec = self.map.values().map(|(_, t)| *t).collect(); + // Keep the newer half: drop every entry used at or before the median tick. + let mid = (ticks.len() / 2).saturating_sub(1); + let (_, cut, _) = ticks.select_nth_unstable(mid); + let cut = *cut; + self.map.retain(|_, (_, t)| *t > cut); + } + self.tick += 1; + self.map.insert(key, (path, self.tick)); + } +} + +/// The shared cache of a cache-returning `OnceLock`, recovering from a poisoned lock. +pub(crate) fn locked(c: &Mutex) -> std::sync::MutexGuard<'_, PathCache> { + c.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PathCache::new(4096))) +} + +thread_local! { + static STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many boolean steps (one operand combined with the result below it) this thread has +/// computed: what a cache miss costs. For tests and benchmarks. +pub fn boolean_steps() -> u64 { + STEPS.with(std::cell::Cell::get) +} + +/// The result of folding `operands` (bottom to top, each with its operation; the bottom one's is +/// unused), reusing the longest cached prefix. Invalid geometry gives an empty path. +pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> BezPath { + if operands.len() < 2 { + return figcraft_pathops::boolean_ops(operands.iter().map(|(p, op)| (p, *op))).unwrap_or_default(); + } + // keys[i]: the fold of operands 0..=i. + let mut h = DefaultHasher::new(); + let keys: Vec = operands + .iter() + .enumerate() + .map(|(i, (p, op))| { + if i > 0 { + (*op as u8).hash(&mut h); + } + hash_path(p, &mut h); + 9u8.hash(&mut h); + h.finish() + }) + .collect(); + let hit = { + let mut c = locked(cache()); + keys.iter().enumerate().skip(1).rev().find_map(|(i, k)| c.get(*k).map(|p| (i, p))) + }; + let (start, mut acc) = match hit { + Some((i, p)) if i + 1 == operands.len() => return (*p).clone(), + Some((i, p)) => (i, (*p).clone()), + None => match operands.first() { + Some((p, _)) => (0, p.clone()), + None => return BezPath::new(), + }, + }; + let mut fresh = Vec::new(); + for ((p, op), key) in operands.iter().zip(&keys).skip(start + 1) { + STEPS.with(|s| s.set(s.get() + 1)); + acc = match figcraft_pathops::boolean_step(&acc, p, *op) { + Ok(r) => r, + Err(_) => return BezPath::new(), + }; + fresh.push((*key, Arc::new(acc.clone()))); + } + let mut c = locked(cache()); + for (k, p) in fresh { + c.insert(k, p); + } + acc +} diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs new file mode 100644 index 0000000..1c996ec --- /dev/null +++ b/crates/render/src/boolean_cache_tests.rs @@ -0,0 +1,135 @@ +//! Live boolean editing in the renderer: per-operand operations, how much of a group is +//! recomputed when one operand changes, the bounded path caches, and one-sided widths on screen. + +use super::*; +use crate::boolean::{PathCache, boolean_steps, fold_cached}; +use figcraft_color::{Color, Paint}; +use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile}; +use figcraft_geom::kurbo::{BezPath, Shape}; + +fn dark(p: &Pixmap, x: u32, y: u32) -> bool { + let i = ((y * p.width() + x) * 4) as usize; + p.data().get(i).is_some_and(|r| *r < 40) +} + +fn render(d: &Document, p: NodeId) -> Pixmap { + render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap() +} + +/// A boolean group doing `op` over squares 20 wide at x = 10, 25, 40, … (overlapping neighbours), +/// on a page of its own. +fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec) { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let b = d.create(p, NodeKind::BooleanOp { op }, "B", 0.0, 0.0, 0.0, 0.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let kids = (0..n).map(|i| d.create(b, NodeKind::Rectangle, "R", 10.0 + 15.0 * i as f64, 10.0, 20.0, 20.0).unwrap()).collect(); + d.fit_to_children(b).unwrap(); + (d, p, b, kids) +} + +fn outline(d: &Document, id: NodeId) -> BezPath { + node_outline(d, d.node(id).unwrap()).unwrap().0 +} + +#[test] +fn operands_combine_by_their_own_operations() { + // Union of three squares in a row, the middle one subtracted instead. + let (mut d, p, b, kids) = row_of_squares(BoolOp::Union, 3); + let union = outline(&d, b).area().abs(); + assert!((union - 20.0 * 50.0).abs() < 0.5, "{union}"); + d.get_mut(kids[1]).unwrap().bool_op = Some(BoolOp::Subtract); + let mixed = outline(&d, b); + // (A − B) ∪ C: A keeps x 10..25, C is whole (x 40..60). + assert!((mixed.area().abs() - 20.0 * (15.0 + 20.0)).abs() < 0.5, "{}", mixed.area()); + let pix = render(&d, p); + assert!(dark(&pix, 15, 20) && !dark(&pix, 32, 20) && dark(&pix, 50, 20)); + // The bottom operand's own operation changes nothing. + d.get_mut(kids[0]).unwrap().bool_op = Some(BoolOp::Intersect); + assert!((outline(&d, b).area().abs() - mixed.area().abs()).abs() < 1e-9); +} + +#[test] +fn editing_one_operand_recombines_only_from_it_up() { + // Distinct sizes so no other test's cached results can be reused. + let (mut d, _, b, kids) = row_of_squares(BoolOp::Union, 8); + for (i, k) in kids.iter().enumerate() { + d.get_mut(*k).unwrap().height = 20.0 + i as f64 * 0.37; + } + let steps = |d: &Document| { + let before = boolean_steps(); + let _ = outline(d, b); + boolean_steps() - before + }; + assert_eq!(steps(&d), 7, "the first time: every operand above the bottom one"); + assert_eq!(steps(&d), 0, "unchanged: from the cache"); + // Moving the sixth operand (a drag frame) recombines it and the two above it. + let mut moved = d.clone(); + moved.get_mut(kids[5]).unwrap().transform = figcraft_geom::Affine::translate((3.0, 1.5)) * d.node(kids[5]).unwrap().transform; + assert_eq!(steps(&moved), 3); + // The top one: one step. Going back: cached. + let mut top = moved.clone(); + top.get_mut(kids[7]).unwrap().height = 33.3; + assert_eq!(steps(&top), 1); + assert_eq!(steps(&moved), 0); + assert_eq!(steps(&d), 0); + // Hiding an operand drops it from the fold (those above it are recombined). + let mut hidden = d.clone(); + hidden.get_mut(kids[6]).unwrap().visible = false; + assert_eq!(steps(&hidden), 1); +} + +#[test] +fn other_groups_are_not_recomputed() { + let (mut d, _, b, kids) = row_of_squares(BoolOp::Exclude, 4); + let (d2, _, b2, _) = row_of_squares(BoolOp::Intersect, 5); + for (i, k) in kids.iter().enumerate() { + d.get_mut(*k).unwrap().width = 21.0 + i as f64 * 0.29; + } + let _ = outline(&d, b); + let _ = outline(&d2, b2); + let before = boolean_steps(); + d.get_mut(kids[3]).unwrap().width = 30.1; + let _ = outline(&d, b); + let _ = outline(&d2, b2); + assert_eq!(boolean_steps() - before, 1, "only the edited group's top step"); +} + +#[test] +fn caches_drop_the_least_recently_used_half() { + let mut c = PathCache::new(4); + let p = std::sync::Arc::new(BezPath::new()); + for k in 0..4 { + c.insert(k, p.clone()); + } + // Using 0 and 1 keeps them when the cache fills up. + assert!(c.get(0).is_some() && c.get(1).is_some()); + c.insert(9, p.clone()); + assert!(c.get(0).is_some() && c.get(1).is_some() && c.get(9).is_some()); + assert!(c.get(2).is_none() && c.get(3).is_none()); + // Junk and degenerate folds. + assert!(fold_cached(&[]).elements().is_empty()); + let mut bad = BezPath::new(); + bad.move_to((f64::NAN, 0.0)); + bad.line_to((1.0, 1.0)); + let sq = figcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1); + assert!(fold_cached(&[(sq, BoolOp::Union), (bad, BoolOp::Union)]).elements().is_empty()); +} + +#[test] +fn one_sided_widths_paint_one_side() { + // A line from (10, 100) to (190, 100), 20 wide, all of it on the right (+y, looking along it). + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let l = d.create(p, NodeKind::Line, "L", 10.0, 100.0, 180.0, 0.0).unwrap(); + let n = d.get_mut(l).unwrap(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 20.0; + n.stroke.width_profile = Some(WidthProfile { points: vec![WidthPoint::from_sides(0.0, 0.0, 2.0), WidthPoint::from_sides(1.0, 0.0, 2.0)] }); + let pix = render(&d, p); + assert!(dark(&pix, 100, 110) && dark(&pix, 100, 118), "the right side, 20 deep"); + assert!(!dark(&pix, 100, 95), "nothing on the left"); + // The paint bounds reach the widest side. + let b = d.world_render_bounds(l).unwrap(); + assert!(b.y1 >= 120.0 - 1e-6, "{b:?}"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 0247960..d2dac6a 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -10,6 +10,7 @@ use figcraft_geom::{Affine, BezPath, Rect, Shape}; use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform}; use crate::backdrop::{self, Under}; +use crate::boolean::{PathCache, locked}; use crate::paint::{prepare, ts_blend}; use crate::{blur, grain, lod, shadow}; @@ -68,9 +69,9 @@ fn fill_with( } /// Stroke outline cache: (geometry hash) → outline path. -fn stroke_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +fn stroke_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PathCache::new(4096))) } pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) { @@ -143,21 +144,32 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { } props.dashes.len().hash(&mut h); for p in props.profile().map(|p| p.points.as_slice()).unwrap_or_default() { - (p.t.to_bits(), p.scale.to_bits()).hash(&mut h); + (p.t.to_bits(), p.scale.to_bits(), p.bias.to_bits()).hash(&mut h); } let key = h.finish(); - if let Some(p) = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return p.clone(); + if let Some(p) = locked(stroke_cache()).get(key) { + return p; } let out = Arc::new(figcraft_pathops::stroke_outline(path, props, closed)); - let mut c = stroke_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 4096 { - c.clear(); - } - c.insert(key, out.clone()); + locked(stroke_cache()).insert(key, out.clone()); out } +/// A node's painted stroke area in local coordinates with the rule to fill it by (`None` without a +/// visible stroke or geometry). Text strokes follow its glyphs and aren't covered. +pub fn stroke_fill(doc: &Document, n: &Node) -> Option<(Arc, FillRule)> { + if !n.has_visible_stroke() { + return None; + } + let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); + let geom = match &n.kind { + NodeKind::BooleanOp { .. } => node_outline(doc, n).map(|(p, _)| p), + NodeKind::Text(_) | NodeKind::Group | NodeKind::Page { .. } => None, + _ => figcraft_doc::geometry::stroke_geometry(n), + }?; + Some((stroke_area(n, &geom, closed), stroke_rule(n, closed))) +} + /// Even-odd ring paths need the even-odd rule; everything else non-zero. pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule { if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) { @@ -172,7 +184,7 @@ pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule { pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { match &n.kind { NodeKind::BooleanOp { op } => { - let paths: Vec = n + let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n .children .iter() .filter_map(|c| doc.node(*c)) @@ -180,11 +192,10 @@ pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { .filter_map(|c| { let (mut p, _) = subtree_outline(doc, c, 0)?; p.apply_affine(c.transform); - Some(p) + Some((p, c.bool_op.unwrap_or(*op))) }) .collect(); - let p = boolean_cached(&paths, *op); - Some((p, FillRule::EvenOdd)) + Some((crate::boolean::fold_cached(&operands), FillRule::EvenOdd)) } NodeKind::Text(_) => { let (l, dy) = figcraft_text::layout_node(n)?; @@ -230,31 +241,6 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat } } -fn boolean_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn boolean_cached(paths: &[BezPath], op: figcraft_doc::BoolOp) -> BezPath { - let mut h = std::collections::hash_map::DefaultHasher::new(); - (op as u8).hash(&mut h); - for p in paths { - hash_path(p, &mut h); - 9u8.hash(&mut h); - } - let key = h.finish(); - if let Some(p) = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return (**p).clone(); - } - let out = figcraft_pathops::boolean(paths, op).unwrap_or_default(); - let mut c = boolean_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 2048 { - c.clear(); - } - c.insert(key, Arc::new(out.clone())); - out -} - fn device_rect(xf: Affine, r: Rect) -> Rect { figcraft_geom::transform_rect_bbox(xf, r) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e900a9d..64b54c7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ pub mod backdrop; pub mod blur; +pub mod boolean; pub mod draw; pub mod fx; pub mod glass; @@ -17,7 +18,7 @@ pub mod paint; pub mod retained; pub mod shadow; -pub use draw::{node_outline, paint_bounds, subtree_outline, ts_path}; +pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path}; pub use retained::{Retained, Work}; pub use tiny_skia::{FillRule, Pixmap}; @@ -376,6 +377,8 @@ mod backdrop_tests; #[cfg(test)] mod band_tests; #[cfg(test)] +mod boolean_cache_tests; +#[cfg(test)] mod effect_order_tests; #[cfg(test)] mod grain_tests; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 77d1b7a..137b893 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -653,6 +653,23 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option { + for l in lines { + painter.add(Shape::line(l, s)); + } + } + None => { + if let Some(q) = node_quad(app, doc, id) { + quad(painter, q, s); + } + } + } +} + /// Everything drawn over the rendered page; returns the frame labels drawn (frame, corners on the /// page) for the engine to hit-test. fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [Point; 4])> { @@ -724,19 +741,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ && app.session.prefs.highlight_on_hover && app.session.tool.overlay.measure_box.is_none() { - let col = outline_colour(&doc, h); - match outline_shape(app, &doc, h) { - Some(lines) => { - for l in lines { - painter.add(Shape::line(l, stroke(col, 1.5))); - } - } - None => { - if let Some(q) = node_quad(app, &doc, h) { - quad(painter, q, stroke(col, 1.5)); - } - } - } + draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5)); + } + // Inside a boolean group (an operand selected or in vector edit mode): every operand's outline, + // so the shapes making the live result stay visible while one of them changes. + let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id)); + for id in operands.iter().filter(|o| !sel.contains(o)) { + draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0)); } // Drop target / insertion indicator. let ov = &app.session.tool.overlay; @@ -779,18 +790,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() { let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT }; for id in &sel { - match outline_shape(app, &doc, *id) { - Some(lines) => { - for l in lines { - painter.add(Shape::line(l, stroke(col, 1.0))); - } - } - None => { - if let Some(q) = node_quad(app, &doc, *id) { - quad(painter, q, stroke(col, 1.0)); - } - } - } + draw_outline(app, painter, &doc, *id, stroke(col, 1.0)); } // The shape builder reshapes by regions: no transform box. if app.session.tool.tool != ToolId::ShapeBuilder @@ -963,6 +963,7 @@ fn arc_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) { /// Variable width tool: each width point on the path with a bar across the stroke to its two /// width handles (the selected point filled), and a faint preview where a drag would add one. fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &figcraft_engine::vectoredit::VectorEdit, zoom: f64) { + use figcraft_doc::WidthSide; use figcraft_engine::vectoredit::{WHit, width_handles, width_preview}; let hover = app.session.tool.overlay.width_hover; let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]); @@ -971,8 +972,8 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT }; painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0)); painter.line_segment([a, b], stroke(col, 1.0)); - let grab = !ghost && hover == Some(WHit::Handle { index: h.index }); - for e in [a, b] { + for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] { + let grab = !ghost && hover == Some(WHit::Handle { index: h.index, side }); let r = if grab { 4.5 } else { 3.5 }; painter.circle_filled(e, r, Color32::WHITE); painter.circle_stroke(e, r, stroke(col, 1.0)); @@ -986,7 +987,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig for h in width_handles(doc, ve, zoom) { draw(&h, false); } - if let Some(WHit::Path { t }) = hover { + if let Some(WHit::Path { t, .. }) = hover { for h in width_preview(doc, ve, t, zoom) { draw(&h, true); } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 4f6d8c2..8b1d45a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -166,6 +166,12 @@ icons! { Reaction, /// Drag to reorder: two columns of three dots. DragHandle, + /// Arc start: a circle with a radius to the dot where the arc begins. + ArcStart, + /// Arc sweep: a circle with a wedge cut out of it. + ArcSweep, + /// Arc ratio: a ring. + ArcRatio, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -968,6 +974,20 @@ fn draw(p: &P, icon: Icon) { p.rect(x, y, x + 4.0, y + 4.0, 1.0); } } + ArcStart => { + p.circle(8.0, 8.0, 5.5); + p.line(8.0, 8.0, 13.5, 8.0); + p.dot(13.5, 8.0, 1.6); + } + ArcSweep => { + let mut wedge = vec![(8.0, 8.0)]; + wedge.extend(arc_pts(8.0, 8.0, 5.5, 0.0, 1.5 * PI)); + p.path(&wedge, true); + } + ArcRatio => { + p.circle(8.0, 8.0, 5.5); + p.circle(8.0, 8.0, 2.5); + } Logo => { // FigCraft's own mark: three stacked rounded tiles forming an "F" shape. p.fill_rect(3.0, 2.0, 13.0, 5.0, 1.0); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index d60271c..e728605 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -14,8 +14,22 @@ use crate::{FigApp, LeftTab}; pub const ROW: f32 = 32.0; +/// A boolean operation's icon. +pub fn op_icon(op: figcraft_doc::BoolOp) -> Icon { + match op { + figcraft_doc::BoolOp::Union => Icon::Union, + figcraft_doc::BoolOp::Subtract => Icon::Subtract, + figcraft_doc::BoolOp::Intersect => Icon::Intersect, + figcraft_doc::BoolOp::Exclude => Icon::Exclude, + } +} + pub fn node_icon(doc: &Document, id: NodeId) -> Icon { let Some(n) = doc.node(id) else { return Icon::Rectangle }; + // An operand combining by an operation other than its group's shows that operation. + if let Some(op) = figcraft_engine::booleans::distinct_op(doc, id) { + return op_icon(op); + } match &n.kind { NodeKind::Page { .. } => Icon::Page, NodeKind::Frame if figcraft_engine::components::is_slot(doc, id) => Icon::Slot, @@ -51,12 +65,7 @@ pub fn node_icon(doc: &Document, id: NodeId) -> Icon { } NodeKind::Vector { .. } => Icon::Vector, NodeKind::Text(_) => Icon::Text, - NodeKind::BooleanOp { op } => match op { - figcraft_doc::BoolOp::Union => Icon::Union, - figcraft_doc::BoolOp::Subtract => Icon::Subtract, - figcraft_doc::BoolOp::Intersect => Icon::Intersect, - figcraft_doc::BoolOp::Exclude => Icon::Exclude, - }, + NodeKind::BooleanOp { op } => op_icon(*op), NodeKind::Component { .. } => Icon::Component, NodeKind::ComponentSet { .. } => Icon::ComponentSet, NodeKind::Instance { .. } => Icon::Instance, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60ed3ce..eca6c6f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -99,6 +99,8 @@ mod proto_responsive_tests; mod shortcuts_tests; #[cfg(test)] mod titlebar_tests; +#[cfg(test)] +mod vector_tail_ui_tests; use std::collections::BTreeSet; use std::path::PathBuf; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 8f2dece..fcd772f 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -255,8 +255,17 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl /// A menu item with a check mark when `on` (the unchecked ones line up with it). pub fn menu_check(ui: &mut Ui, on: bool, label: &str, shortcut: &str) -> bool { - let resp = menu_row(ui, &format!("{}{label}", if on { "✓ " } else { " " }), shortcut, 0.0, |_, _, _| {}); - a11y::menu_item(&resp, label, shortcut, Some(on)); + marked_row(ui, Some(on), label, shortcut) +} + +/// A plain menu item lined up with the check items above or below it. +pub fn menu_item_beside_checks(ui: &mut Ui, label: &str, shortcut: &str) -> bool { + marked_row(ui, None, label, shortcut) +} + +fn marked_row(ui: &mut Ui, on: Option, label: &str, shortcut: &str) -> bool { + let resp = menu_row(ui, &format!("{}{label}", if on == Some(true) { "✓ " } else { " " }), shortcut, 0.0, |_, _, _| {}); + a11y::menu_item(&resp, label, shortcut, on); resp.clicked() } @@ -296,6 +305,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) { // Editing a vector with points selected, the points' fields stand in for the layer's position // and dimensions. let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty()); + let vector_edit_none = vector_edit.is_none(); match vector_edit { Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve), None => { @@ -312,6 +322,9 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::layout_panel::layout_section(app, ui, &doc, &sel); widgets::separator(ui); } + if vector_edit_none && arc_section(app, ui, &doc, &sel) { + widgets::separator(ui); + } appearance(app, ui, &doc, &sel); widgets::separator(ui); if variable_modes(app, ui, &doc, &sel) { @@ -466,6 +479,8 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { (Icon::Mask, "Use as mask", "object.mask"), (Icon::Union, "Boolean groups", "bool.union"), ]; + // Layers in boolean groups, or one boolean group: the operation they use (checked in the menu). + let current = figcraft_engine::booleans::shown_op(doc, sel); for (icon, label, cmd) in actions.into_iter().rev() { let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0)); let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) }; @@ -473,21 +488,36 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } + // The boolean button shows the operation in use. + let icon = match (cmd, current) { + ("bool.union", Some(op)) => crate::layers::op_icon(op), + _ => icon, + }; icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon); if cmd == "bool.union" { menu_popup(&bresp).width(180.0).show(|ui| { - for (l, c) in [ - ("Union", "bool.union"), - ("Subtract", "bool.subtract"), - ("Intersect", "bool.intersect"), - ("Exclude", "bool.exclude"), - ("Flatten", "vector.flatten"), + for (op, c) in [ + (BoolOp::Union, "bool.union"), + (BoolOp::Subtract, "bool.subtract"), + (BoolOp::Intersect, "bool.intersect"), + (BoolOp::Exclude, "bool.exclude"), ] { - if menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) { + let key = crate::shortcuts::shortcut(c).unwrap_or(""); + let picked = match current { + Some(cur) => menu_check(ui, cur == op, op.label(), key), + None => menu_item(ui, op.label(), key), + }; + if picked { app.run(c, json!({})); ui.close(); } } + let key = crate::shortcuts::shortcut("vector.flatten").unwrap_or(""); + let flatten = if current.is_some() { menu_item_beside_checks(ui, "Flatten", key) } else { menu_item(ui, "Flatten", key) }; + if flatten { + app.run("vector.flatten", json!({})); + ui.close(); + } }); } else if bresp.clicked() { app.run(cmd, json!({})); @@ -605,6 +635,50 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running +/// the other way) and Ratio (the inner radius), the same values the canvas arc handles drag +/// (`node.set`'s `arcStart` / `arcSweep` / `arcInner`; bindable to number variables, sweep and +/// ratio ones holding percentages). Returns whether it showed. +fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool { + let arcs: Vec<(f64, f64, f64)> = sel + .iter() + .filter_map(|i| match doc.node(*i)?.kind { + NodeKind::Ellipse { start, sweep, inner } => Some((start, sweep, inner)), + _ => None, + }) + .collect(); + if arcs.is_empty() || arcs.len() != sel.len() { + return false; + } + let ids = sel.to_vec(); + let same = |f: &dyn Fn(&(f64, f64, f64)) -> f64| -> Option { + let v = (f(arcs.first()?) * 100.0).round() / 100.0; + arcs.iter().all(|a| ((f(a) * 100.0).round() / 100.0 - v).abs() < 1e-9).then_some(v) + }; + let start = same(&|a| a.0.to_degrees().rem_euclid(360.0)); + let sweep = same(&|a| a.1 / std::f64::consts::TAU * 100.0); + let ratio = same(&|a| a.2 * 100.0); + widgets::section_header(ui, "Arc", &[], false); + let col = widgets::col_width(ui.available_width()); + let field = |app: &mut FigApp, ui: &mut Ui, key: &str, icon: Icon, name: &str, value: Option, suffix: &str| { + crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("arc", key)), Lead::Icon(icon, name), value, suffix, col) + }; + widgets::sublabels(ui, "Start", Some("Sweep")); + row(ui, 32.0, |ui| { + let e = field(app, ui, "arcStart", Icon::ArcStart, "Arc start", start, "°"); + app.field(&ids, "arcStart", e, |v| json!(v)); + let e = field(app, ui, "arcSweep", Icon::ArcSweep, "Arc sweep", sweep, "%"); + app.field(&ids, "arcSweep", e, |v| json!(v.clamp(-100.0, 100.0) * 3.6)); + }); + widgets::sublabels(ui, "Ratio", None); + row(ui, 32.0, |ui| { + let e = field(app, ui, "arcInner", Icon::ArcRatio, "Arc ratio", ratio, "%"); + app.field(&ids, "arcInner", e, |v| json!((v / 100.0).clamp(0.0, 0.99))); + }); + ui.add_space(8.0); + true +} + fn pretty(s: &str) -> String { match s { "LeftRight" => "Left & right".into(), @@ -1033,13 +1107,17 @@ pub(crate) fn profile_art(painter: &egui::Painter, rect: Rect, profile: Option<& let (mut top, mut bottom) = (Vec::new(), Vec::new()); for i in 0..=STEPS { let t = f64::from(i) / f64::from(STEPS); - let s = profile.map_or(0.5, |p| p.scale_at(t) / max) as f32; + // The path runs left to right: its left side is drawn on top. + let (l, r) = profile.map_or((0.5, 0.5), |p| { + let (l, r) = p.sides_at(t); + (l / max, r / max) + }); let x = rect.min.x + rect.width() * t as f32; - let h = (s * half).max(0.5); - top.push(pos2(x, cy - h)); - bottom.push(pos2(x, cy + h)); - mesh.colored_vertex(pos2(x, cy - h), col); - mesh.colored_vertex(pos2(x, cy + h), col); + let (hl, hr) = (((l as f32) * half).max(0.5), ((r as f32) * half).max(0.5)); + top.push(pos2(x, cy - hl)); + bottom.push(pos2(x, cy + hr)); + mesh.colored_vertex(pos2(x, cy - hl), col); + mesh.colored_vertex(pos2(x, cy + hr), col); } for i in 0..STEPS { let k = 2 * i; @@ -1088,7 +1166,8 @@ fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Opt if flip.clicked() && let Some(p) = custom { - let pts: Vec<[f64; 2]> = p.points.iter().rev().map(|q| [1.0 - q.t, q.scale]).collect(); + let pts: Vec = + p.points.iter().rev().map(|q| figcraft_engine::cmd::props::width_point_json(&WidthPoint { t: 1.0 - q.t, ..*q })).collect(); app.run("stroke.widthProfile", json!({"ids": ids, "points": pts})); } }); diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index cb5d74b..aaa86e2 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -53,6 +53,23 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon, "Width"), Some(round(p.scale * weight)), "", col, 1.0); point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "width": v.max(0.0)})); }); + // Each side's reach from the centre line (Alt-drag a handle on the canvas for one side). + let (l, r) = p.sides(); + widgets::sublabels(ui, "Left side", Some("Right side")); + row(ui, 32.0, |ui| { + for (side, scale, name) in [("left", l, "Left side width"), ("right", r, "Right side width")] { + let e = widgets::number_field( + ui, + Id::new(("wpt_side", side)), + Lead::Icon(Icon::StrokeIcon, name), + Some(round(scale * weight / 2.0)), + "", + col, + 1.0, + ); + point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "side": side, "width": v.max(0.0)})); + } + }); ui.add_space(8.0); } diff --git a/crates/ui-egui/src/vector_tail_ui_tests.rs b/crates/ui-egui/src/vector_tail_ui_tests.rs new file mode 100644 index 0000000..0c4ef3f --- /dev/null +++ b/crates/ui-egui/src/vector_tail_ui_tests.rs @@ -0,0 +1,105 @@ +//! The Design panel's Arc fields (named, valued, Mixed across ellipses, typed into), the width +//! point's side fields, and operands with an operation of their own in the layers panel. + +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::icons::Icon; +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn tree(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn value(nodes: &[Node], label: &str) -> Option { + nodes.iter().find(|n| n.role() == Role::SpinButton && n.label() == Some(label)).map(|n| n.value().unwrap_or_default().to_string()) +} + +fn press(u: &mut Ui, key: Key) { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +#[test] +fn arc_fields_show_and_set_the_ellipse_arc() { + let mut s = Session::new(); + let e = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("node.set", &json!({"id": e, "props": {"arcStart": 90, "arcSweep": 270, "arcInner": 0.4}})).unwrap(); + s.execute("select.set", &json!({"ids": [e]})).unwrap(); + let mut u = Ui::new(s); + let nodes = tree(&mut u); + assert_eq!(value(&nodes, "Arc start").as_deref(), Some("90°")); + assert_eq!(value(&nodes, "Arc sweep").as_deref(), Some("75%")); + assert_eq!(value(&nodes, "Arc ratio").as_deref(), Some("40%")); + // Two ellipses with different sweeps: Mixed; with a rectangle in the selection: no Arc fields. + let e2 = id(u.app.session.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 50, "height": 50})).unwrap()); + u.app.session.execute("node.set", &json!({"id": e2, "props": {"arcStart": 90, "arcInner": 0.4}})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [e, e2]})).unwrap(); + let nodes = tree(&mut u); + assert_eq!(value(&nodes, "Arc start").as_deref(), Some("90°")); + assert_eq!(value(&nodes, "Arc sweep").as_deref(), Some("Mixed")); + u.app.session.execute("select.set", &json!({"ids": [e, r]})).unwrap(); + assert_eq!(value(&tree(&mut u), "Arc sweep"), None); + // Typing into Sweep sets it on the ellipse (one step). + u.app.session.execute("select.set", &json!({"ids": [e]})).unwrap(); + press(&mut u, Key::F6); + for _ in 0..200 { + if crate::a11y::focused_name(&u.ctx) == "Arc sweep" { + break; + } + press(&mut u, Key::Tab); + } + assert_eq!(crate::a11y::focused_name(&u.ctx), "Arc sweep"); + press(&mut u, Key::Enter); + u.frame(vec![Event::Text("50".into())]); + press(&mut u, Key::Enter); + u.frame(vec![]); + let sweep = match u.app.session.doc().unwrap().node(e).unwrap().kind { + figcraft_doc::NodeKind::Ellipse { sweep, .. } => sweep, + _ => 0.0, + }; + assert!((sweep - std::f64::consts::PI).abs() < 1e-9, "{sweep}"); +} + +#[test] +fn a_width_point_shows_its_sides() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"points": [[0, 50], [100, 50]], "closed": false})).unwrap()); + s.execute("node.set", &json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}})).unwrap(); + s.execute("vector.edit.begin", &json!({"id": v})).unwrap(); + s.execute("vector.edit.tool", &json!({"tool": "width"})).unwrap(); + s.execute("vector.edit.widthPoint", &json!({"t": 0.5, "width": 20})).unwrap(); + s.execute("vector.edit.widthPoint", &json!({"index": 1, "side": "left", "width": 4})).unwrap(); + let mut u = Ui::new(s); + let nodes = tree(&mut u); + assert_eq!(value(&nodes, "Left side width").as_deref(), Some("4")); + assert_eq!(value(&nodes, "Right side width").as_deref(), Some("10")); + assert_eq!(value(&nodes, "Width").as_deref(), Some("14")); +} + +#[test] +fn operands_with_their_own_operation_show_it_in_the_layers_panel() { + let mut s = Session::new(); + let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let b = id(s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()); + let g = id(s.execute("bool.subtract", &json!({"ids": [a, b]})).unwrap()); + let doc = s.doc().unwrap(); + assert_eq!(crate::layers::node_icon(doc, b), Icon::Ellipse, "following the group: its own icon"); + assert_eq!(crate::layers::node_icon(doc, g), Icon::Subtract); + s.execute("bool.intersect", &json!({"ids": [b]})).unwrap(); + assert_eq!(crate::layers::node_icon(s.doc().unwrap(), b), Icon::Intersect); + assert_eq!(crate::layers::node_icon(s.doc().unwrap(), a), Icon::Rectangle, "the bottom operand's never shows"); +} diff --git a/docs/gaps.md b/docs/gaps.md index b87062d..82bf1cf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -26,7 +26,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 10 | Shared (team) libraries | Feature | 10–20 | [components-parity](components-parity.md) | | 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 12 | Video and animated GIF fills; HEIC/TIFF images | File format | 6–12 | [file-format-parity](file-format-parity.md) | -| 13 | Live boolean editing and the vector tail | Feature | 6–10 | [target-app-parity](target-app-parity.md) | +| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections, import/export | Feature | 4–8 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) | | 16 | Dev Mode: Code Connect, editor integrations | Feature | 4–8 | [dev-mode-parity](dev-mode-parity.md) | @@ -159,10 +159,17 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe (help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF (first frame) and BMP. - **Estimate:** 6–12 h (video playback in the presenter is most of it). -### 13. Live boolean editing and the vector tail -- **Missing:** editing a boolean group's operands in place with live results, Design panel arc - fields, shape builder on strokes and open paths, one-sided variable widths. -- **Estimate:** 6–10 h. +### 13. Boolean groups hit-tested by their operands +- **Landed (2026-10-11):** live boolean editing (operands selected by double-click or Cmd/Ctrl-click; + moving, resizing or editing an operand's points redraws the result during the drag, with every + operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame + recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep, + Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take + part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG, + SVG, PDF and outline stroke). +- **Missing:** a click is tested against a boolean group's operands, not its result, so a click in + a subtracted hole still picks the group. +- **Estimate:** 1–3 h. ### 14. Variables: extended collections, import/export - **Missing:** extended collections (a library collection extended with overrides), variables @@ -218,6 +225,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | diff --git a/docs/mcp.md b/docs/mcp.md index fb91b22..f597b34 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -218,6 +218,16 @@ points: `vector.edit.widthPoint {t, width? (px) | scale?}` adds one, `{index, t? moves or resizes it, `{action: "remove", index}` deletes it. Rendering, PNG/SVG/PDF export and `vector.outlineStroke` follow the profile. +One-sided widths: a width point can be wider on one side of the path than the other (left and +right looking along the path, y down). Points are `[t, left, right]` (or `{t, left, right}`) where +each side's scale is what `scale` would be with both sides that wide, so `[t, s]` equals +`[t, s, s]`; `get_node` reports one-sided points that way. `vector.edit.widthPoint {index, side: +left|right, width? | scale?}` changes one side (`width`: the distance from the centre line to that +edge; with `t` it adds a point keeping the other side as it is); a width without `side` makes the +point symmetric again. With `pointer`, `alt: true` while dragging a handle (or the path) changes +that side only; `vector.edit.state` lists `widthPoints` with both handle positions (`sides`: +right, left) and `oneSided`. + ### Prototypes Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep @@ -367,15 +377,35 @@ remove and reorder layers there, unlike the rest of an instance). It is a compon `get_node` shows `"slot": ""` on slot layers. +### Boolean groups + +`bool.union` / `bool.subtract` / `bool.intersect` / `bool.exclude {ids?}` wrap layers in a new +boolean group, except: with one boolean group selected they set its operation (every operand follows +it again), and with layers inside boolean groups they set those operands' own operation. Each +operand above the bottom one combines with the result of the operands below it by its own +operation, or the group's (`node.set {operandOperation: union|subtract|intersect|exclude | null}` +on any operand, or `booleanOperation` on an operand that isn't itself a boolean group; on a group +`booleanOperation` is the group's). `get_node` shows `operandOperation` on operands that have one. +Operands stay editable layers: `select.children` (Enter) or a double-click selects them, `cmd: true` +clicks reach them directly, and moving, resizing or editing an operand's points (`vector.edit.*`) +updates the group's result on every pointer move, one undo step per gesture; a dragged operand +stays in its group. `bool.flatten` bakes the result. Only the operands from the edited one up are +recombined during a drag (`figcraft_render::boolean`; timed by `cargo xtask bench`). + ### Shape builder The selected shapes' outlines split the canvas into regions (areas covered by the same shapes). `shapeBuilder.regions {ids?}` lists them: an SVG `path` in page coordinates, the `sources` covering -each one (top-most first), `bounds` and a `point` inside. `shapeBuilder.merge {ids?, regions?, -points?}` makes one vector from the regions (by index, or the regions under page points), styled -like the top-most shape over the first one; `shapeBuilder.remove {ids?, regions?, points?}` deletes -them. Either way every shape covering a picked region loses that area (it becomes a vector, or goes -when nothing is left), and each call is one undo step. The tool is `tool.shapeBuilder` (Shift+M): +each one (top-most first), the `parts` covering it (`{id, stroke}`: a shape's fill, or its stroke's +outlined area; top-most first), `bounds` and a `point` inside. Visible strokes take part as their +outlined areas, so stroked lines and open paths split the regions they cross and a stroke's band is a +region of its own. `shapeBuilder.merge {ids?, regions?, points?}` makes one vector from the regions +(by index, or the regions under page points), styled like the top-most fill or stroke over the first +one (a stroke's region is filled with the stroke's paints); `shapeBuilder.remove {ids?, regions?, +points?}` deletes them. Either way every shape covering a picked region loses that area (it becomes +a vector, or goes when nothing is left); the strokes of the shapes involved become outlined vectors +just above them (`outlines` in the result; an open path is replaced by its outline), and each call +is one undo step. The tool is `tool.shapeBuilder` (Shift+M): click or drag across regions with `pointer` to merge, add `alt` to remove; `shapeBuilder.mode {mode: merge|remove}` swaps what a plain click does. @@ -715,7 +745,10 @@ guide, along the frame's axis nearer the ruler's. the start. **Start** turns the arc and keeps its sweep. **Ratio** (the centre, or the inner edge halfway round a ring) drags out to make a ring, and into the gap to show the other part. `shift: true` snaps angles to 15° steps and the ratio to 5% steps. Each drag is one undo step - and writes `node.set`'s `arcStart` / `arcSweep` (degrees) and `arcInner` (0–0.99). + and writes `node.set`'s `arcStart` / `arcSweep` (degrees) and `arcInner` (0–0.99). The Design + panel's Arc fields (Start °, Sweep %, Ratio %) write the same keys; number variables bound to + `arcSweep` and `arcInner` (`variable.bind`) hold percentages, as the fields show them, and ones + bound to `arcStart` degrees. `tool.overlay.arc` holds the readout while dragging (`{handle, label: "Sweep 75%", at}`), and `tool.overlay.arc_hover` holds the handle under the pointer. - **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its diff --git a/docs/roadmap.md b/docs/roadmap.md index 13d35a6..a6868e4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -50,7 +50,7 @@ blockers. | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~75% | extended collections, variables import/export, real libraries | 10–20 | -| M4 Vector & text depth | ~70% | live booleans, spell check, Figma Draw (brushes, pressure, text on a path, patterns) | 24–40 | +| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 | | M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 | | M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | @@ -71,6 +71,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index befca11..57fe8ce 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged; last major: the 2026-10-10 full re-measure) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live boolean editing and the vector tail: feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -16,12 +16,12 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Number | Value | Kind | |---|---|---| | Menu/command breadth | **98.5%** (200 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) | -| Feature breadth (does each Figma Design feature exist, weighted) | **~78%** (core editor areas 1–16: ~90%) | estimated from the area table below | +| Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below | | Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) | -| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.5% × discounts 0.9 × 0.9 × 0.8) | +| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.7% × discounts 0.9 × 0.9 × 0.8) | | Remaining to beta | **~200–365 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~390–745 h** one agent (~95–170 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~385–730 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~47% | ~375–705 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | -| Mainstream practitioner | ~43% | ~180–330 h | core editor depth incl. `.fig` import (98–188), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | +| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -97,7 +97,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 1 | Canvas & navigation | 6 | 95% | 72% | Outline mode variants (`view.outlinesSelection`, `view.maskOutlines`), Display P3 on screen, QA at extreme zooms | 3–6 | | 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 | | 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 | -| 4 | Shapes & vector | 8 | 92% | 66% | Live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 8–14 | +| 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 | | 5 | Paint | 7 | 85% | 66% | Video and animated GIF fills, wide-gamut canvas, embedded image profiles, HEIC/TIFF images | 5–9 | | 6 | Strokes | 3 | 90% | 72% | Stroke styles, brush strokes (see 20) | 2–4 | | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 | | 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~78%** | **~57%** | | **218–431** | -| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 98–188 | +| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **211–420** | +| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 91–177 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 78–146 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | +| Features (depth) | 35 | 57% | 71–135 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 20% | 25–55 | Own WASM plugins with a Rust SDK and two examples (#101); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~47%** | **~390–745** | | | +| **Ready for real work** | 100 | **~47%** | **~385–730** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **66.5%**. +**2. Average depth** ("Ready" column, weighted): **66.7%**. **3. Discounts for what still stops real work:** @@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.80 | A Figma designer's work lives in shared Figma files. FigCraft can't open `.fig` or write anything Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -66.5% × 0.90 × 0.90 × 0.80 = **~43%** (estimated). +66.7% × 0.90 × 0.90 × 0.80 = **~43%** (estimated). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is @@ -254,8 +254,13 @@ Hours in this narrative are the builders' estimates; the tables above supersede other vectors, the usual layer targets and the pixel grid, with guides and a marker on the point landed on; Ctrl/Cmd turns it off), arc handles on the canvas (sweep, start and ratio with a live readout, Shift snapping, closing at the start, the other part from the gap; rotated and - flipped ellipses, any zoom). *Left:* live boolean editing, one-sided widths, Design panel arc - fields. 4–8 h. + flipped ellipses, any zoom), Design panel Arc fields (Start, Sweep, Ratio; bindable), live + boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or + editing an operand's points redraws the result during the drag with every operand outlined; + per-operand operations from the boolean controls; only the operands from the edited one up are + recombined), the shape builder on strokes and open paths (strokes as their outlined areas), and + one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke). + *Left:* boolean groups are hit-tested by their operands rather than their result. 1–3 h. 2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, truncation/max lines, font picker with previews and recents, word/line selection, IME commit, inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the @@ -572,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a4d03cf..9c15c87 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; collected from the ROADMAP, the keymap and the canvas code) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -52,10 +52,11 @@ interaction feel not yet compared. |---|---|---| | Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | | | Frame (presets panel, swap orientation), section, slice | done | | -| Rectangle, ellipse (arc handles), polygon, star, line, arrow; Shift/Alt | done | Design panel arc fields missing | +| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | | | Pen: vector networks, branch, close, bend; pencil | done | no pen pressure | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | live boolean editing missing | -| Shape builder (Shift+M), variable width (Shift+W) | done | | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | | +| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | +| Boolean groups: double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | hit-tested by the operands, not the result | | Text: point, auto height, fixed; caret, words, lines, IME | done | no spell check | | Eyedropper (I) | done | | | Comment (C), Shift+C to show/hide | done | local only | @@ -90,4 +91,5 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) | | 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |