- 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