From 08d91869cbbfcf77a23193c4df6ca331d0fa3942 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 10:04:25 +0700 Subject: [PATCH 1/3] M4: live boolean editing, arc fields, shape builder on strokes, one-sided widths (#111) Boolean operands are editable in place with live results (per-operand operations, cached prefixes so a drag recomputes from the edited operand up); a Design panel Arc section; the shape builder takes strokes and open paths as outlined areas; variable width points can be wider on one side. --- README.md | 2 +- ROADMAP.md | 22 +- apps/figcraft-cli/src/perf.rs | 36 +++ crates/collab/src/change.rs | 1 + crates/doc/src/boolean_hit_tests.rs | 64 ++++++ crates/doc/src/geometry.rs | 23 +- crates/doc/src/lib.rs | 4 + crates/doc/src/node.rs | 5 + crates/doc/src/props.rs | 163 ++++++++++--- crates/doc/src/width_sides_tests.rs | 106 +++++++++ crates/engine/src/arc_fields_tests.rs | 68 ++++++ crates/engine/src/booleans.rs | 130 +++++++++++ crates/engine/src/booleans_tests.rs | 215 ++++++++++++++++++ crates/engine/src/cmd/props.rs | 70 +++++- crates/engine/src/cmd/structure.rs | 20 +- crates/engine/src/cmd/styles.rs | 3 + crates/engine/src/components.rs | 2 + crates/engine/src/inspect.rs | 6 +- crates/engine/src/lib.rs | 7 + crates/engine/src/shapebuilder.rs | 153 ++++++++++--- .../engine/src/shapebuilder_strokes_tests.rs | 156 +++++++++++++ crates/engine/src/shapebuilder_tests.rs | 2 +- crates/engine/src/tools.rs | 16 +- crates/engine/src/vectoredit.rs | 2 + crates/engine/src/vectoredit/width.rs | 142 +++++++++--- .../src/vectoredit/width_sides_tests.rs | 167 ++++++++++++++ crates/engine/src/vectoredit/width_tests.rs | 4 +- crates/pathops/src/lib.rs | 63 +++-- crates/pathops/src/ops_tests.rs | 83 +++++++ crates/pathops/src/sides_tests.rs | 70 ++++++ crates/pathops/src/width.rs | 73 +++--- crates/render/src/boolean.rs | 121 ++++++++++ crates/render/src/boolean_cache_tests.rs | 135 +++++++++++ crates/render/src/draw.rs | 66 +++--- crates/render/src/lib.rs | 5 +- crates/ui-egui/src/canvas.rs | 57 ++--- crates/ui-egui/src/icons.rs | 20 ++ crates/ui-egui/src/layers.rs | 21 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/right.rs | 111 +++++++-- crates/ui-egui/src/vector_panel.rs | 17 ++ crates/ui-egui/src/vector_tail_ui_tests.rs | 105 +++++++++ docs/gaps.md | 20 +- docs/mcp.md | 45 +++- docs/roadmap.md | 5 +- docs/target-app-parity.md | 36 +-- docs/ui-parity.md | 10 +- 47 files changed, 2328 insertions(+), 326 deletions(-) create mode 100644 crates/doc/src/boolean_hit_tests.rs create mode 100644 crates/doc/src/width_sides_tests.rs create mode 100644 crates/engine/src/arc_fields_tests.rs create mode 100644 crates/engine/src/booleans.rs create mode 100644 crates/engine/src/booleans_tests.rs create mode 100644 crates/engine/src/shapebuilder_strokes_tests.rs create mode 100644 crates/engine/src/vectoredit/width_sides_tests.rs create mode 100644 crates/pathops/src/ops_tests.rs create mode 100644 crates/pathops/src/sides_tests.rs create mode 100644 crates/render/src/boolean.rs create mode 100644 crates/render/src/boolean_cache_tests.rs create mode 100644 crates/ui-egui/src/vector_tail_ui_tests.rs 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 | From 19343f53316bf4f0ef4473f341555491035d2f2e Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 10:05:49 +0700 Subject: [PATCH 2/3] M7: home and moon icon fixes (#110) The home icon's roof stays centred (unhinted outline) and its floor stops at the door; the moon's arcs stop short of the cusp so the tips no longer fold. --- crates/ui-egui/src/icons.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 8b1d45a..cc5511e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -718,7 +718,8 @@ fn draw(p: &P, icon: Icon) { } Play => p.fill(&[(4.5, 2.5), (13.5, 8.0), (4.5, 13.5)]), Home => { - p.poly(&[(3.5, 13.5), (3.5, 7.0), (8.0, 2.8), (12.5, 7.0), (12.5, 13.5)], true); + // Unhinted so the roof's peak stays centred; the floor stops at the door. + p.path(&[(6.5, 13.5), (3.5, 13.5), (3.5, 7.0), (8.0, 2.8), (12.5, 7.0), (12.5, 13.5), (9.5, 13.5)], false); p.poly(&[(6.5, 13.5), (6.5, 9.5), (9.5, 9.5), (9.5, 13.5)], false); } Bell => { @@ -825,9 +826,10 @@ fn draw(p: &P, icon: Icon) { StrokeIcon => p.rect(2.5, 2.5, 13.5, 13.5, 2.0), Fill => p.fill_rect(2.0, 2.0, 14.0, 14.0, 2.0), Moon => { - // A crescent: the outer rim, then the shadowing circle's edge back. - let mut m: Vec<(f32, f32)> = arc_pts(8.0, 8.0, 5.5, 0.166, TAU - 1.737).collect(); - m.extend(arc_pts(11.0, 5.0, 4.6, -2.586, 1.016 - TAU)); + // A crescent: the outer rim, then the shadowing circle's edge back. Both arcs stop short + // of where the circles meet, so each tip is two blunt joins (a single cusp join folds). + let mut m: Vec<(f32, f32)> = arc_pts(8.0, 8.0, 5.5, 0.166 + 0.12, TAU - 1.737 - 0.12).collect(); + m.extend(arc_pts(11.0, 5.0, 4.6, -2.586 - 0.18, 1.016 - TAU + 0.18)); p.path(&m, true); } Undo | Redo => { From 07fa4d92ed01b1011b3ae73dfad87aa4ec5f26cd Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 10:19:03 +0700 Subject: [PATCH 3/3] M9: standalone collaboration server with share links and permissions (#112) figcraft-server hosts many files over the same hub the app uses for sessions, persists each as a checksummed snapshot plus an append-only log (synced before replies, compacted, damaged files skipped and reported) so restarts lose nothing confirmed, and gives per-file share links with edit or view access (hashed 128-bit tokens, revocable). The app can publish a file to a server and join server-hosted files; view-only tabs refuse edits. Protocol version 2. --- Cargo.lock | 18 + README.md | 6 +- ROADMAP.md | 9 +- apps/figcraft-server/Cargo.toml | 33 + apps/figcraft-server/src/cli.rs | 294 ++++++++ apps/figcraft-server/src/cli_tests.rs | 107 +++ apps/figcraft-server/src/config.rs | 121 ++++ apps/figcraft-server/src/disk.rs | 200 ++++++ apps/figcraft-server/src/lib.rs | 80 +++ apps/figcraft-server/src/main.rs | 40 ++ apps/figcraft-server/src/server_tests.rs | 280 ++++++++ apps/figcraft-server/src/store.rs | 615 +++++++++++++++++ apps/figcraft-server/src/store_tests.rs | 271 ++++++++ apps/figcraft-server/src/test_util.rs | 83 +++ crates/collab-net/src/driver.rs | 4 +- crates/collab-net/src/driver_tests.rs | 4 +- crates/collab-net/src/guest.rs | 94 ++- crates/collab-net/src/host.rs | 569 ++------------- crates/collab-net/src/hub.rs | 842 +++++++++++++++++++++++ crates/collab-net/src/invite.rs | 203 ++++-- crates/collab-net/src/invite_tests.rs | 73 +- crates/collab-net/src/join.rs | 17 +- crates/collab-net/src/lib.rs | 6 +- crates/collab-net/src/link.rs | 43 +- crates/collab-net/src/loopback_tests.rs | 22 +- crates/collab-net/src/manage.rs | 71 ++ crates/collab-net/src/net.rs | 20 + crates/collab-net/src/web.rs | 13 +- crates/collab/src/client.rs | 18 +- crates/collab/src/codec_tests.rs | 23 +- crates/collab/src/journal_tests.rs | 176 +++++ crates/collab/src/lib.rs | 12 +- crates/collab/src/message.rs | 130 +++- crates/collab/src/server.rs | 120 +++- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/view.rs | 6 +- crates/engine/src/lib.rs | 19 +- crates/engine/src/live.rs | 141 +++- crates/engine/src/live_access_tests.rs | 127 ++++ crates/engine/src/live_tests.rs | 24 +- crates/ui-egui/src/a11y.rs | 8 + crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/live.rs | 210 ++++-- crates/ui-egui/src/live_join_tests.rs | 2 +- crates/ui-egui/src/live_server_tests.rs | 121 ++++ crates/ui-egui/src/live_tests.rs | 4 + crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/toolbar.rs | 42 ++ docs/architecture.md | 7 +- docs/cli.md | 7 +- docs/collab-protocol.md | 131 +++- docs/collaboration-parity.md | 12 +- docs/control-protocol.md | 10 +- docs/gaps.md | 24 +- docs/mcp.md | 16 +- docs/releasing.md | 8 + docs/roadmap.md | 9 +- docs/server.md | 174 +++++ docs/target-app-parity.md | 7 +- 60 files changed, 4954 insertions(+), 780 deletions(-) create mode 100644 apps/figcraft-server/Cargo.toml create mode 100644 apps/figcraft-server/src/cli.rs create mode 100644 apps/figcraft-server/src/cli_tests.rs create mode 100644 apps/figcraft-server/src/config.rs create mode 100644 apps/figcraft-server/src/disk.rs create mode 100644 apps/figcraft-server/src/lib.rs create mode 100644 apps/figcraft-server/src/main.rs create mode 100644 apps/figcraft-server/src/server_tests.rs create mode 100644 apps/figcraft-server/src/store.rs create mode 100644 apps/figcraft-server/src/store_tests.rs create mode 100644 apps/figcraft-server/src/test_util.rs create mode 100644 crates/collab-net/src/hub.rs create mode 100644 crates/collab-net/src/manage.rs create mode 100644 crates/collab/src/journal_tests.rs create mode 100644 crates/engine/src/live_access_tests.rs create mode 100644 crates/ui-egui/src/live_server_tests.rs create mode 100644 docs/server.md diff --git a/Cargo.lock b/Cargo.lock index 0b7e674..13295a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1607,6 +1607,24 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "figcraft-server" +version = "0.3.1" +dependencies = [ + "crc32fast", + "env_logger", + "figcraft-collab", + "figcraft-collab-net", + "figcraft-doc", + "figcraft-engine", + "log", + "serde", + "serde_json", + "sha2", + "toml", + "tungstenite", +] + [[package]] name = "figcraft-svg" version = "0.3.1" diff --git a/README.md b/README.md index 7feb045..5cec4ca 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). -- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow (undo is not per person yet). Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). +- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). +- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. - **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images. @@ -149,6 +150,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control git clone https://github.com/storytold/figcraft cd figcraft cargo run --release -p figcraft # the desktop app +cargo run --release -p figcraft-server # the collaboration server (docs/server.md) cargo test --workspace # the test suite cargo xtask ci # fmt, clippy, tests, layering and asset checks ``` @@ -166,7 +168,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] -> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app on a local network, with per-person undo; no sharing or permissions yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). +> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). ## The Crafting Apps diff --git a/ROADMAP.md b/ROADMAP.md index 35276ae..5f3d2d1 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 (live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; 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 @@ -45,7 +45,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human: **Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but -FigCraft can't open Figma's own `.fig` files, has no multiplayer, and nothing has been checked +FigCraft can't open Figma's own `.fig` files, has only a preview of multiplayer, and nothing has been checked against real Figma documents. Ready-for-real-work is ~47%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is missing, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs ~75% **and** reliable `.fig` import: the `.fig` reader (30–60 h), a real-file corpus with measured fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), @@ -59,7 +59,7 @@ the localization layer with Figma's own languages (20–35 h). | 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) | +| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) | @@ -123,7 +123,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. 4. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h. -5. Multiplayer (M9): sharing and a standalone server with TLS on top of the live sessions (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, and the browser client landed): 45–90 h. +5. Multiplayer (M9): accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view landed): 45–90 h. ## Progress log @@ -157,6 +157,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 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 | diff --git a/apps/figcraft-server/Cargo.toml b/apps/figcraft-server/Cargo.toml new file mode 100644 index 0000000..bf3877f --- /dev/null +++ b/apps/figcraft-server/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "figcraft-server" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "FigCraft's standalone collaboration server: keeps many files with share links (edit or view), persists them as snapshots plus a log, and serves live sessions (headless, native only)" + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "figcraft-server" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +figcraft-collab = { workspace = true } +figcraft-collab-net = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +crc32fast = { workspace = true } +toml = "1" +env_logger = { version = "0.11", default-features = false, features = ["humantime"] } + +[dev-dependencies] +figcraft-engine = { workspace = true } +figcraft-doc = { workspace = true } +tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs new file mode 100644 index 0000000..95ebdc1 --- /dev/null +++ b/apps/figcraft-server/src/cli.rs @@ -0,0 +1,294 @@ +//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data +//! directory. When a server is running on the directory they ask it (so revoking a link +//! disconnects whoever joined with it at once); otherwise they change the directory themselves. + +use std::fmt::Write as _; +use std::net::{IpAddr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink}; +use figcraft_collab_net::hub::Backend; +use figcraft_collab_net::{Endpoint, Invite, manage}; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; +use crate::disk; +use crate::store::{self, KEY, Store}; + +/// Where a running server says it listens. +pub const SERVER_JSON: &str = "server.json"; + +pub const USAGE: &str = "\ +figcraft-server: FigCraft's collaboration server (files, share links, live sessions) + +Usage: + figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE] + figcraft-server key [--rotate] the address to publish to, with the server key + figcraft-server files [list] the files kept + figcraft-server files remove FILE remove a file and its links for good + figcraft-server links list FILE a file's share links + figcraft-server links create FILE --access edit|view [--label TEXT] + figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected) + +Options (also keys of the --config TOML file): + --listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network) + --data DIR the data directory (default ./figcraft-data) + --public-url URL how links name the server behind a reverse proxy (wss://host/path/) + --config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links, + max_connections, max_people, compact_after, compact_mb + -h, --help this text + -V, --version the version + +Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens. +"; + +/// What to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Command { + Serve, + Key { rotate: bool }, + Files, + RemoveFile { file: String }, + Links { file: String }, + CreateLink { file: String, access: Access, label: String }, + Revoke { file: String, link: String }, + Help, + Version, +} + +/// A parsed command line. +#[derive(Clone, Debug, PartialEq)] +pub struct Cli { + pub config: Config, + pub command: Command, +} + +/// Read the arguments (after the program name). +pub fn parse(args: &[String]) -> Result { + // The config file first: flags override it. + let mut config = match args.iter().position(|a| a == "--config") { + Some(i) => Config::load(Path::new(args.get(i + 1).ok_or("--config needs a file")?))?, + None => Config::default(), + }; + let mut words = Vec::new(); + let mut access = None; + let mut label = String::new(); + let mut rotate = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value")); + match a.as_str() { + "-h" | "--help" => return Ok(Cli { config, command: Command::Help }), + "-V" | "--version" => return Ok(Cli { config, command: Command::Version }), + "--config" => { + value("--config")?; + } + "--listen" => config.listen = value("--listen")?, + "--data" => config.data = PathBuf::from(value("--data")?), + "--public-url" => config.public_url = Some(value("--public-url")?), + "--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?), + "--label" => label = value("--label")?, + "--rotate" => rotate = true, + s if s.starts_with('-') => return Err(format!("unknown option {s}")), + s => words.push(s.to_string()), + } + } + let w: Vec<&str> = words.iter().map(String::as_str).collect(); + let command = match w.as_slice() { + [] | ["serve"] => Command::Serve, + ["key"] => Command::Key { rotate }, + ["files"] | ["files", "list"] => Command::Files, + ["files", "remove", file] => Command::RemoveFile { file: file.to_string() }, + ["links", "list", file] => Command::Links { file: file.to_string() }, + ["links", "create", file] => { + Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label } + } + ["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() }, + _ => return Err(format!("unknown command `{}`", words.join(" "))), + }; + config.check()?; + Ok(Cli { config, command }) +} + +/// What a running server writes into its data directory. +#[derive(Debug, Serialize, Deserialize)] +pub struct Running { + pub listen: SocketAddr, + pub pid: u32, +} + +/// Note where the server listens (for the command line). +pub fn write_running(data: &Path, listen: SocketAddr) -> Result<(), String> { + let text = serde_json::to_vec_pretty(&Running { listen, pid: std::process::id() }).map_err(|e| e.to_string())?; + disk::write_atomic(&data.join(SERVER_JSON), &text).map_err(|e| format!("can't write {SERVER_JSON}: {e}")) +} + +/// `addr` as something this machine can connect to (the loopback address for "every interface"). +fn reachable(addr: SocketAddr) -> SocketAddr { + match addr.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => SocketAddr::from(([127, 0, 0, 1], addr.port())), + IpAddr::V6(ip) if ip.is_unspecified() => SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, addr.port())), + _ => addr, + } +} + +/// How share links and the publish address name the server: `public_url`, else the listen +/// address (this machine's network address for "every interface"). +pub fn public_endpoint(cfg: &Config, listen: SocketAddr) -> Result { + if let Some(e) = cfg.public()? { + return Ok(e); + } + let ip = if listen.ip().is_unspecified() { figcraft_collab_net::lan_ip().unwrap_or(reachable(listen).ip()) } else { listen.ip() }; + Ok(Endpoint::plain(SocketAddr::new(ip, listen.port()).to_string())) +} + +/// Run a command other than serving; what to print. +pub fn run(cli: &Cli) -> Result { + let cfg = &cli.config; + match &cli.command { + Command::Help => Ok(USAGE.to_string()), + Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))), + Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()), + Command::Key { rotate } => key(cfg, *rotate), + Command::Files => match admin(cfg, Admin::Files)? { + AdminReply::Files(files) => Ok(files_table(&files)), + other => unexpected(other), + }, + Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? { + AdminReply::Done => Ok(format!("Removed file {file} and its links.\n")), + other => unexpected(other), + }, + Command::Links { file } => match admin(cfg, Admin::Links { file: file.clone() })? { + AdminReply::Links(links) => Ok(links_table(&links)), + other => unexpected(other), + }, + Command::CreateLink { file, access, label } => { + match admin(cfg, Admin::CreateLink { file: file.clone(), access: *access, label: label.clone() })? { + AdminReply::Link(l) => Ok(new_link(cfg, &l)?), + other => unexpected(other), + } + } + Command::Revoke { file, link } => match admin(cfg, Admin::Revoke { file: file.clone(), link: link.clone() })? { + AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")), + other => unexpected(other), + }, + } +} + +fn unexpected(reply: AdminReply) -> Result { + match reply { + AdminReply::Failed { reason } => Err(reason), + other => Err(format!("the server answered {other:?}")), + } +} + +/// The running server's address, when one holds the data directory. +fn running(data: &Path) -> Result { + let bytes = disk::read_capped(&data.join(SERVER_JSON), 1 << 16).map_err(|e| format!("a server is using {} but {e}", data.display()))?; + let r: Running = serde_json::from_slice(&bytes).map_err(|e| format!("{SERVER_JSON} is damaged ({e})"))?; + Ok(reachable(r.listen)) +} + +/// Do one request: through the running server, or on the data directory. +fn admin(cfg: &Config, req: Admin) -> Result { + match Store::try_open(&cfg.data, cfg.store())? { + Some(mut store) => Ok(store.admin(req, &|_| None).reply), + None => { + let addr = running(&cfg.data)?; + let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?; + let replies = manage::request(&Endpoint::plain(addr.to_string()), key.trim(), vec![req], &cfg.net(), Duration::from_secs(5))?; + replies.into_iter().next().ok_or_else(|| "the server didn't answer".to_string()) + } + } +} + +/// The address to publish to, with the key. +fn key(cfg: &Config, rotate: bool) -> Result { + let mut store = Store::try_open(&cfg.data, cfg.store())?; + let listen = match &store { + Some(_) => cfg.addr()?, + None => running(&cfg.data)?, + }; + let key = match (store.as_mut(), rotate) { + (Some(s), true) => s.rotate_key()?, + (Some(s), false) => s.key().to_string(), + (None, true) => return Err("stop the server to replace its key".into()), + (None, false) => std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?.trim().to_string(), + }; + let public = public_endpoint(cfg, listen)?; + let mut out = String::new(); + if rotate { + out.push_str("A new server key: the old one no longer works.\n"); + } + let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):"); + let _ = writeln!(out, " {}?key={key}", public.url()); + let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust."); + Ok(out) +} + +fn new_link(cfg: &Config, l: &NewLink) -> Result { + let listen = match running(&cfg.data) { + Ok(a) if store::lock(&cfg.data)?.is_none() => a, + _ => cfg.addr()?, + }; + let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() }; + let mut out = String::new(); + let what = if l.link.access == Access::View { "view" } else { "edit" }; + let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file); + let _ = writeln!(out, " {}", invite.link()); + let _ = writeln!(out, " {}", invite.url()); + let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token)."); + Ok(out) +} + +fn files_table(files: &[FileInfo]) -> String { + if files.is_empty() { + return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into(); + } + let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n"); + for f in files { + let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes)); + } + out +} + +fn links_table(links: &[LinkInfo]) -> String { + if links.is_empty() { + return "No links (make one with `figcraft-server links create FILE --access view`).\n".into(); + } + let mut out = String::from("LINK ACCESS CREATED LABEL\n"); + for l in links { + let _ = writeln!(out, "{:<16} {:<6} {:<20} {}", l.id, l.access.id(), date(l.created), l.label); + } + out +} + +fn elide(s: &str, n: usize) -> String { + if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::()) } +} + +fn size(bytes: u64) -> String { + match bytes { + b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / f64::from(1 << 20)), + b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0), + b => format!("{b} B"), + } +} + +/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970. +pub fn date(secs: u64) -> String { + let days = i64::try_from(secs / 86_400).unwrap_or(0); + let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); + // Days to a civil date (proleptic Gregorian). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(mo <= 2); + format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC") +} diff --git a/apps/figcraft-server/src/cli_tests.rs b/apps/figcraft-server/src/cli_tests.rs new file mode 100644 index 0000000..085daea --- /dev/null +++ b/apps/figcraft-server/src/cli_tests.rs @@ -0,0 +1,107 @@ +//! The command line: parsing (flags over the config file), and managing a data directory both +//! on its own and through the server running on it. + +use figcraft_collab::Replica; +use figcraft_collab::message::Access; +use figcraft_doc::Document; + +use crate::cli::{self, Cli, Command, date, parse}; +use crate::config::Config; +use crate::store::Store; +use crate::test_util::{TempDir, config}; + +fn args(s: &[&str]) -> Vec { + s.iter().map(|a| a.to_string()).collect() +} + +fn run(dir: &std::path::Path, command: Command) -> Result { + cli::run(&Cli { config: config(dir), command }) +} + +/// A data directory with one published file: its id. +fn with_file(dir: &std::path::Path) -> String { + let mut store = Store::open(dir, config(dir).store()).unwrap(); + let snapshot = Replica::new(Document::new("Board")).snapshot().unwrap(); + store.publish("Board", &snapshot).unwrap().0.id +} + +#[test] +fn parsing() { + let c = parse(&[]).unwrap(); + assert_eq!(c.command, Command::Serve); + assert_eq!(c.config, Config::default()); + let c = parse(&args(&["serve", "--listen", "0.0.0.0:47900", "--data", "d", "--public-url", "wss://f.example.com/live/"])).unwrap(); + assert_eq!((c.config.listen.as_str(), c.config.data.to_str()), ("0.0.0.0:47900", Some("d"))); + assert_eq!( + parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command, + Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() } + ); + assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files); + assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true }); + assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help); + for bad in + [&["--nope"][..], &["links", "create", "f1"], &["--listen", "nowhere"], &["--listen"], &["frobnicate"], &["--public-url", "ws://h/?key=k"]] + { + assert!(parse(&args(bad)).is_err(), "{bad:?}"); + } + // A config file, with flags over it; unknown keys are errors. + let dir = TempDir::new("cli-config"); + let file = dir.path().join("server.toml"); + std::fs::write(&file, "listen = \"0.0.0.0:47901\"\nmax_files = 7\ncompact_after = 50\n").unwrap(); + let c = parse(&args(&["--config", file.to_str().unwrap(), "--listen", "127.0.0.1:1"])).unwrap(); + assert_eq!((c.config.listen.as_str(), c.config.max_files, c.config.compact_after), ("127.0.0.1:1", 7, 50)); + std::fs::write(&file, "max_files = 0\n").unwrap(); + assert!(parse(&args(&["--config", file.to_str().unwrap()])).unwrap_err().contains("max_files")); + std::fs::write(&file, "colour = \"blue\"\n").unwrap(); + assert!(parse(&args(&["--config", file.to_str().unwrap()])).is_err()); +} + +#[test] +fn managing_a_directory_on_its_own() { + let dir = TempDir::new("cli-offline"); + let file = with_file(dir.path()); + let list = run(dir.path(), Command::Files).unwrap(); + assert!(list.contains(&file) && list.contains("Board"), "{list}"); + let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap(); + assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}"); + let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap(); + assert_eq!(links.lines().count(), 4, "a header and three links: {links}"); + let id = links.lines().find(|l| l.contains("Client")).and_then(|l| l.split_whitespace().next()).unwrap().to_string(); + run(dir.path(), Command::Revoke { file: file.clone(), link: id.clone() }).unwrap(); + assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap_err().contains("no such link")); + // The key: printed as the address to publish to; replaced on request. + let key = run(dir.path(), Command::Key { rotate: false }).unwrap(); + assert!(key.contains("ws://127.0.0.1:") && key.contains("?key="), "{key}"); + let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap(); + assert_ne!(key.lines().nth(1), rotated.lines().nth(2)); + run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap(); + assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet")); + assert!(run(dir.path(), Command::Links { file }).is_err()); +} + +#[test] +fn managing_through_the_running_server() { + let dir = TempDir::new("cli-online"); + let file = with_file(dir.path()); + let mut server = crate::start(&config(dir.path())).unwrap(); + // The directory is the server's now: commands ask it. + let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::Edit, label: "Team".into() }).unwrap(); + assert!(made.contains(&format!("host={}", server.addr())), "links name the running server: {made}"); + let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap(); + assert!(links.contains("Team"), "{links}"); + let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string(); + assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked")); + assert!(run(dir.path(), Command::Files).unwrap().contains("Board")); + assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server")); + assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr()))); + server.stop(); + // Stopped: the directory is free again. + assert!(run(dir.path(), Command::Files).unwrap().contains("Board")); +} + +#[test] +fn dates() { + assert_eq!(date(0), "1970-01-01 00:00 UTC"); + assert_eq!(date(951_782_400), "2000-02-29 00:00 UTC"); + assert_eq!(date(1_791_986_580), "2026-10-14 14:03 UTC"); +} diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs new file mode 100644 index 0000000..c7f4da9 --- /dev/null +++ b/apps/figcraft-server/src/config.rs @@ -0,0 +1,121 @@ +//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use figcraft_collab_net::{Endpoint, NetLimits}; +use serde::Deserialize; + +use crate::store::StoreConfig; + +/// The port the server listens on unless told otherwise (the app's sessions try it first too). +pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT; + +/// Everything the server can be told (the TOML file's keys are these names). +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + /// Where to listen: `127.0.0.1:47900` (this machine only) unless told otherwise. + pub listen: String, + /// The data directory (files, links, the server key). + pub data: PathBuf, + /// How share links name this server when it isn't reached at `listen` (a reverse proxy: + /// `wss://figcraft.example.com/`). + pub public_url: Option, + /// Most files kept. + pub max_files: usize, + /// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at + /// most 64 MiB). + pub max_file_mb: u64, + /// Most share links per file. + pub max_links: usize, + /// Most open connections. + pub max_connections: usize, + /// Most people in one file at once. + pub max_people: usize, + /// Compact a file's log into a new snapshot after this many entries… + pub compact_after: usize, + /// …or once the log is this large (MiB). + pub compact_mb: u64, +} + +impl Default for Config { + fn default() -> Self { + Self { + listen: format!("127.0.0.1:{DEFAULT_PORT}"), + data: PathBuf::from("figcraft-data"), + public_url: None, + max_files: 1000, + max_file_mb: 64, + max_links: 100, + max_connections: 512, + max_people: 256, + compact_after: 1000, + compact_mb: 16, + } + } +} + +impl Config { + /// Read a TOML file's settings (unknown keys are errors). + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("can't read {}: {e}", path.display()))?; + toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display())) + } + + /// The listen address. + pub fn addr(&self) -> Result { + self.listen.trim().parse().map_err(|_| format!("`listen` must be an address with a port, like 0.0.0.0:{DEFAULT_PORT}")) + } + + /// The public URL, when set: a `ws://` or `wss://` address without a query. + pub fn public(&self) -> Result, String> { + let Some(url) = self.public_url.as_deref().map(str::trim).filter(|u| !u.is_empty()) else { return Ok(None) }; + let (e, query) = Endpoint::parse(url).map_err(|e| format!("`public_url`: {e}"))?; + if !query.is_empty() { + return Err("`public_url` can't have a query (no keys or tokens in it)".into()); + } + Ok(Some(e)) + } + + /// Check everything (before starting). + pub fn check(&self) -> Result<(), String> { + self.addr()?; + self.public()?; + for (name, v) in [ + ("max_files", self.max_files as u64), + ("max_file_mb", self.max_file_mb), + ("max_links", self.max_links as u64), + ("max_connections", self.max_connections as u64), + ("max_people", self.max_people as u64), + ("compact_after", self.compact_after as u64), + ("compact_mb", self.compact_mb), + ] { + if v == 0 { + return Err(format!("`{name}` must be at least 1")); + } + } + if self.max_file_mb > 4096 || self.compact_mb > 4096 { + return Err("sizes are in MiB and at most 4096".into()); + } + Ok(()) + } + + /// The store's part. + pub fn store(&self) -> StoreConfig { + let mib = |n: u64| n.saturating_mul(1 << 20); + StoreConfig { + max_files: self.max_files, + max_file_bytes: mib(self.max_file_mb), + max_links: self.max_links, + compact_after: self.compact_after, + compact_bytes: mib(self.compact_mb), + core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }, + } + } + + /// The transport's part. + pub fn net(&self) -> NetLimits { + NetLimits { max_connections: self.max_connections, ..NetLimits::default() } + } +} diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs new file mode 100644 index 0000000..9b7c4d8 --- /dev/null +++ b/apps/figcraft-server/src/disk.rs @@ -0,0 +1,200 @@ +//! The server's files on disk and reading them back without trusting them: atomic writes (a +//! temporary file beside the target, flushed, then renamed over it), the snapshot format and the +//! append-only log format. +//! +//! - `snapshot.bin`: `FCSN`, a format byte, the generation (u64 LE), a CRC-32 of the rest (u32 LE), +//! then the session state ([`SessionState`] in the wire encoding). +//! - `log.bin`: `FCLG`, a format byte, the generation of the snapshot it continues, then records: +//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding. +//! +//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log +//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it +//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short). + +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use figcraft_collab::message; +use figcraft_collab::{Journal, SessionState}; + +pub const SNAPSHOT: &str = "snapshot.bin"; +pub const LOG: &str = "log.bin"; +pub const META: &str = "meta.json"; +pub const LINKS: &str = "links.json"; + +const SNAP_MAGIC: &[u8; 4] = b"FCSN"; +const LOG_MAGIC: &[u8; 4] = b"FCLG"; +const FORMAT: u8 = 1; +const SNAP_HEADER: usize = 4 + 1 + 8 + 4; +pub const LOG_HEADER: usize = 4 + 1 + 8; +const RECORD_HEADER: usize = 8; + +/// `path` with `.tmp` added: where its next version is written first. +pub fn temp_of(path: &Path) -> PathBuf { + let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); + name.push(".tmp"); + path.with_file_name(name) +} + +/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let tmp = temp_of(path); + { + let mut f = File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + fs::rename(&tmp, path)?; + sync_dir(path.parent()); + Ok(()) +} + +/// Make a rename in `dir` durable (directories can't be opened for that on Windows). +pub fn sync_dir(dir: Option<&Path>) { + #[cfg(unix)] + if let Some(d) = dir + && let Ok(f) = File::open(d) + { + let _ = f.sync_all(); + } + #[cfg(not(unix))] + let _ = dir; +} + +/// Read a file of at most `max` bytes (a longer one is refused without being read). +pub fn read_capped(path: &Path, max: u64) -> Result, String> { + let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?; + let len = f.metadata().map_err(|e| e.to_string())?.len(); + if len > max { + return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len)); + } + let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0)); + f.take(max).read_to_end(&mut bytes).map_err(|e| format!("can't read {}: {e}", name(path)))?; + Ok(bytes) +} + +fn name(path: &Path) -> String { + path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() +} + +fn crc(bytes: &[u8]) -> u32 { + crc32fast::hash(bytes) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + b.get(at..at + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes) +} + +/// A snapshot file's bytes. +pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result, String> { + let body = message::encode(state).map_err(|e| e.to_string())?; + let mut out = Vec::with_capacity(SNAP_HEADER + body.len()); + out.extend_from_slice(SNAP_MAGIC); + out.push(FORMAT); + out.extend_from_slice(&generation.to_le_bytes()); + out.extend_from_slice(&crc(&body).to_le_bytes()); + out.extend_from_slice(&body); + Ok(out) +} + +/// A snapshot's generation and body, when its header and checksum are right. +fn snapshot_body(bytes: &[u8]) -> Result<(u64, &[u8]), String> { + if bytes.get(..4) != Some(SNAP_MAGIC.as_slice()) { + return Err("the snapshot isn't one (wrong header)".into()); + } + if bytes.get(4) != Some(&FORMAT) { + return Err("the snapshot is in a format this version doesn't read".into()); + } + let (Some(generation), Some(sum), Some(body)) = (u64_at(bytes, 5), u32_at(bytes, 13), bytes.get(SNAP_HEADER..)) else { + return Err("the snapshot is cut short".into()); + }; + if crc(body) != sum { + return Err("the snapshot is damaged (checksum mismatch)".into()); + } + Ok((generation, body)) +} + +/// A snapshot's generation, after checking its header and checksum (no decoding). +pub fn check_snapshot(bytes: &[u8]) -> Result { + snapshot_body(bytes).map(|(g, _)| g) +} + +/// A snapshot's generation and session state. +pub fn decode_snapshot(bytes: &[u8]) -> Result<(u64, SessionState), String> { + let (generation, body) = snapshot_body(bytes)?; + let state = message::decode::(body, body.len()).map_err(|e| format!("the snapshot can't be read: {e}"))?; + Ok((generation, state)) +} + +/// An empty log continuing snapshot `generation`. +pub fn log_header(generation: u64) -> Vec { + let mut out = Vec::with_capacity(LOG_HEADER); + out.extend_from_slice(LOG_MAGIC); + out.push(FORMAT); + out.extend_from_slice(&generation.to_le_bytes()); + out +} + +/// One log record. +pub fn record(entry: &Journal) -> Result, String> { + let body = message::encode(entry).map_err(|e| e.to_string())?; + let len = u32::try_from(body.len()).map_err(|_| "a journal entry over 4 GiB".to_string())?; + let mut out = Vec::with_capacity(RECORD_HEADER + body.len()); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&crc(&body).to_le_bytes()); + out.extend_from_slice(&body); + Ok(out) +} + +/// What a log holds. +#[derive(Debug)] +pub struct LogRead { + pub generation: u64, + pub entries: Vec, + /// Why it ends before the end of the file (a damaged or cut-short record), if it does. + pub damaged: Option, +} + +/// Read a log: its generation and the entries up to the first damaged record. Only a header that +/// isn't a log's is an error. +pub fn read_log(bytes: &[u8]) -> Result { + if bytes.get(..4) != Some(LOG_MAGIC.as_slice()) { + return Err("the log isn't one (wrong header)".into()); + } + if bytes.get(4) != Some(&FORMAT) { + return Err("the log is in a format this version doesn't read".into()); + } + let generation = u64_at(bytes, 5).ok_or("the log is cut short")?; + let mut at = LOG_HEADER; + let mut entries = Vec::new(); + let mut damaged = None; + while at < bytes.len() { + let (Some(len), Some(sum)) = (u32_at(bytes, at), u32_at(bytes, at + 4)) else { + damaged = Some(format!("a record cut short at byte {at}")); + break; + }; + let start = at + RECORD_HEADER; + let Some(body) = usize::try_from(len).ok().and_then(|n| bytes.get(start..start.checked_add(n)?)) else { + damaged = Some(format!("a record cut short at byte {at}")); + break; + }; + if crc(body) != sum { + damaged = Some(format!("a damaged record at byte {at}")); + break; + } + match message::decode::(body, body.len()) { + Ok(e) => entries.push(e), + Err(e) => { + damaged = Some(format!("an unreadable record at byte {at} ({e})")); + break; + } + } + at = start + body.len(); + } + Ok(LogRead { generation, entries, damaged }) +} diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs new file mode 100644 index 0000000..45c4c8c --- /dev/null +++ b/apps/figcraft-server/src/lib.rs @@ -0,0 +1,80 @@ +//! `figcraft-server`: FigCraft's standalone collaboration server (milestone M9, step 5 of +//! `docs/collab-protocol.md`; running it: `docs/server.md`). +//! +//! It keeps many files in a data directory ([`store`]), each with share links that let people +//! edit or only view, and serves their live sessions through the same hub the desktop app hosts +//! with ([`figcraft_collab_net::hub`]). Files persist as a snapshot plus an append-only log of what +//! changed ([`disk`]), compacted into a new snapshot from time to time, so a restart loses nothing +//! that was confirmed and clients resume where they were. There are no user accounts yet: share +//! links are the access, and the server key manages files. + +pub mod cli; +pub mod config; +pub mod disk; +pub mod store; + +use std::net::{SocketAddr, TcpListener}; + +use figcraft_collab_net::hub::Hosting; + +use crate::config::Config; +use crate::store::Store; + +/// A running server. Dropping it stops it: open files are saved, everyone is told the server is +/// restarting (their apps reconnect once it is back). +pub struct Running { + hosting: Hosting, +} + +impl Running { + /// Where it listens. + pub fn addr(&self) -> SocketAddr { + self.hosting.addr() + } + + /// Open connections. + pub fn connections(&self) -> usize { + self.hosting.connections() + } + + /// Stop (see the type's docs). + pub fn stop(&mut self) { + self.hosting.stop(); + } +} + +/// Start serving `cfg.data` on `cfg.listen`. Files that can't be read are skipped and logged. +pub fn start(cfg: &Config) -> Result { + cfg.check()?; + let store = Store::open(&cfg.data, cfg.store())?; + for p in store.problems() { + log::warn!("{p}"); + } + let files = store.len(); + let addr = cfg.addr()?; + let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?; + let addr = listener.local_addr().map_err(|e| e.to_string())?; + cli::write_running(&cfg.data, addr)?; + let public = cli::public_endpoint(cfg, addr)?; + let hosting = Hosting::serve(listener, store, cfg.net()).map_err(|e| format!("can't start serving: {e}"))?; + log::info!( + "figcraft-server {}: {files} file{} in {}; listening on {addr}; links name {}", + env!("CARGO_PKG_VERSION"), + if files == 1 { "" } else { "s" }, + cfg.data.display(), + public.url() + ); + if addr.ip().is_loopback() { + log::info!("only this machine can connect: listen on 0.0.0.0:{} (or behind a reverse proxy) to share", addr.port()); + } + Ok(Running { hosting }) +} + +#[cfg(test)] +mod cli_tests; +#[cfg(test)] +mod server_tests; +#[cfg(test)] +mod store_tests; +#[cfg(test)] +mod test_util; diff --git a/apps/figcraft-server/src/main.rs b/apps/figcraft-server/src/main.rs new file mode 100644 index 0000000..8e9fddd --- /dev/null +++ b/apps/figcraft-server/src/main.rs @@ -0,0 +1,40 @@ +//! `figcraft-server`: see `docs/server.md`, or run it with `--help`. + +use std::process::ExitCode; + +use figcraft_server::cli::{self, Command}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let cli = match cli::parse(&args) { + Ok(c) => c, + Err(e) => { + eprintln!("figcraft-server: {e}\n(figcraft-server --help lists what it takes)"); + return ExitCode::from(2); + } + }; + if cli.command != Command::Serve { + return match cli::run(&cli) { + Ok(out) => { + print!("{out}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("figcraft-server: {e}"); + ExitCode::FAILURE + } + }; + } + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).target(env_logger::Target::Stdout).init(); + match figcraft_server::start(&cli.config) { + // Serves until the process is stopped: what was confirmed is on disk already, and clients + // reconnect once it runs again. + Ok(_running) => loop { + std::thread::park(); + }, + Err(e) => { + log::error!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/apps/figcraft-server/src/server_tests.rs b/apps/figcraft-server/src/server_tests.rs new file mode 100644 index 0000000..5226824 --- /dev/null +++ b/apps/figcraft-server/src/server_tests.rs @@ -0,0 +1,280 @@ +//! The server end to end over loopback, in-process with a temporary data directory: an app +//! publishes its file and syncs through the server, others join with the edit link and the view +//! link, edits converge and the viewer's are refused (by its app, and by the server for a client +//! that skips the check), the server restarts mid-session and everyone resumes without losing a +//! confirmed edit, revoked links stop working at once, and fuzzed frames don't hurt it. + +use std::net::{SocketAddr, TcpStream}; +use std::path::Path; +use std::time::Duration; + +use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, ServerMsg}; +use figcraft_collab::sim::Rng; +use figcraft_collab::{Client, VIEW_ONLY}; +use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use figcraft_engine::live::{Published, Status}; +use serde_json::{Value, json}; +use tungstenite::{Bytes, Message, WebSocket}; + +use crate::test_util::{TempDir, config, pump, same, session}; + +fn key(dir: &Path) -> String { + std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string() +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn live(s: &Session) -> bool { + s.live.status == Status::Live && s.live.syncing() +} + +/// `a` publishes its open file to the server at `addr`; the links. +fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published { + let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap(); + assert_eq!(r["status"], "connecting"); + assert_eq!(r["readOnly"], true, "read-only while it is on its way"); + pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some()); + a.live.published.clone().unwrap() +} + +fn join(link: &str, name: &str) -> Session { + let mut s = session(name); + s.execute("collab.join", &json!({"link": link})).unwrap(); + s +} + +/// A bare client: hello, then what it's told. +struct Raw(WebSocket); + +impl Raw { + fn connect(addr: SocketAddr) -> Raw { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0) + } + + fn send(&mut self, m: &ClientMsg) { + self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap(); + } + + fn next(&mut self) -> ServerMsg { + loop { + if let Message::Binary(b) = self.0.read().unwrap() { + return message::decode(&b, usize::MAX).unwrap(); + } + } + } +} + +#[test] +fn publish_then_edit_and_view_together() { + let dir = TempDir::new("share"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let mut a = session("Ada"); + a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 80})).unwrap(); + let addr = server.addr(); + let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir.path()))})).unwrap(); + assert_eq!(r["status"], "connecting"); + // Nothing changes it until it is on the server. + let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string(); + assert!(e.contains("being published"), "{e}"); + pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some()); + let p = a.live.published.clone().unwrap(); + assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab"); + assert_eq!(a.live_summary()["readOnly"], false); + assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:")); + assert_ne!(p.edit, p.view); + // Bo joins with the edit link, Cy with the view link. + let mut b = join(&p.edit, "Bo"); + let mut c = join(&p.view, "Cy"); + pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2); + assert_eq!(b.live_summary()["access"], "edit"); + assert_eq!(c.live_summary()["access"], "view"); + assert_eq!(c.live_summary()["readOnly"], true); + assert!(same(a.doc().unwrap(), b.doc().unwrap()) && same(a.doc().unwrap(), c.doc().unwrap())); + // Ada and Bo edit; everyone converges, Cy included. + let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap()); + pump(&mut [&mut a, &mut b, &mut c], "Ada's rectangle at Bo", |s| s[1].doc().unwrap().contains(rect)); + b.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Card"}})).unwrap(); + pump(&mut [&mut a, &mut b, &mut c], "everyone agreeing", |s| { + let (ad, bd, cd) = (s[0].doc().unwrap(), s[1].doc().unwrap(), s[2].doc().unwrap()); + same(ad, bd) && same(ad, cd) && cd.node(rect).is_some_and(|n| n.name == "Card") + }); + // Cy can't change anything: commands and gestures are refused before anything is sent. + let before = c.doc().unwrap().clone(); + let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string(); + assert!(e.contains("View only"), "{e}"); + assert!(c.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Mine"}})).is_err()); + assert!(c.begin_interaction("Move").is_err()); + assert!(same(&before, c.doc().unwrap())); + // Cy is still seen: presence goes both ways. + let cy = c.live.info.as_ref().unwrap().me; + c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap(); + pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0)); + // A client that skips the app's check is refused by the server. + let view = Invite::parse(&p.view).unwrap(); + let mut raw = Raw::connect(addr); + raw.send(&Client::hello(&view.file, &view.token, "Raw", "")); + let welcome = loop { + if let w @ ServerMsg::Welcome { .. } = raw.next() { + break w; + } + }; + let mut client = Client::join(welcome).unwrap(); + assert_eq!(client.access(), Access::View); + let mut d = client.document().clone(); + d.node_mut(rect).unwrap().name = "Sneaky".into(); + raw.send(&client.edit(&d).unwrap().unwrap()); + loop { + if let ServerMsg::Reject { reason, .. } = raw.next() { + assert_eq!(reason, VIEW_ONLY); + break; + } + } + pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card")); + // Leaving keeps a local copy. + c.execute("collab.leave", &json!({})).unwrap(); + assert_eq!(c.live.status, Status::Off); + assert!(c.doc().unwrap().contains(rect)); + assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit"); + c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + server.stop(); +} + +#[test] +fn a_restart_mid_session_loses_no_confirmed_edit() { + let dir = TempDir::new("restart"); + let mut cfg = config(dir.path()); + let mut server = crate::start(&cfg).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + let p = publish(&mut a, dir.path(), addr); + let mut b = join(&p.edit, "Bo"); + pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1); + let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect)); + let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me); + // The server stops: both reconnect, and what Ada makes meanwhile waits. + server.stop(); + pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. }))); + let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap()); + cfg.listen = addr.to_string(); + let mut server = start_again(&cfg); + pump(&mut [&mut a, &mut b], "everyone back with everything", |s| { + s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect) + }); + assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself"); + assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo"); + assert!(same(a.doc().unwrap(), b.doc().unwrap())); + // Someone new gets it all from the restarted server. + let mut c = join(&p.view, "Cy"); + pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2])); + assert!(same(a.doc().unwrap(), c.doc().unwrap())); + server.stop(); +} + +/// Start a server on `cfg.listen` again (the port may take a moment to come free). +fn start_again(cfg: &crate::config::Config) -> crate::Running { + let start = std::time::Instant::now(); + loop { + match crate::start(cfg) { + Ok(s) => return s, + Err(e) if start.elapsed() < Duration::from_secs(10) && e.contains("can't listen") => std::thread::sleep(Duration::from_millis(50)), + Err(e) => panic!("{e}"), + } + } +} + +#[test] +fn revoked_links_stop_working_at_once() { + let dir = TempDir::new("revoke"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + let p = publish(&mut a, dir.path(), addr); + let file = Invite::parse(&p.edit).unwrap().file; + let endpoint = Endpoint::plain(addr.to_string()); + let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0); + let AdminReply::Link(review) = ask(Admin::CreateLink { file: file.clone(), access: Access::View, label: "Review".into() }) else { panic!() }; + let link = Invite { server: endpoint.clone(), file: file.clone(), token: review.token.clone() }.link(); + let mut b = join(&p.edit, "Bo"); + let mut c = join(&link, "Cy"); + pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x))); + let AdminReply::Files(files) = ask(Admin::Files) else { panic!() }; + assert_eq!((files.len(), files[0].people), (1, 3)); + // Revoked: Cy is out at once, the others stay. + assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done); + pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| { + matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1 + }); + assert!(live(&b)); + // The link no longer joins. + c.execute("collab.join", &json!({"link": link})).unwrap(); + pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid"))); + // Managing needs the key. + let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err(); + assert!(e.contains("wrong server key"), "{e}"); + // Removing the file ends it for everyone in it. + assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done); + pump(&mut [&mut a, &mut b], "everyone out", |s| { + s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed"))) + }); + server.stop(); +} + +#[test] +fn fuzzed_frames_dont_hurt_the_server() { + let dir = TempDir::new("fuzz"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + let p = publish(&mut a, dir.path(), addr); + let edit = Invite::parse(&p.edit).unwrap(); + let valid: Vec> = [ + Client::hello(&edit.file, &edit.token, "Fuzz", ""), + ClientMsg::Manage(message::Manage { protocol: message::PROTOCOL, key: key(dir.path()), client_version: String::new() }), + ClientMsg::Admin(Admin::Links { file: "0000000000000000".into() }), + ClientMsg::Ping, + ] + .iter() + .map(|m| message::encode(m).unwrap()) + .collect(); + let mut rng = Rng::new(11); + for round in 0..80 { + let Ok(s) = TcpStream::connect(addr) else { continue }; + let _ = s.set_read_timeout(Some(Duration::from_millis(200))); + let Ok((mut ws, _)) = tungstenite::client::client(format!("ws://{addr}/"), s) else { continue }; + for _ in 0..1 + rng.below(3) { + let mut bytes = rng.pick(&valid).cloned().unwrap_or_default(); + if round % 5 == 0 { + bytes = vec![0x5A; 100_000]; + } + for _ in 0..1 + rng.below(4) { + if bytes.is_empty() { + break; + } + let i = rng.below(bytes.len()); + match rng.below(3) { + 0 => bytes[i] = rng.next_u64() as u8, + 1 => bytes.insert(i, rng.next_u64() as u8), + _ => bytes.truncate(i.max(1)), + } + } + let frame = + if round % 7 == 0 { Message::Text(String::from_utf8_lossy(&bytes).into_owned().into()) } else { Message::Binary(Bytes::from(bytes)) }; + if ws.send(frame).is_err() { + break; + } + } + let _ = ws.read(); + } + // Still serving, the file intact. + let mut b = join(&p.edit, "Bo"); + pump(&mut [&mut a, &mut b], "Bo in after the noise", |s| live(s[1]) && live(s[0])); + assert!(same(a.doc().unwrap(), b.doc().unwrap())); + server.stop(); +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs new file mode 100644 index 0000000..9038980 --- /dev/null +++ b/apps/figcraft-server/src/store.rs @@ -0,0 +1,615 @@ +//! The data directory: the files a server keeps, their share links and the server key, and the +//! [`Backend`] the hub serves them through. +//! +//! ```text +//! /LOCK held while a server (or a command changing the directory) uses it +//! /server.json where the running server listens (for the command line) +//! /server.key the server key: managing files (publish, links, removal) +//! /files//meta.json name, created, updated, sequence number +//! /files//links.json share links: id, access, label, created, SHA-256 of the token +//! /files//snapshot.bin the session state at the last compaction +//! /files//log.bin what changed since (see `disk`) +//! ``` +//! +//! An open file appends its server's journal to its log; the hub has the store make the appends +//! durable before anyone hears of them. After [`StoreConfig::compact_after`] entries or +//! [`StoreConfig::compact_bytes`] of log, when everyone has left, and when the server stops, the +//! state is written as the next snapshot and the log starts over. Link tokens are stored only as +//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported, +//! never deleted. + +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink}; +use figcraft_collab::{Change, Replica, Server, same_secret}; +use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile}; +use figcraft_collab_net::token; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::disk::{self, LINKS, LOG, LOG_HEADER, META, SNAPSHOT}; + +pub const LOCK: &str = "LOCK"; +pub const KEY: &str = "server.key"; +pub const FILES: &str = "files"; + +/// What the store enforces. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StoreConfig { + pub max_files: usize, + pub max_file_bytes: u64, + pub max_links: usize, + pub compact_after: usize, + pub compact_bytes: u64, + pub core: figcraft_collab::Limits, +} + +/// A file's description (`meta.json`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct Meta { + name: String, + created: u64, + updated: u64, + #[serde(default)] + seq: u64, +} + +/// A share link (`links.json`): the token itself is never stored. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct Link { + id: String, + access: Access, + label: String, + created: u64, + /// SHA-256 of the token, hex. + hash: String, +} + +impl Link { + fn info(&self, file: &str) -> LinkInfo { + LinkInfo { id: self.id.clone(), file: file.to_string(), access: self.access, label: self.label.clone(), created: self.created } + } +} + +/// An open file's log. +struct Log { + generation: u64, + file: File, + /// Records not written yet (written and synced at the next flush). + pending: Vec, + /// Entries and bytes since the last compaction. + entries: usize, + bytes: u64, + /// Changed since the last compaction. + dirty: bool, +} + +struct Entry { + meta: Meta, + links: Vec, + /// Open: its log. + log: Option, +} + +/// The data directory in use (it holds the directory's lock until dropped). +pub struct Store { + root: PathBuf, + cfg: StoreConfig, + key: String, + files: BTreeMap, + /// What was skipped at startup or found damaged since, for the operator. + problems: Vec, + _lock: File, +} + +/// Seconds since 1970. +pub fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +/// SHA-256 of a token, hex. +pub fn hash(token: &str) -> String { + Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() +} + +/// A file or link id as this store makes them (and nothing a path could misuse). +fn valid_id(s: &str) -> bool { + (1..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// A name others may see: trimmed, no control characters, at most 256 characters. +fn clean_name(s: &str) -> String { + s.trim().chars().filter(|c| !c.is_control()).take(256).collect() +} + +/// Take the data directory's lock: `None` when someone else holds it. +pub fn lock(root: &Path) -> Result, String> { + fs::create_dir_all(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?; + let f = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(root.join(LOCK)) + .map_err(|e| format!("can't open {}: {e}", root.join(LOCK).display()))?; + match f.try_lock() { + Ok(()) => Ok(Some(f)), + Err(TryLockError::WouldBlock) => Ok(None), + Err(TryLockError::Error(e)) => Err(format!("can't lock the data directory: {e}")), + } +} + +/// Read the server key, or make one. +fn load_key(root: &Path) -> Result { + let path = root.join(KEY); + match fs::read_to_string(&path) { + Ok(k) if valid_id(k.trim()) && k.trim().len() >= 16 => Ok(k.trim().to_string()), + Ok(_) => Err(format!("{} isn't a server key (delete it to make a new one)", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => write_key(root), + Err(e) => Err(format!("can't read {}: {e}", path.display())), + } +} + +/// Make a new server key (only the operator's account can read it on Unix). +fn write_key(root: &Path) -> Result { + let key = token::secret()?; + let path = root.join(KEY); + disk::write_atomic(&path, format!("{key}\n").as_bytes()).map_err(|e| format!("can't write {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); + } + Ok(key) +} + +impl Store { + /// Use the data directory `root` (made if missing). Fails when another server or command holds + /// it. Files that can't be read are skipped and listed in [`problems`](Self::problems). + pub fn open(root: &Path, cfg: StoreConfig) -> Result { + Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display())) + } + + /// [`open`](Self::open), or `None` when someone else holds the directory. + pub fn try_open(root: &Path, cfg: StoreConfig) -> Result, String> { + let Some(lock) = lock(root)? else { return Ok(None) }; + let key = load_key(root)?; + let dir = root.join(FILES); + fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; + let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), problems: Vec::new(), _lock: lock }; + store.scan(&dir); + Ok(Some(store)) + } + + fn scan(&mut self, dir: &Path) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())), + }; + let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect(); + ids.sort(); + for (id, path) in ids { + if !path.is_dir() { + continue; + } + if !valid_id(&id) { + self.problems.push(format!("skipped {}: not a file folder of this server", path.display())); + continue; + } + if self.files.len() >= self.cfg.max_files { + self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files)); + continue; + } + match self.check(&path) { + Ok(entry) => { + self.files.insert(id, entry); + } + Err(why) => self.problems.push(format!("skipped file {id}: {why}")), + } + } + } + + /// A file folder's description and links, after checking its snapshot and log. Leftover + /// temporary files (writes cut short) are removed. + fn check(&self, path: &Path) -> Result { + for name in [SNAPSHOT, LOG, META, LINKS] { + let tmp = disk::temp_of(&path.join(name)); + if tmp.exists() { + let _ = fs::remove_file(&tmp); + } + } + let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?; + let links: Vec = + serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?; + let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?; + // A damaged log only loses its end; it is reported when the file opens. + if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes) + && let Ok(log) = disk::read_log(&bytes) + && log.generation != generation + { + log::info!("file at {}: its log predates the last compaction; it is ignored", path.display()); + } + Ok(Entry { meta, links, log: None }) + } + + /// The server key (for managing files). + pub fn key(&self) -> &str { + &self.key + } + + /// Replace the server key; the new one. + pub fn rotate_key(&mut self) -> Result { + self.key = write_key(&self.root)?; + Ok(self.key.clone()) + } + + /// What was skipped or found damaged. + pub fn problems(&self) -> &[String] { + &self.problems + } + + /// How many files are kept. + pub fn len(&self) -> usize { + self.files.len() + } + + /// No files are kept. + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } + + fn dir(&self, id: &str) -> PathBuf { + self.root.join(FILES).join(id) + } + + fn entry(&mut self, id: &str) -> Result<&mut Entry, String> { + self.files.get_mut(id).ok_or_else(|| "this file isn't on the server (it may have been removed)".to_string()) + } + + fn info(&self, id: &str, e: &Entry, open: Option) -> FileInfo { + let dir = self.dir(id); + let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(dir.join(n)).ok()).map(|m| m.len()).sum(); + FileInfo { + id: id.to_string(), + name: e.meta.name.clone(), + created: e.meta.created, + updated: e.meta.updated, + seq: open.map_or(e.meta.seq, |o| o.seq), + bytes, + people: open.map_or(0, |o| o.people), + } + } + + /// Keep a document (its snapshot) as a new file with an edit link and a view link. + pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { + if self.files.len() >= self.cfg.max_files { + return Err(format!("the server keeps at most {} files", self.cfg.max_files)); + } + let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; + if replica.document().node_count() > self.cfg.core.max_nodes { + return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); + } + let mut doc = replica.document().clone(); + let name = match clean_name(name) { + n if n.is_empty() => doc.name.clone(), + n => n, + }; + doc.name = name.clone(); + let server = Server::new(doc, "", self.cfg.core); + let state = server.state().map_err(|e| e.to_string())?; + let bytes = disk::encode_snapshot(1, &state)?; + if bytes.len() as u64 > self.cfg.max_file_bytes { + return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); + } + let id = loop { + let id = token::file_id()?; + if !self.files.contains_key(&id) && !self.dir(&id).exists() { + break id; + } + }; + let t = now(); + let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; + let (view, view_token) = new_link(Access::View, "Published (view)", t)?; + let meta = Meta { name, created: t, updated: t, seq: 0 }; + let dir = self.dir(&id); + let written = fs::create_dir_all(&dir) + .map_err(|e| e.to_string()) + .and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())) + .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) + .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) + // Last: a folder without its description is an upload cut short. + .and_then(|()| write_json(&dir.join(META), &meta)); + if let Err(e) = written { + let _ = fs::remove_dir_all(&dir); + return Err(format!("couldn't keep the file: {e}")); + } + let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None }; + let info = self.info(&id, &entry, None); + self.files.insert(id.clone(), entry); + log::info!("published file {id} ({} bytes)", bytes.len()); + Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token })) + } + + /// The files kept. + pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { + self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() + } + + /// A file's links. + pub fn links(&mut self, file: &str) -> Result, String> { + Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) + } + + /// A new link to a file. + pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { + let max = self.cfg.max_links; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + if entry.links.len() >= max { + return Err(format!("a file has at most {max} links: revoke one first")); + } + let (link, token) = new_link(access, &clean_name(label), now())?; + entry.links.push(link.clone()); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.pop(); + return Err(format!("couldn't keep the link: {e}")); + } + Ok(NewLink { link: link.info(file), token }) + } + + /// Revoke a link. + pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; + let gone = entry.links.remove(at); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.insert(at, gone); + return Err(format!("couldn't revoke the link: {e}")); + } + log::info!("revoked link {link} of file {file}"); + Ok(()) + } + + /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no + /// longer served either way). + pub fn remove(&mut self, file: &str) -> Result<(), String> { + // Closed first: an open log would keep the folder from being deleted on Windows. + let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; + drop(entry); + let dir = self.dir(file); + fs::remove_dir_all(&dir) + .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; + log::info!("removed file {file}"); + Ok(()) + } + + /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). + fn load(&mut self, file: &str) -> Result { + let dir = self.dir(file); + let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?; + let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; + let mut issues = Vec::new(); + let mut fresh_log = true; + let mut kept = 0; + match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) { + Ok(log) if log.generation == generation => { + fresh_log = false; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); + fresh_log = true; + break; + } + kept += 1; + } + if let Some(why) = log.damaged { + issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); + fresh_log = true; + } + } + // From before a compaction that was cut short: the snapshot has it all. + Ok(_) => {} + Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), + } + for i in issues { + log::warn!("{i}"); + self.problems.push(i); + } + server.journaling(true); + let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); + let log = match (fresh_log, file_handle) { + (false, Ok(f)) => { + let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); + // Replayed entries make it worth compacting once everyone has left. + Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } + } + // A new snapshot and log of the next generation, so appends start clean. + _ => self.rewrite(file, generation + 1, &server)?, + }; + self.entry(file)?.log = Some(log); + Ok(server) + } + + /// Write `server`'s state as snapshot `generation` and start an empty log of it. + fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { + let dir = self.dir(file); + let state = server.state().map_err(|e| e.to_string())?; + let bytes = disk::encode_snapshot(generation, &state)?; + if bytes.len() as u64 > self.cfg.max_file_bytes { + log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); + } + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?; + disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?; + let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?; + if let Ok(e) = self.entry(file) { + e.meta.seq = server.seq(); + let meta = e.meta.clone(); + if let Err(err) = write_json(&dir.join(META), &meta) { + log::warn!("file {file}: couldn't update {META}: {err}"); + } + } + Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) + } + + /// Compact an open file: its state as the next snapshot, an empty log. + fn compact(&mut self, file: &str, server: &Server) { + let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; + match self.rewrite(file, generation + 1, server) { + Ok(log) => { + if let Ok(e) = self.entry(file) { + e.log = Some(log); + } + log::debug!("compacted file {file}"); + } + // The old snapshot and log still hold everything: carry on appending. + Err(e) => log::error!("file {file}: compaction failed: {e}"), + } + } + + /// Write and sync what's pending in every open log. + fn flush_all(&mut self) { + for (id, e) in &mut self.files { + if let Some(log) = e.log.as_mut() { + flush_log(id, log); + } + } + } +} + +/// Write and sync a log's pending records (kept for the next try when that fails). +fn flush_log(id: &str, log: &mut Log) { + if log.pending.is_empty() { + return; + } + match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { + Ok(()) => { + log.bytes += log.pending.len() as u64; + log.pending.clear(); + } + Err(e) => log::error!("file {id}: couldn't write its log: {e}"), + } +} + +fn write_json(path: &Path, value: &T) -> Result<(), String> { + let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; + disk::write_atomic(path, &text).map_err(|e| e.to_string()) +} + +/// A new link and its token. +fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> { + let token = token::secret()?; + Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token)) +} + +impl Backend for Store { + fn admit(&mut self, file: &str, token: &str) -> Result { + let entry = self.entry(file)?; + let presented = hash(token); + // Every link is compared (in constant time), so timing doesn't tell which one matched. + let mut found = None; + for l in &entry.links { + if same_secret(&presented, &l.hash) { + found = Some(Admission { access: l.access, link: Some(l.id.clone()) }); + } + } + found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) + } + + fn open(&mut self, file: &str) -> Result { + self.load(file).map_err(|e| { + let why = format!("file {file} can't be opened: {e}"); + log::error!("{why}"); + self.problems.push(why); + "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() + }) + } + + fn changed(&mut self, file: &str, server: &mut Server) { + let journal = server.take_journal(); + if journal.is_empty() { + return; + } + let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); + let Some(e) = self.files.get_mut(file) else { return }; + let Some(log) = e.log.as_mut() else { return }; + for j in &journal { + match disk::record(j) { + Ok(r) => log.pending.extend(r), + Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), + } + } + log.entries += journal.len(); + log.dirty = true; + e.meta.updated = now(); + if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { + // The snapshot holds what's pending too. + self.compact(file, server); + } + } + + fn flush(&mut self) { + self.flush_all(); + } + + fn idle(&mut self, file: &str, server: &mut Server) -> bool { + let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| { + flush_log(file, log); + log.dirty + }); + if dirty { + self.compact(file, server); + } + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + true + } + + fn stop(&mut self, open: Vec<(&str, &mut Server)>) { + self.flush_all(); + for (file, server) in open { + if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { + self.compact(file, server); + } + } + } + + fn manage(&mut self, key: &str) -> Result<(), String> { + if same_secret(key, &self.key) { Ok(()) } else { Err("wrong server key".into()) } + } + + fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); + match req { + Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { + Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), + Err(e) => failed(e), + }, + Admin::Files => AdminReply::Files(self.list(open)).into(), + Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), + Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), + Admin::Revoke { file, link } => match self.revoke(&file, &link) { + Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None }, + Err(e) => failed(e), + }, + Admin::Remove { file } => { + if !self.files.contains_key(&file) { + return failed("this file isn't on the server (it may have been removed)".into()); + } + let reply = match self.remove(&file) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + }; + Handled { reply, revoked: None, removed: Some(file) } + } + } + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs new file mode 100644 index 0000000..7ca0e56 --- /dev/null +++ b/apps/figcraft-server/src/store_tests.rs @@ -0,0 +1,271 @@ +//! The data directory on its own: publishing, links and their hashes, persistence across a crash +//! (snapshot plus log) and compaction, damaged files skipped and reported, caps, the lock, and +//! temporary directories cleaned up. + +use std::fs; + +use figcraft_collab::message::{Access, ClientMsg}; +use figcraft_collab::sim::Rng; +use figcraft_collab::{Client, Outgoing, Replica, Server}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::{Document, NodeKind}; + +use crate::disk::{self, LINKS, LOG, META, SNAPSHOT}; +use crate::store::{FILES, Store, StoreConfig, hash}; +use crate::test_util::{TempDir, config}; + +fn cfg(dir: &std::path::Path) -> StoreConfig { + config(dir).store() +} + +fn doc(name: &str) -> Document { + let mut d = Document::new(name); + let page = d.first_page().unwrap(); + d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap(); + d.repair(); + d +} + +fn snapshot(d: Document) -> Vec { + Replica::new(d).snapshot().unwrap() +} + +/// Publish `name`; its id and the edit and view tokens. +fn publish(store: &mut Store, name: &str) -> (String, String, String) { + let (info, edit, view) = store.publish(name, &snapshot(doc(name))).unwrap(); + assert_eq!(edit.link.access, Access::Edit); + assert_eq!(view.link.access, Access::View); + (info.id, edit.token, view.token) +} + +/// A client in `server` (as the hub would let it in) and what it was told. +fn join(server: &mut Server, access: Access) -> Client { + let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() }; + let (_, out) = server.join_as(h, access).unwrap(); + let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() }; + Client::join(w).unwrap() +} + +/// Rename the first page through `c`; the server takes the batch. +fn rename(server: &mut Server, c: &mut Client, name: &str) { + let mut d = c.document().clone(); + let page = d.first_page().unwrap(); + d.node_mut(page).unwrap().name = name.into(); + let m = c.edit(&d).unwrap().unwrap(); + for o in server.receive(c.id(), m) { + if let Outgoing::All(m) | Outgoing::To(_, m) = o { + c.receive(m, &figcraft_collab::NoRecompute).unwrap(); + } + } +} + +#[test] +fn files_links_and_access() { + let dir = TempDir::new("links"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + assert!(store.is_empty()); + let (id, edit, view) = publish(&mut store, "Café ☕"); + let files = store.list(&|_| None); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "Café ☕"); + assert!(files[0].bytes > 0); + // Each link lets in with its access; anything else doesn't. + assert_eq!(store.admit(&id, &edit).unwrap().access, Access::Edit); + assert_eq!(store.admit(&id, &view).unwrap().access, Access::View); + assert!(store.admit(&id, "WrongWrongWrongWrong00").unwrap_err().contains("isn't valid")); + assert!(store.admit("0000000000000000", &edit).unwrap_err().contains("isn't on the server")); + // Tokens are kept only as hashes. + let on_disk = fs::read_to_string(dir.path().join(FILES).join(&id).join(LINKS)).unwrap(); + assert!(!on_disk.contains(&edit) && !on_disk.contains(&view)); + assert!(on_disk.contains(&hash(&edit)) && on_disk.contains(&hash(&view))); + // More links; revoking one stops it, the others keep working. + let review = store.create_link(&id, Access::View, " Review\n").unwrap(); + assert_eq!(review.link.label, "Review"); + assert_eq!(store.links(&id).unwrap().len(), 3); + assert_eq!(store.admit(&id, &review.token).unwrap().link.as_deref(), Some(review.link.id.as_str())); + store.revoke(&id, &review.link.id).unwrap(); + assert!(store.admit(&id, &review.token).is_err()); + assert!(store.admit(&id, &view).is_ok()); + assert!(store.revoke(&id, &review.link.id).is_err(), "already gone"); + // Removed for good. + store.remove(&id).unwrap(); + assert!(store.is_empty()); + assert!(!dir.path().join(FILES).join(&id).exists()); + assert!(store.admit(&id, &edit).is_err()); +} + +#[test] +fn a_file_survives_a_crash_and_compacts() { + let dir = TempDir::new("crash"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let (id, before, seq) = { + let mut store = Store::open(dir.path(), c).unwrap(); + let (id, _, _) = publish(&mut store, "Crash"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..5 { + rename(&mut server, &mut a, &format!("Page {i}")); + store.changed(&id, &mut server); + } + store.flush(); + // A crash: no compaction, nothing else written. + (id, server.replica().clone(), server.seq()) + }; + let mut store = Store::open(dir.path(), c).unwrap(); + assert!(store.problems().is_empty(), "{:?}", store.problems()); + let mut server = store.open(&id).unwrap(); + assert_eq!(server.replica(), &before, "the log brought it back"); + assert_eq!(server.seq(), seq); + // Compaction: everyone left; the snapshot has it all and the log starts over. + assert!(store.idle(&id, &mut server)); + let snap = disk::decode_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap(); + assert_eq!(snap.1.seq, seq); + let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap(); + assert_eq!(log.generation, snap.0); + assert!(log.entries.is_empty()); + assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows"); +} + +#[test] +fn a_long_log_is_compacted_while_open() { + let dir = TempDir::new("compact"); + let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) }; + let mut store = Store::open(dir.path(), c).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + let generation = || disk::check_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap(); + assert_eq!(generation(), 1); + for i in 0..3 { + rename(&mut server, &mut a, &format!("P{i}")); + store.changed(&id, &mut server); + store.flush(); + } + assert_eq!(generation(), 2, "compacted after three entries"); + rename(&mut server, &mut a, "After"); + store.changed(&id, &mut server); + store.flush(); + drop(store); + let mut store = Store::open(dir.path(), c).unwrap(); + let restored = store.open(&id).unwrap(); + assert_eq!(restored.replica(), server.replica()); +} + +#[test] +fn damaged_files_are_skipped_and_reported() { + let dir = TempDir::new("damaged"); + let (bad_snapshot, bad_meta, cut_log, good) = { + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let ids: Vec = ["A", "B", "C", "D"].iter().map(|n| publish(&mut store, n).0).collect(); + // C gets a log with entries. + let mut server = store.open(&ids[2]).unwrap(); + let mut c = join(&mut server, Access::Edit); + store.changed(&ids[2], &mut server); + rename(&mut server, &mut c, "Kept"); + store.changed(&ids[2], &mut server); + rename(&mut server, &mut c, "Lost"); + store.changed(&ids[2], &mut server); + store.flush(); + (ids[0].clone(), ids[1].clone(), ids[2].clone(), ids[3].clone()) + }; + let folder = |id: &str| dir.path().join(FILES).join(id); + // A: a flipped byte in its snapshot; B: garbage description; C: its log cut mid-record; + // D: a write that was cut short left a temporary file. + let mut snap = fs::read(folder(&bad_snapshot).join(SNAPSHOT)).unwrap(); + let last = snap.len() - 1; + snap[last] ^= 0xFF; + fs::write(folder(&bad_snapshot).join(SNAPSHOT), snap).unwrap(); + fs::write(folder(&bad_meta).join(META), b"{not json").unwrap(); + let log = fs::read(folder(&cut_log).join(LOG)).unwrap(); + fs::write(folder(&cut_log).join(LOG), &log[..log.len() - 3]).unwrap(); + fs::write(disk::temp_of(&folder(&good).join(SNAPSHOT)), b"half").unwrap(); + fs::create_dir_all(dir.path().join(FILES).join("not a file id")).unwrap(); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let problems = store.problems().join("\n"); + assert!(problems.contains(&bad_snapshot) && problems.contains("checksum"), "{problems}"); + assert!(problems.contains(&bad_meta) && problems.contains(META), "{problems}"); + assert!(problems.contains("not a file id"), "{problems}"); + assert_eq!(store.len(), 2, "C and D are served"); + // Nothing is deleted. + assert!(folder(&bad_snapshot).exists() && folder(&bad_meta).exists()); + assert!(!disk::temp_of(&folder(&good).join(SNAPSHOT)).exists(), "the leftover is gone"); + // C opens without the cut-short record, and says so. + let server = store.open(&cut_log).unwrap(); + let page = server.document().first_page().unwrap(); + assert_eq!(server.document().node(page).unwrap().name, "Kept"); + assert!(store.problems().iter().any(|p| p.contains(&cut_log) && p.contains("cut short"))); + // Damage after startup: the file can't be opened, with a reason for the person joining. + fs::write(folder(&good).join(SNAPSHOT), b"FCSN garbage").unwrap(); + assert!(store.open(&good).unwrap_err().contains("can't be opened")); + assert!(store.problems().iter().any(|p| p.contains(&good))); +} + +#[test] +fn caps_hold() { + let dir = TempDir::new("caps"); + let c = StoreConfig { max_files: 1, max_links: 3, ..cfg(dir.path()) }; + let mut store = Store::open(dir.path(), c).unwrap(); + let (id, _, _) = publish(&mut store, "One"); + assert!(store.publish("Two", &snapshot(doc("Two"))).unwrap_err().contains("at most 1")); + store.create_link(&id, Access::View, "").unwrap(); + assert!(store.create_link(&id, Access::View, "").unwrap_err().contains("at most 3")); + let dir2 = TempDir::new("caps-size"); + let mut tiny = Store::open(dir2.path(), StoreConfig { max_file_bytes: 64, ..cfg(dir2.path()) }).unwrap(); + assert!(tiny.publish("Big", &snapshot(doc("Big"))).unwrap_err().contains("MiB")); + assert!(tiny.publish("Junk", &[figcraft_collab::Change::Delete { id: figcraft_doc::NodeId(1) }]).is_err()); +} + +#[test] +fn one_server_per_data_directory() { + let dir = TempDir::new("lock"); + let first = Store::open(dir.path(), cfg(dir.path())).unwrap(); + assert!(Store::open(dir.path(), cfg(dir.path())).err().unwrap().contains("is using")); + assert!(Store::try_open(dir.path(), cfg(dir.path())).unwrap().is_none()); + let key = first.key().to_string(); + drop(first); + let again = Store::open(dir.path(), cfg(dir.path())).unwrap(); + assert_eq!(again.key(), key, "the key is kept"); +} + +#[test] +fn temporary_directories_are_removed() { + let path = { + let dir = TempDir::new("cleanup"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + publish(&mut store, "Gone"); + drop(store); + dir.path().to_path_buf() + }; + assert!(!path.exists()); +} + +#[test] +fn random_bytes_never_panic_the_readers() { + let mut rng = Rng::new(7); + let state = Server::new(doc("Fuzz"), "", figcraft_collab::Limits::default()).state().unwrap(); + let snap = disk::encode_snapshot(3, &state).unwrap(); + let mut log = disk::log_header(3); + log.extend(disk::record(&figcraft_collab::Journal::Issued { client: 9 }).unwrap()); + assert_eq!(disk::decode_snapshot(&snap).unwrap().1, state); + assert_eq!(disk::read_log(&log).unwrap().entries.len(), 1); + for round in 0..5_000 { + let base = if round % 2 == 0 { &snap } else { &log }; + let mut bytes = base.clone(); + for _ in 0..1 + rng.below(4) { + if bytes.is_empty() { + break; + } + let i = rng.below(bytes.len()); + match rng.below(3) { + 0 => bytes[i] = rng.next_u64() as u8, + 1 => bytes.insert(i, rng.next_u64() as u8), + _ => bytes.truncate(i), + } + } + let _ = disk::decode_snapshot(&bytes); + let _ = disk::check_snapshot(&bytes); + let _ = disk::read_log(&bytes); + } +} diff --git a/apps/figcraft-server/src/test_util.rs b/apps/figcraft-server/src/test_util.rs new file mode 100644 index 0000000..6127678 --- /dev/null +++ b/apps/figcraft-server/src/test_util.rs @@ -0,0 +1,83 @@ +//! What the tests share: temporary data directories (removed when dropped), settings, and engine +//! sessions linked over loopback. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use figcraft_collab_net::NetLink; +use figcraft_collab_net::guest::GuestConfig; +use figcraft_engine::Session; +use serde_json::json; + +use crate::config::Config; + +/// A directory of its own under the system's temporary directory, removed with everything in it +/// when dropped. +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> TempDir { + static NEXT: AtomicU32 = AtomicU32::new(0); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join("figcraft-server-tests").join(format!("{tag}-{}-{nanos}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + + pub fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Settings for a server on `dir`, listening on a free loopback port. +pub fn config(dir: &Path) -> Config { + Config { listen: "127.0.0.1:0".into(), data: dir.to_path_buf(), ..Config::default() } +} + +/// Joins quickly, and keeps trying long enough to outlast a server restart. +pub fn quick(mut c: GuestConfig) -> GuestConfig { + c.backoff = Duration::from_millis(20); + c.max_backoff = Duration::from_millis(200); + c.attempts = 100; + c.limits.hello_timeout = Duration::from_secs(5); + c +} + +/// An engine session with the network link, named `name`. +pub fn session(name: &str) -> Session { + let mut s = Session::new(); + s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick))); + s.execute("pref.userName", &json!({"name": name})).unwrap(); + s +} + +/// Pump every session (as their windows would each frame) until `f` holds. +pub fn pump(sessions: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) { + let start = Instant::now(); + loop { + for s in sessions.iter_mut() { + s.live_poll(); + s.live_sync(); + } + if f(sessions) { + return; + } + let states: Vec = sessions.iter().map(|s| s.live_summary().to_string()).collect(); + assert!(start.elapsed() < Duration::from_secs(20), "timed out waiting for {what}: {states:#?}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// The synced properties of two documents agree. +pub fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool { + figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty()) +} diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index 5be4523..26a9865 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -139,7 +139,7 @@ impl Driver { pub(crate) fn closed(&mut self, code: u16, reason: &str, now: Instant) { let Some(opened) = self.conn.as_ref().map(|c| c.opened) else { return }; let outcome = match closed(code, reason) { - Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)), + Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), Outcome::Retry(_) if code == 1006 => Outcome::Retry("the connection dropped".into()), o => o, }; @@ -206,7 +206,7 @@ impl Driver { let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent); if !opened { if now >= started + self.cfg.connect_timeout { - self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)), now); + self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now); } return; } diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index 6e5a791..ca6563c 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -57,7 +57,7 @@ impl Socket for Script { } fn invite() -> Invite { - Invite { host: "127.0.0.1:47900".into(), file: FILE.into(), token: TOKEN.into() } + Invite::new("127.0.0.1:47900", FILE, TOKEN) } fn start(sock: Script, t0: Instant) -> (Driver