diff --git a/README.md b/README.md
index 6c1d43d..3431d15 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, boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes and per-side strokes. Rectangles with smooth (squircle) corners, polygons, stars and arcs.
+ 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 and per-side strokes. Rectangles with smooth (squircle) corners, polygons, stars and arcs.
|
Paints and effects
diff --git a/ROADMAP.md b/ROADMAP.md
index 2bb145f..83adfee 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -42,8 +42,9 @@ dimension measured side by side.
pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any
closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and
length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and
- merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E). *Left:* variable
- width strokes, shape builder, transform box for several points, point snapping. 12–18 h.
+ merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder
+ (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept).
+ *Left:* variable width strokes, transform box for several points, point snapping. 10–15 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
@@ -97,7 +98,7 @@ dimension measured side by side.
### Where we're going
- **Next:** hardening and performance (9) with the fidelity pass (7); the rest of vector editing
- (eraser, shape builder, variable width) and components (nested overrides, libraries).
+ (variable width) and components (nested overrides, libraries).
- **Then:** interop depth (6: .fig best effort, paste from other apps), version history and
collaboration (8).
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
@@ -147,7 +148,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Canvas & navigation | 6 | 65% | pixel preview, frame guides, perf at scale | 6–10 |
| Selection & transform | 8 | 64% | scale tool polish, measure hover polish | 8–12 |
| Layers panel | 5 | 76% | keyboard row navigation, drag between pages | 2–4 |
-| Shapes & vector | 8 | 60% | variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 12–18 |
+| Shapes & vector | 8 | 63% | variable width, multi-point transform box, point snapping, live boolean editing, arc handles on canvas, shape builder on strokes and open paths | 10–15 |
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
index 96cdab0..bae24cf 100644
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -255,6 +255,7 @@ pub static MENU: &[MenuItem] = &[
m!("Tools", "Pencil", "tool.pencil", "Shift+P"),
m!("Tools", "Text", "tool.text", "T"),
m!("Tools", "Comment", "tool.comment", "C"),
+ m!("Tools", "Shape builder", "tool.shapeBuilder", "Shift+M"),
m!("Tools", "Actions", "app.actions", "Cmd+K"),
];
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index ce34e83..b318720 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -79,6 +79,7 @@ fn registry() -> &'static Registry {
list.extend(proto::specs());
list.extend(comment::specs());
list.extend(crate::vectoredit::specs());
+ list.extend(crate::shapebuilder::specs());
list.extend(crate::codegen::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs
index ee79c2b..6eb4946 100644
--- a/crates/engine/src/cmd/view.rs
+++ b/crates/engine/src/cmd/view.rs
@@ -181,7 +181,7 @@ pub fn specs() -> Vec {
spec!(
"tool.set",
"Choose tool",
- "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment}",
+ "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}",
false,
|s, p| {
let t = req_string("tool.set", p, "tool")?;
@@ -205,6 +205,7 @@ pub fn specs() -> Vec {
spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)),
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
+ spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
]
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 5eb9fd8..e405322 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -17,6 +17,7 @@ pub mod inspect;
pub mod library;
pub mod measure;
pub mod params;
+pub mod shapebuilder;
pub mod snap;
pub mod synthetic;
pub mod textedit;
@@ -877,6 +878,8 @@ mod rotated_select_tests;
#[cfg(test)]
mod section_tests;
#[cfg(test)]
+mod shapebuilder_tests;
+#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod tests;
diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs
new file mode 100644
index 0000000..690c911
--- /dev/null
+++ b/crates/engine/src/shapebuilder.rs
@@ -0,0 +1,465 @@
+//! The shape builder: build shapes from the overlapping regions of the selected shapes.
+//!
+//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
+//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
+//! styled like the top-most shape covering the first region; removing regions deletes them. Either
+//! 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.
+//!
+//! 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
+//! and selection, so hovering doesn't recompute it.
+
+use std::collections::BTreeSet;
+use std::sync::Arc;
+
+use figcraft_doc::*;
+use figcraft_geom::kurbo::PathEl;
+use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
+use serde_json::{Value, json};
+
+use crate::cmd::CommandSpec;
+use crate::cmd::edit::{roots, z_sorted};
+use crate::params::*;
+use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId};
+use crate::{EngineError, Result, Session, bad, spec};
+
+/// One region of the arrangement, in page coordinates.
+#[derive(Clone, Debug)]
+pub struct Region {
+ /// The region's outer contour and holes.
+ pub path: BezPath,
+ /// The shapes covering it, top-most first.
+ pub sources: Vec,
+ pub bounds: Rect,
+ /// The outline flattened to polygons (outer first), for drawing.
+ pub polys: Vec>,
+ /// A point inside the region.
+ pub point: Option,
+}
+
+/// The regions formed by a set of shapes, for one version of the document.
+#[derive(Clone, Debug)]
+pub struct Arrangement {
+ doc: Arc,
+ uid: u64,
+ ids: Vec,
+ /// The shapes taking part, bottom to top.
+ pub sources: Vec,
+ pub regions: Vec,
+}
+
+impl Arrangement {
+ /// The region under page point `p`.
+ pub fn region_at(&self, p: Point) -> Option {
+ if !(p.x.is_finite() && p.y.is_finite()) {
+ return None;
+ }
+ self.regions.iter().position(|r| r.bounds.contains(p) && r.path.winding(p) != 0)
+ }
+
+ fn is_for(&self, doc: &Arc, uid: u64, ids: &[NodeId]) -> bool {
+ self.uid == uid && Arc::ptr_eq(&self.doc, doc) && self.ids == ids
+ }
+}
+
+/// Can `id` take part? Visible, unlocked shapes, boolean groups and text with an area, 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(_));
+ kind_ok
+ && n.visible
+ && !n.locked
+ && !doc.ancestors(id).iter().any(|a| doc.node(*a).is_some_and(|x| matches!(x.kind, NodeKind::Instance { .. } | NodeKind::BooleanOp { .. })))
+}
+
+/// The shapes `ids` stand for (groups contribute their shapes), bottom to top.
+pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec {
+ fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec) {
+ let Some(n) = doc.node(id) else { return };
+ if matches!(n.kind, NodeKind::Group) && depth < 64 {
+ for c in &n.children {
+ walk(doc, *c, depth + 1, out);
+ }
+ } else if usable(doc, id) {
+ out.push(id);
+ }
+ }
+ let ids: Vec = ids.iter().copied().filter(|i| doc.contains(*i)).collect();
+ let mut out = Vec::new();
+ for id in roots(doc, &ids) {
+ walk(doc, id, 0, &mut out);
+ }
+ let mut out = z_sorted(doc, &out);
+ out.dedup();
+ out.truncate(figcraft_pathops::MAX_ARRANGEMENT_INPUTS);
+ 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));
+ (!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
+}
+
+/// Flatten a region outline to polygons for drawing (tolerance relative to its size).
+fn polygons(path: &BezPath, bounds: Rect) -> Vec> {
+ let tol = (bounds.width().max(bounds.height()) * 2e-4).clamp(0.01, 0.5);
+ let mut polys: Vec> = Vec::new();
+ figcraft_geom::kurbo::flatten(path.iter(), tol, |el| match el {
+ PathEl::MoveTo(p) => polys.push(vec![p]),
+ PathEl::LineTo(p) => {
+ if let Some(last) = polys.last_mut() {
+ last.push(p);
+ }
+ }
+ _ => {}
+ });
+ polys.retain(|p| p.len() > 2);
+ polys
+}
+
+fn compute(doc: Arc, uid: u64, ids: &[NodeId]) -> Result {
+ let mut sources = Vec::new();
+ let mut inputs = Vec::new();
+ for id in eligible(&doc, ids) {
+ if let Some(o) = world_outline(&doc, id) {
+ sources.push(id);
+ inputs.push(o);
+ }
+ }
+ let faces = figcraft_pathops::faces(&inputs).map_err(|e| EngineError::Other(format!("shape builder: {e}")))?;
+ let mut regions: Vec = faces
+ .into_iter()
+ .filter(|f| f.path.area().abs() > 1e-4)
+ .map(|f| {
+ let bounds = f.path.bounding_box();
+ Region {
+ sources: f.cover.iter().rev().filter_map(|i| sources.get(*i).copied()).collect(),
+ polys: polygons(&f.path, bounds),
+ point: figcraft_doc::vector_faces::interior_point(&f.path),
+ bounds,
+ path: f.path,
+ }
+ })
+ .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 })
+}
+
+/// The arrangement of `ids` (default: the selection), from the cache when the document and the
+/// ids haven't changed.
+pub fn arrangement(s: &mut Session, ids: &[NodeId]) -> Result> {
+ let ds = s.ds()?;
+ let (doc, uid) = (ds.doc.clone(), ds.uid);
+ if let Some(a) = s.tool.shape_builder.as_ref().filter(|a| a.is_for(&doc, uid, ids)) {
+ return Ok(a.clone());
+ }
+ let a = Arc::new(compute(doc, uid, ids)?);
+ s.tool.shape_builder = Some(a.clone());
+ Ok(a)
+}
+
+/// The cached arrangement of the selection, if it is up to date (for drawing).
+pub fn current(s: &Session) -> Option> {
+ let ds = s.ds().ok()?;
+ s.tool.shape_builder.as_ref().filter(|a| a.is_for(&ds.doc, ds.uid, &ds.selection)).cloned()
+}
+
+/// Region indices from `regions: [i, ...]` and `points: [[x, y], ...]`, first pick first.
+fn picked(p: &Value, a: &Arrangement) -> Vec {
+ let mut out: Vec = Vec::new();
+ let mut add = |i: usize| {
+ if i < a.regions.len() && !out.contains(&i) {
+ out.push(i);
+ }
+ };
+ if let Some(Value::Array(v)) = get(p, "regions") {
+ for i in v.iter().filter_map(f64_of).filter(|x| *x >= 0.0 && x.fract() == 0.0 && *x < 1e9) {
+ add(i as usize);
+ }
+ }
+ if let Some(Value::Array(v)) = get(p, "points") {
+ for q in v.iter().take(10_000) {
+ if let (Some(x), Some(y)) = (q.get(0).and_then(f64_of), q.get(1).and_then(f64_of))
+ && let Some(i) = a.region_at(Point::new(x, y))
+ {
+ add(i);
+ }
+ }
+ }
+ out
+}
+
+/// A vector network from a page-space path, mapped through `to_local`.
+fn network_from(path: &BezPath, to_local: Affine) -> Option {
+ let mut local = path.clone();
+ local.apply_affine(to_local);
+ if !figcraft_pathops::finite(&local) {
+ return None;
+ }
+ let mut net = VectorNetwork::from_path(&local);
+ for r in &mut net.regions {
+ r.winding = WindingRule::EvenOdd;
+ }
+ Some(net)
+}
+
+/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
+fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
+ let w = doc.world_transform(id);
+ if !(w.determinant().is_finite() && w.determinant().abs() > 1e-12) {
+ return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
+ }
+ let net = network_from(area, w.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
+ // A boolean group's operands go with it.
+ if matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) {
+ for c in doc.get(id)?.children.clone() {
+ doc.remove(c)?;
+ }
+ }
+ let n = doc.get_mut(id)?;
+ n.kind = NodeKind::Vector { network: net };
+ n.children.clear();
+ n.corners = Corners::default();
+ 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();
+ 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) {
+ return Err(EngineError::Other("can't place a shape in a container scaled to nothing".into()));
+ }
+ let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
+ let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
+ net.translate(-bb.x0, -bb.y0);
+ let id = doc.alloc_id();
+ let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
+ v.transform = Affine::translate((bb.x0, bb.y0));
+ v.fills = src.fills;
+ v.strokes = src.strokes;
+ v.stroke = src.stroke;
+ v.effects = src.effects;
+ v.opacity = src.opacity;
+ v.blend = if matches!(src.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { src.blend };
+ // In an auto layout frame the new shape stays over its region instead of joining the flow.
+ v.child_layout.absolute = doc.get(parent)?.layout.is_some();
+ let index = doc.index_in_parent(style).map(|i| i + 1);
+ doc.insert(v, parent, index)?;
+ Ok(id)
+}
+
+/// Merge (`merge`) or remove the picked regions. See the module docs for the rule.
+fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result {
+ let ids = targets(s, p);
+ let a = arrangement(s, &ids)?;
+ let pick = picked(p, &a);
+ if pick.is_empty() {
+ 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();
+ // 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)));
+ if merge && whole_shape {
+ return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick}));
+ }
+ 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"))?;
+ 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)) {
+ let keep: Vec<&BezPath> =
+ a.regions.iter().enumerate().filter(|(i, r)| !pick.contains(i) && r.sources.contains(&src)).map(|(_, r)| &r.path).collect();
+ if keep.is_empty() {
+ doc.remove(src)?;
+ removed.push(src);
+ continue;
+ }
+ 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);
+ } else {
+ reshape(doc, src, &area)?;
+ changed.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.
+ let mut sel: Vec = ids.iter().copied().filter(|i| !removed.contains(i)).collect();
+ 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}))
+}
+
+pub fn specs() -> Vec {
+ 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}]}",
+ false,
+ |s, p| {
+ let ids = targets(s, p);
+ let a = arrangement(s, &ids)?;
+ let regions: Vec = a
+ .regions
+ .iter()
+ .enumerate()
+ .map(|(i, r)| {
+ json!({
+ "index": i,
+ "path": r.path.to_svg(),
+ "sources": r.sources,
+ "bounds": [r.bounds.x0, r.bounds.y0, r.bounds.width(), r.bounds.height()],
+ "point": r.point.map(|q| [q.x, q.y]),
+ })
+ })
+ .collect();
+ Ok(json!({"sources": a.sources, "regions": regions}))
+ }
+ ),
+ 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",
+ 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",
+ true,
+ |s, p| apply(s, "shapeBuilder.remove", p, false)
+ ),
+ spec!("shapeBuilder.mode", "Shape builder mode", "{mode: merge|remove} (what a click does; Alt does the other)", false, |s, p| {
+ let remove = match string(p, "mode").as_deref() {
+ Some("merge") => false,
+ Some("remove") => true,
+ _ => return Err(bad("shapeBuilder.mode", "mode: merge or remove")),
+ };
+ s.tool.builder_remove = remove;
+ s.tool.overlay.builder_remove = remove;
+ if let Ok(ds) = s.ds_mut() {
+ ds.revision += 1;
+ }
+ Ok(json!({"mode": if remove { "remove" } else { "merge" }}))
+ }),
+ ]
+}
+
+/// Keys while the Shape builder tool is active: Enter finishes (Escape goes through
+/// [`Session::escape`]).
+pub fn key(s: &mut Session, name: &str) -> Result {
+ if s.tool.tool != ToolId::ShapeBuilder || !matches!(name, "Enter" | "Return") {
+ return Ok(false);
+ }
+ s.set_tool(ToolId::Move);
+ Ok(true)
+}
+
+impl Session {
+ /// The Shape builder tool: hover highlights a region, click/drag collects regions, release
+ /// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
+ /// every region selects the layer there (Shift adds), so shapes can be picked without
+ /// switching tools.
+ pub(crate) fn shape_builder_pointer(&mut self, ev: &PointerEvent) -> Result<()> {
+ let sel = self.selection();
+ // An arrangement that can't be built (bad geometry) just shows no regions.
+ let arr = arrangement(self, &sel).ok();
+ let at = |p: Point| arr.as_ref().and_then(|a| a.region_at(p));
+ let remove = ev.alt != self.tool.builder_remove;
+ self.tool.overlay.cursor = Cursor::Crosshair;
+ let before = (self.tool.overlay.builder_regions.clone(), self.tool.overlay.builder_remove);
+ match ev.kind {
+ // While a gesture runs, the collected regions stay highlighted.
+ PointerKind::Hover if !matches!(self.tool.gesture, Gesture::ShapeBuild { .. }) => {
+ self.tool.overlay.builder_regions = at(ev.p()).into_iter().collect();
+ }
+ PointerKind::Hover => {}
+ PointerKind::Down => {
+ let picked: Vec = at(ev.p()).into_iter().collect();
+ self.tool.overlay.builder_regions = picked.clone();
+ self.tool.gesture = Gesture::ShapeBuild { picked, remove, start: ev.p(), last: ev.p() };
+ }
+ PointerKind::Move => {
+ let Gesture::ShapeBuild { picked, last, .. } = &mut self.tool.gesture else { return Ok(()) };
+ // Sample the stroke every few screen pixels so fast drags don't skip regions.
+ let (from, to) = (*last, ev.p());
+ let steps = ((to - from).hypot() * ev.zoom.max(1e-6) / 3.0).ceil().clamp(1.0, 500.0) as usize;
+ for k in 1..=steps {
+ if let Some(i) = at(from.lerp(to, k as f64 / steps as f64))
+ && !picked.contains(&i)
+ {
+ picked.push(i);
+ }
+ }
+ *last = to;
+ self.tool.overlay.builder_regions = picked.clone();
+ }
+ // A double click arrives instead of the second click's release.
+ PointerKind::Up | PointerKind::DoubleClick => {
+ let Gesture::ShapeBuild { picked, remove, start, .. } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) };
+ self.tool.overlay.builder_regions.clear();
+ if picked.is_empty() {
+ if (ev.p() - start).hypot() * ev.zoom < 3.0 {
+ self.builder_select(ev);
+ }
+ } else {
+ let cmd = if remove { "shapeBuilder.remove" } else { "shapeBuilder.merge" };
+ self.execute(cmd, &json!({"ids": sel, "regions": picked}))?;
+ }
+ }
+ }
+ // A gesture keeps the mode it started with.
+ self.tool.overlay.builder_remove = match &self.tool.gesture {
+ Gesture::ShapeBuild { remove, .. } => *remove,
+ _ => remove,
+ };
+ if before != (self.tool.overlay.builder_regions.clone(), self.tool.overlay.builder_remove)
+ && let Ok(ds) = self.ds_mut()
+ {
+ ds.revision += 1;
+ }
+ Ok(())
+ }
+
+ /// A click outside the regions: select the layer under the pointer (Shift toggles it).
+ fn builder_select(&mut self, ev: &PointerEvent) {
+ let Ok(ds) = self.ds() else { return };
+ let (doc, page, mut sel) = (ds.doc.clone(), ds.page, ds.selection.clone());
+ let Some(leaf) = doc.hit_test(page, ev.p(), 2.0 / ev.zoom.max(1e-6)) else { return };
+ let t = figcraft_doc::geometry::select_target(&doc, leaf, &sel, ev.cmd);
+ if ev.shift {
+ if let Some(i) = sel.iter().position(|x| *x == t) {
+ sel.remove(i);
+ } else {
+ sel.push(t);
+ }
+ } else {
+ sel = vec![t];
+ }
+ self.set_selection(sel);
+ }
+}
diff --git a/crates/engine/src/shapebuilder_tests.rs b/crates/engine/src/shapebuilder_tests.rs
new file mode 100644
index 0000000..305d756
--- /dev/null
+++ b/crates/engine/src/shapebuilder_tests.rs
@@ -0,0 +1,285 @@
+//! The shape builder: regions, merge and remove commands, the tool's gestures, keys and undo.
+
+use serde_json::{Value, json};
+
+use crate::shapebuilder::{arrangement, current};
+use crate::tools::{PointerEvent, PointerKind};
+use crate::{EngineError, Session, ToolId, params};
+use figcraft_doc::{NodeId, NodeKind};
+use figcraft_geom::Shape;
+
+fn ex(s: &mut Session, c: &str, p: Value) -> Value {
+ s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
+}
+
+fn id(v: &Value) -> NodeId {
+ params::id_of(&v["id"]).unwrap()
+}
+
+fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, fill: &str) -> NodeId {
+ id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h, "props": {"fill": fill}})))
+}
+
+/// Two 10×10 squares overlapping in a 5×5 square: A (red) at the origin, B (blue) above it at 5,5.
+fn squares(s: &mut Session) -> (NodeId, NodeId) {
+ let a = rect(s, 0.0, 0.0, 10.0, 10.0, "#ff0000");
+ let b = rect(s, 5.0, 5.0, 10.0, 10.0, "#0000ff");
+ s.set_selection(vec![a, b]);
+ (a, b)
+}
+
+/// 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()
+}
+
+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, pts: &[(f64, f64)], alt: bool) {
+ let (first, rest) = pts.split_first().unwrap();
+ s.pointer(&ev(PointerKind::Down, first.0, first.1, alt)).unwrap();
+ for p in rest {
+ s.pointer(&ev(PointerKind::Move, p.0, p.1, alt)).unwrap();
+ }
+ let last = pts.last().unwrap();
+ s.pointer(&ev(PointerKind::Up, last.0, last.1, alt)).unwrap();
+}
+
+#[test]
+fn overlapping_squares_make_three_regions() {
+ let mut s = Session::new();
+ let (a, b) = squares(&mut s);
+ let r = ex(&mut s, "shapeBuilder.regions", json!({}));
+ assert_eq!(r["sources"], json!([a, b]));
+ let regions = r["regions"].as_array().unwrap();
+ assert_eq!(regions.len(), 3, "{r}");
+ let arr = arrangement(&mut s, &[a, b]).unwrap();
+ let only_a = arr.region_at((2.0, 2.0).into()).unwrap();
+ let both = arr.region_at((7.0, 7.0).into()).unwrap();
+ let only_b = arr.region_at((12.0, 12.0).into()).unwrap();
+ assert_eq!(arr.regions[only_a].sources, vec![a]);
+ assert_eq!(arr.regions[both].sources, vec![b, a], "top-most first");
+ assert_eq!(arr.regions[only_b].sources, vec![b]);
+ assert!(arr.region_at((20.0, 20.0).into()).is_none());
+ // Every region reports a point inside itself.
+ for (i, reg) in regions.iter().enumerate() {
+ let p = reg["point"].as_array().unwrap();
+ assert_eq!(arr.region_at((p[0].as_f64().unwrap(), p[1].as_f64().unwrap()).into()), Some(i));
+ }
+ // Cached per document version and ids.
+ assert!(std::sync::Arc::ptr_eq(&arr, &arrangement(&mut s, &[a, b]).unwrap()));
+ assert!(current(&s).is_some_and(|c| std::sync::Arc::ptr_eq(&c, &arr)));
+ rect(&mut s, 40.0, 40.0, 5.0, 5.0, "#00ff00");
+ assert!(current(&s).is_none(), "a new document version (and selection) invalidates the cache");
+}
+
+#[test]
+fn merge_the_overlap_into_a_new_shape() {
+ let mut s = Session::new();
+ let (a, b) = squares(&mut s);
+ let undo = s.ds().unwrap().history.undo.len();
+ let r = ex(&mut s, "shapeBuilder.merge", json!({"points": [[7, 7]]}));
+ let m = id(&r);
+ assert!((area(&s, m) - 25.0).abs() < 0.01, "{}", area(&s, m));
+ assert_eq!(fill_hex(&s, m), "0000FF", "styled like the top-most shape over the region");
+ assert!((area(&s, a) - 75.0).abs() < 0.01, "A loses the overlap: {}", area(&s, a));
+ assert!((area(&s, b) - 75.0).abs() < 0.01, "B loses the overlap: {}", area(&s, b));
+ assert_eq!(fill_hex(&s, a), "FF0000");
+ let doc = s.doc().unwrap();
+ assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Vector { .. }));
+ assert_eq!(doc.index_in_parent(m), doc.index_in_parent(b).map(|i| i + 1), "just above the style source");
+ let sel = s.selection();
+ assert!(sel.contains(&a) && sel.contains(&b) && sel.contains(&m), "{sel:?}");
+ // One undo step brings the rectangles back.
+ assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
+ assert!(s.undo().unwrap());
+ let doc = s.doc().unwrap();
+ assert!(!doc.contains(m));
+ assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Rectangle));
+ assert!((area(&s, a) - 100.0).abs() < 1e-6);
+}
+
+#[test]
+fn merging_every_region_unites_the_shapes() {
+ let mut s = Session::new();
+ let (a, b) = squares(&mut s);
+ let r = ex(&mut s, "shapeBuilder.merge", json!({"regions": [0, 1, 2]}));
+ let m = id(&r);
+ assert_eq!(r["removed"], json!([a, b]));
+ assert!((area(&s, m) - 175.0).abs() < 0.01, "{}", area(&s, m));
+ assert_eq!(s.selection(), vec![m]);
+ // Merging a lone shape's only region is a no-op.
+ let before = s.doc().unwrap().clone();
+ let r = ex(&mut s, "shapeBuilder.merge", json!({"regions": [0]}));
+ assert!(r["id"].is_null());
+ assert_eq!(*s.doc().unwrap(), before);
+}
+
+#[test]
+fn remove_regions() {
+ let mut s = Session::new();
+ let (a, b) = squares(&mut s);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[7, 7], [12, 12]]}));
+ assert!(!s.doc().unwrap().contains(b), "B had nothing left");
+ assert!((area(&s, a) - 75.0).abs() < 0.01);
+ assert_eq!(s.selection(), vec![a]);
+ let e = s.execute("shapeBuilder.remove", &json!({"points": [[100, 100]]}));
+ assert!(matches!(e, Err(EngineError::BadParams { .. })), "{e:?}");
+}
+
+#[test]
+fn curved_shapes_keep_their_curves() {
+ let mut s = Session::new();
+ let a = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
+ let b = id(&ex(&mut s, "shape.ellipse", json!({"x": 50, "y": 0, "width": 100, "height": 100})));
+ s.set_selection(vec![a, b]);
+ let r = ex(&mut s, "shapeBuilder.regions", json!({}));
+ assert_eq!(r["regions"].as_array().unwrap().len(), 3);
+ let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"points": [[75, 50]]})));
+ // The lens of two r=50 circles 50 apart: 2r²·acos(d/2r) − (d/2)·√(4r² − d²).
+ let lens = 2.0 * 2500.0 * (0.5f64).acos() - 25.0 * (10000.0f64 - 2500.0).sqrt();
+ assert!((area(&s, m) - lens).abs() < lens * 0.01, "{} vs {lens}", area(&s, m));
+ let doc = s.doc().unwrap();
+ let NodeKind::Vector { network } = &doc.node(m).unwrap().kind else { panic!("not a vector") };
+ assert!(network.segments.iter().any(|g| !g.is_straight()), "the lens keeps curved edges");
+ let full = std::f64::consts::PI * 2500.0;
+ assert!((area(&s, a) - (full - lens)).abs() < full * 0.01);
+}
+
+#[test]
+fn tool_click_drag_alt_and_keys() {
+ let mut s = Session::new();
+ let (a, b) = squares(&mut s);
+ ex(&mut s, "tool.shapeBuilder", json!({}));
+ assert_eq!(s.tool.tool, ToolId::ShapeBuilder);
+ // Hover highlights the region under the pointer.
+ s.pointer(&ev(PointerKind::Hover, 7.0, 7.0, false)).unwrap();
+ let both = current(&s).unwrap().region_at((7.0, 7.0).into()).unwrap();
+ assert_eq!(s.tool.overlay.builder_regions, vec![both]);
+ assert!(!s.tool.overlay.builder_remove);
+ s.pointer(&ev(PointerKind::Hover, 7.0, 7.0, true)).unwrap();
+ assert!(s.tool.overlay.builder_remove, "Alt removes");
+ // Alt-drag from A's own part across the overlap removes both regions: one undo step.
+ let undo = s.ds().unwrap().history.undo.len();
+ drag(&mut s, &[(2.0, 2.0), (4.0, 4.0), (7.0, 7.0)], true);
+ assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1);
+ assert!(!s.doc().unwrap().contains(a));
+ assert!((area(&s, b) - 75.0).abs() < 0.01);
+ assert!(s.tool.overlay.builder_regions.is_empty());
+ assert!(s.undo().unwrap());
+ // A click merges the region under it: B keeps only the overlap.
+ s.set_selection(vec![a, b]);
+ drag(&mut s, &[(12.0, 12.0)], false);
+ let sel = s.selection();
+ assert_eq!(sel.len(), 3, "{sel:?}");
+ assert!((area(&s, b) - 25.0).abs() < 0.01);
+ // A click outside every region selects the layer there; Enter finishes the tool.
+ let c = {
+ s.set_tool(ToolId::Move);
+ let c = rect(&mut s, 40.0, 40.0, 5.0, 5.0, "#00ff00");
+ s.set_selection(vec![a]);
+ s.set_tool(ToolId::ShapeBuilder);
+ c
+ };
+ drag(&mut s, &[(42.0, 42.0)], false);
+ assert_eq!(s.selection(), vec![c]);
+ assert!(s.tool_key("Enter", false, false, false).unwrap());
+ assert_eq!(s.tool.tool, ToolId::Move);
+ assert!(!s.tool_key("Enter", false, false, false).unwrap(), "Enter is not the tool's outside it");
+}
+
+#[test]
+fn remove_mode_and_escape() {
+ let mut s = Session::new();
+ let (a, _) = squares(&mut s);
+ ex(&mut s, "tool.shapeBuilder", json!({}));
+ ex(&mut s, "shapeBuilder.mode", json!({"mode": "remove"}));
+ s.pointer(&ev(PointerKind::Down, 2.0, 2.0, false)).unwrap();
+ assert!(s.tool.busy());
+ // Escape cancels the gesture, then leaves the tool.
+ s.escape().unwrap();
+ assert!(!s.tool.busy());
+ assert!(s.doc().unwrap().contains(a));
+ // In Remove mode a plain click removes and Alt merges.
+ drag(&mut s, &[(2.0, 2.0)], false);
+ assert!((area(&s, a) - 25.0).abs() < 0.01);
+ s.escape().unwrap();
+ assert_eq!(s.tool.tool, ToolId::Move);
+ assert!(s.execute("shapeBuilder.mode", &json!({"mode": "sideways"})).is_err());
+}
+
+#[test]
+fn groups_rotated_and_ineligible_layers() {
+ let mut s = Session::new();
+ let (a, b) = squares(&mut s);
+ let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
+ 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");
+ // 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]]})));
+ assert_eq!(s.doc().unwrap().node(m).unwrap().parent, Some(g));
+ assert_eq!(s.selection(), vec![g]);
+ // Rotated shapes reshape in their own axes and keep their rotation.
+ let mut s = Session::new();
+ let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0, "#ff0000");
+ let b = rect(&mut s, 5.0, 0.0, 10.0, 10.0, "#0000ff");
+ ex(&mut s, "node.set", json!({"id": b, "props": {"rotation": 30}}));
+ let t_before = s.doc().unwrap().node(b).unwrap().transform;
+ let total = area(&s, a) + area(&s, b);
+ s.set_selection(vec![a, b]);
+ let regions = ex(&mut s, "shapeBuilder.regions", json!({}));
+ let n = regions["regions"].as_array().unwrap().len();
+ assert!(n >= 3, "{regions}");
+ let both = arrangement(&mut s, &[a, b]).unwrap().regions.iter().position(|r| r.sources.len() == 2).unwrap();
+ let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"regions": [both]})));
+ let after = area(&s, a) + area(&s, b) + 2.0 * area(&s, m);
+ assert!((after - total).abs() < 0.05, "areas are conserved: {after} vs {total}");
+ let t_after = s.doc().unwrap().node(b).unwrap().transform;
+ let rot = |t: figcraft_geom::Affine| t.as_coeffs()[1].atan2(t.as_coeffs()[0]);
+ assert!((rot(t_before) - rot(t_after)).abs() < 1e-9);
+}
+
+#[test]
+fn junk_params_never_panic() {
+ let junk = [
+ json!({}),
+ json!(null),
+ json!({"regions": [-1, 1e99, "x", null, 0.5, 2]}),
+ json!({"points": [[f64::MAX, 1], ["NaN", "x"], [1], 5, null]}),
+ json!({"ids": ["x", -1, 99999], "regions": [0]}),
+ json!({"mode": 7}),
+ ];
+ for cmd in ["shapeBuilder.regions", "shapeBuilder.merge", "shapeBuilder.remove", "shapeBuilder.mode"] {
+ for j in &junk {
+ let mut s = Session::new();
+ squares(&mut s);
+ if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
+ panic!("{cmd} panicked on {j}: {msg}");
+ }
+ }
+ }
+ // Degenerate geometry: a shape scaled to nothing, a huge one and a self-overlapping vector.
+ let mut s = Session::new();
+ let a = rect(&mut s, 0.0, 0.0, 0.0, 10.0, "#ff0000");
+ let b = rect(&mut s, -1e8, -1e8, 2e8, 2e8, "#0000ff");
+ let v = id(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [20, 20], [20, 0], [0, 20]], "closed": true})));
+ s.set_selection(vec![a, b, v]);
+ let _ = s.execute("shapeBuilder.regions", &json!({}));
+ for p in [[10.0, 5.0], [5.0, 10.0], [1e7, 1e7]] {
+ if let Err(EngineError::Internal { msg, .. }) = s.execute("shapeBuilder.merge", &json!({"points": [p]})) {
+ panic!("merge panicked: {msg}");
+ }
+ }
+}
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
index 0141cc6..4929a67 100644
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -36,10 +36,11 @@ pub enum ToolId {
Text,
Comment,
Eyedropper,
+ ShapeBuilder,
}
impl ToolId {
- pub const ALL: [ToolId; 17] = [
+ pub const ALL: [ToolId; 18] = [
ToolId::Move,
ToolId::Scale,
ToolId::Hand,
@@ -57,6 +58,7 @@ impl ToolId {
ToolId::Text,
ToolId::Comment,
ToolId::Eyedropper,
+ ToolId::ShapeBuilder,
];
pub fn id(self) -> &'static str {
match self {
@@ -77,6 +79,7 @@ impl ToolId {
ToolId::Text => "text",
ToolId::Comment => "comment",
ToolId::Eyedropper => "eyedropper",
+ ToolId::ShapeBuilder => "shapeBuilder",
}
}
pub fn from_id(s: &str) -> Option {
@@ -101,6 +104,7 @@ impl ToolId {
ToolId::Text => "Text",
ToolId::Comment => "Comment",
ToolId::Eyedropper => "Pick color",
+ ToolId::ShapeBuilder => "Shape builder",
}
}
pub fn shortcut(self) -> &'static str {
@@ -120,6 +124,7 @@ impl ToolId {
ToolId::Text => "T",
ToolId::Comment => "C",
ToolId::Eyedropper => "I",
+ ToolId::ShapeBuilder => "Shift+M",
_ => "",
}
}
@@ -227,6 +232,10 @@ pub struct Overlay {
/// The eraser brush under the pointer (x, y, radius in page units).
pub brush: Option<[f64; 3]>,
pub face: Vec>,
+ /// Shape builder: the regions under the pointer or collected by the drag (indexes into
+ /// [`crate::shapebuilder::current`]) and whether releasing removes them.
+ pub builder_regions: Vec,
+ pub builder_remove: bool,
}
#[derive(Clone, Debug)]
@@ -292,6 +301,13 @@ pub(crate) enum Gesture {
},
/// Vector edit mode gestures (handled in `vectoredit`).
Vector(crate::vectoredit::VGesture),
+ /// Shape builder: regions collected so far (handled in `shapebuilder`).
+ ShapeBuild {
+ picked: Vec,
+ remove: bool,
+ start: Point,
+ last: Point,
+ },
}
/// The pen tool's in-progress path (page coordinates).
@@ -314,6 +330,10 @@ pub struct ToolState {
pub temp_from: Option,
/// Last click (for double-click detection by agents that only send downs).
pub last_hover: Option,
+ /// Shape builder: the cached arrangement of the selection, and Remove mode (a plain click
+ /// removes; Alt merges).
+ pub shape_builder: Option>,
+ pub builder_remove: bool,
}
impl ToolState {
@@ -441,7 +461,13 @@ impl Session {
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
- self.tool.overlay = Overlay::default();
+ self.tool.overlay = Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, ..Overlay::default() };
+ self.tool.shape_builder = None;
+ if t == ToolId::ShapeBuilder {
+ // Regions show straight away, before the pointer moves.
+ let sel = self.selection();
+ let _ = crate::shapebuilder::arrangement(self, &sel);
+ }
if let Ok(ds) = self.ds_mut() {
if t != ToolId::Text && t != ToolId::Move {
ds.text_edit = None;
@@ -456,6 +482,9 @@ impl Session {
if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? {
return Ok(());
}
+ if tool == ToolId::ShapeBuilder {
+ return self.shape_builder_pointer(ev);
+ }
match ev.kind {
PointerKind::Hover => self.hover(ev),
PointerKind::Down => match tool {
@@ -786,7 +815,7 @@ impl Session {
textedit::set_caret(self, idx, true)?;
Ok(())
}
- Gesture::Vector(_) => Ok(()),
+ Gesture::Vector(_) | Gesture::ShapeBuild { .. } => Ok(()),
Gesture::PenDrag { anchor } => {
let out = ev.p() - *anchor;
if let Some(pen) = self.tool.pen.as_mut()
@@ -1190,7 +1219,7 @@ impl Session {
self.pencil_finish(&points)?;
}
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
- Gesture::PenDrag { .. } => {}
+ Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } => {}
Gesture::Vector(_) => self.commit_interaction()?,
}
if let Ok(ds) = self.ds_mut() {
@@ -1460,6 +1489,15 @@ impl Session {
Ok(())
}
+ /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point
+ /// keys, Enter to finish the shape builder). Returns whether the key was used.
+ pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result {
+ if self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
+ return crate::vectoredit::key(self, name, shift, alt, cmd);
+ }
+ crate::shapebuilder::key(self, name)
+ }
+
/// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect.
pub fn escape(&mut self) -> Result<()> {
if self.tool.pen.is_some() {
diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs
index 6a11d85..95ea8ed 100644
--- a/crates/pathops/src/lib.rs
+++ b/crates/pathops/src/lib.rs
@@ -115,6 +115,136 @@ pub fn boolean(paths: &[BezPath], op: BoolOp) -> Result {
Ok(acc)
}
+/// Per-input winding numbers for [`faces`] (index = input path; trailing zeros trimmed so equal
+/// coverage compares equal).
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+struct Windings(Vec);
+
+impl Windings {
+ fn trimmed(mut self) -> Self {
+ while self.0.last() == Some(&0) {
+ self.0.pop();
+ }
+ self
+ }
+}
+
+impl std::ops::Add for Windings {
+ type Output = Self;
+ fn add(mut self, rhs: Self) -> Self {
+ self += rhs;
+ self
+ }
+}
+
+impl std::ops::AddAssign for Windings {
+ fn add_assign(&mut self, rhs: Self) {
+ if self.0.len() < rhs.0.len() {
+ self.0.resize(rhs.0.len(), 0);
+ }
+ for (a, b) in self.0.iter_mut().zip(rhs.0) {
+ *a += b;
+ }
+ *self = std::mem::take(self).trimmed();
+ }
+}
+
+impl linesweeper::topology::WindingNumber for Windings {
+ type Tag = usize;
+ fn single(tag: usize, positive: bool) -> Self {
+ let mut v = vec![0; tag + 1];
+ if let Some(w) = v.last_mut() {
+ *w = if positive { 1 } else { -1 };
+ }
+ Self(v)
+ }
+ fn of_tag(&self, tag: usize) -> Self {
+ let mut v = vec![0; tag + 1];
+ if let (Some(w), Some(src)) = (v.last_mut(), self.0.get(tag)) {
+ *w = *src;
+ }
+ Self(v).trimmed()
+ }
+}
+
+/// One face of the arrangement of several closed paths: a connected area that the same set of
+/// inputs covers.
+#[derive(Clone, Debug)]
+pub struct ArrangementFace {
+ /// The face's outer contour and its holes (fill with either rule).
+ pub path: BezPath,
+ /// The inputs covering the face, by index, ascending.
+ pub cover: Vec,
+}
+
+/// The most inputs [`faces`] accepts, and the most faces it returns.
+pub const MAX_ARRANGEMENT_INPUTS: usize = 256;
+const MAX_FACES: usize = 4096;
+
+/// Split the plane by the outlines of `paths` (each filled with its own rule: `true` = even-odd)
+/// into faces: the connected areas covered by the same inputs. Areas no input covers are left
+/// out. Open subpaths are closed first.
+pub fn faces(paths: &[(BezPath, bool)]) -> Result, PathOpsError> {
+ if paths.len() > MAX_ARRANGEMENT_INPUTS || paths.iter().any(|(p, _)| !finite(p)) {
+ return Err(PathOpsError::BadInput);
+ }
+ let closed_paths: Vec = paths.iter().map(|(p, _)| closed(p)).collect();
+ if closed_paths.iter().all(|p| p.elements().is_empty()) {
+ return Ok(Vec::new());
+ }
+ let bbox = closed_paths.iter().filter(|p| !p.elements().is_empty()).map(Shape::bounding_box).reduce(|a, b| a.union(b));
+ let m = bbox.map(|b| b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs())).unwrap_or(0.0);
+ let eps = (m * f64::EPSILON * 64.0).max(1e-6);
+ let topo = linesweeper::topology::Topology::::from_paths(closed_paths.iter().enumerate().map(|(i, p)| (p, i)), eps)
+ .map_err(|_| PathOpsError::BadInput)?;
+ let even_odd: Vec = paths.iter().map(|(_, e)| *e).collect();
+ let cover_of = |w: &Windings| -> Vec {
+ w.0.iter()
+ .enumerate()
+ .filter(|(i, n)| if even_odd.get(*i).copied().unwrap_or(false) { *n % 2 != 0 } else { **n != 0 })
+ .map(|(i, _)| i)
+ .collect()
+ };
+ // Every coverage that occurs on either side of an output segment.
+ let mut covers: std::collections::BTreeSet> = std::collections::BTreeSet::new();
+ for seg in topo.segment_indices() {
+ let half = seg.first_half();
+ for w in [topo.winding_clockwise(half), topo.winding_counter_clockwise(half)] {
+ let c = cover_of(w);
+ if !c.is_empty() {
+ covers.insert(c);
+ }
+ }
+ }
+ let mut out = Vec::new();
+ for cover in covers {
+ let contours = topo.contours(|w| cover_of(w) == cover);
+ let all: Vec<&linesweeper::topology::Contour> = contours.contours().collect();
+ // Each outer contour with its direct holes is one connected face (islands inside the
+ // holes are faces of their own).
+ for (i, c) in all.iter().enumerate().filter(|(_, c)| c.outer) {
+ let mut path = c.path.clone();
+ for hole in all.iter().filter(|h| !h.outer && h.parent.map(|p| p.0) == Some(i)) {
+ path.extend(hole.path.elements().iter().copied());
+ }
+ out.push(ArrangementFace { path, cover: cover.clone() });
+ if out.len() >= MAX_FACES {
+ return Ok(out);
+ }
+ }
+ }
+ Ok(out)
+}
+
+/// The union of disjoint faces (shared edges dissolve) as one clean path.
+pub fn union_faces<'a>(faces: impl IntoIterator- ) -> Result {
+ let mut all = BezPath::new();
+ for f in faces {
+ all.extend(f.elements().iter().copied());
+ }
+ simplify(&all)
+}
+
fn kcap(c: StrokeCap) -> Cap {
match c {
StrokeCap::Round | StrokeCap::CircleFilled => Cap::Round,
@@ -314,6 +444,39 @@ mod tests {
assert_eq!((bb.x0, bb.y0, bb.x1, bb.y1), (0.0, 0.0, 15.0, 15.0));
}
+ #[test]
+ fn arrangement_faces() {
+ // Two overlapping squares: A only, B only, both.
+ let f = faces(&[(sq(0.0, 0.0, 10.0), false), (sq(5.0, 5.0, 10.0), false)]).unwrap();
+ let mut got: Vec<(Vec, i64)> = f.iter().map(|x| (x.cover.clone(), x.path.area().abs().round() as i64)).collect();
+ got.sort();
+ assert_eq!(got, vec![(vec![0], 75), (vec![0, 1], 25), (vec![1], 75)]);
+ // Uniting faces dissolves their shared edges.
+ let u = union_faces(f.iter().map(|x| &x.path)).unwrap();
+ assert!((u.area().abs() - 175.0).abs() < 1e-6);
+ assert_eq!(u.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 1);
+ // A square inside a ring (even-odd): the ring, the square, and the gap between them is
+ // not covered; the square's area under the ring's hole is a face of its own.
+ let mut ring = sq(0.0, 0.0, 30.0);
+ ring.extend(sq(10.0, 10.0, 10.0).elements().iter().copied());
+ let f = faces(&[(ring, true), (sq(12.0, 12.0, 4.0), false)]).unwrap();
+ let mut got: Vec<(Vec, i64)> = f.iter().map(|x| (x.cover.clone(), x.path.area().abs().round() as i64)).collect();
+ got.sort();
+ assert_eq!(got, vec![(vec![0], 800), (vec![1], 16)]);
+ // Two overlapping circles: three faces with curved edges.
+ let c = |x: f64| kurbo::Circle::new((x, 0.0), 10.0).to_path(0.01);
+ let f = faces(&[(c(0.0), false), (c(10.0), false)]).unwrap();
+ assert_eq!(f.len(), 3);
+ assert!(f.iter().all(|x| x.path.elements().iter().any(|e| matches!(e, PathEl::CurveTo(..)))));
+ // Hostile and empty input.
+ let mut bad = BezPath::new();
+ bad.move_to((f64::NAN, 0.0));
+ bad.line_to((1.0, 1.0));
+ assert!(faces(&[(bad, false)]).is_err());
+ assert!(faces(&[]).unwrap().is_empty());
+ assert!(faces(&vec![(sq(0.0, 0.0, 1.0), false); MAX_ARRANGEMENT_INPUTS + 1]).is_err());
+ }
+
#[test]
fn hostile_inputs() {
let mut bad = BezPath::new();
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 5d4f964..8bf14fd 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -604,7 +604,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
}
}
}
- if let Some(b) = selection_box(&doc, &sel) {
+ // The shape builder reshapes by regions: no transform box.
+ if app.session.tool.tool != ToolId::ShapeBuilder
+ && let Some(b) = selection_box(&doc, &sel)
+ {
let q = b.map(|p| app.to_screen(p.x, p.y));
if sel.len() > 1 {
quad(painter, q, stroke(col, 1.0));
@@ -632,6 +635,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
}
}
}
+ if app.session.tool.tool == ToolId::ShapeBuilder
+ && let Some(arr) = figcraft_engine::shapebuilder::current(&app.session)
+ {
+ builder_overlay(app, painter, t, &arr);
+ }
// Text editing: caret and selection, or the IME composition underlined at the caret.
let preedit = app.preedit_view();
let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(&doc);
@@ -748,12 +756,7 @@ fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::ve
// Paint bucket target: hatched like a fill to come.
if !ov.face.is_empty() {
let polys: Vec> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect();
- for l in hatch(&polys, 5.0) {
- painter.line_segment(l, stroke(ACCENT, 1.0));
- }
- for poly in &polys {
- painter.add(Shape::closed_line(poly.clone(), stroke(ACCENT, 2.0)));
- }
+ hatched(painter, &polys, ACCENT);
}
let hover_seg = match ov.vector_hover {
Some(VHit::Segment { seg, .. }) => Some(seg),
@@ -795,6 +798,40 @@ fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::ve
}
}
+/// Shape builder: every region's edges, and the hovered or collected regions hatched (in the
+/// danger colour when releasing removes them).
+fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figcraft_engine::shapebuilder::Arrangement) {
+ let ov = &app.session.tool.overlay;
+ let screen = |r: &figcraft_engine::shapebuilder::Region| -> Vec> {
+ r.polys.iter().map(|poly| poly.iter().map(|p| app.to_screen(p.x, p.y)).collect()).collect()
+ };
+ // Edges read on any fill: a light halo under a thin accent line.
+ for r in &arr.regions {
+ for poly in screen(r) {
+ painter.add(Shape::closed_line(poly.clone(), stroke(Color32::from_white_alpha(150), 2.5)));
+ painter.add(Shape::closed_line(poly, stroke(ACCENT, 1.0)));
+ }
+ }
+ let col = if ov.builder_remove { t.danger } else { ACCENT };
+ for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) {
+ hatched(painter, &screen(r), col);
+ }
+}
+
+/// Polygons hatched and outlined in `col` (an area about to be filled, merged or removed), each
+/// line on a light halo so it reads on any fill, even one in `col`.
+fn hatched(painter: &egui::Painter, polys: &[Vec], col: Color32) {
+ let halo = Color32::from_white_alpha(200);
+ for l in hatch(polys, 6.0) {
+ painter.line_segment(l, stroke(halo, 3.0));
+ painter.line_segment(l, stroke(col, 1.25));
+ }
+ for poly in polys {
+ painter.add(Shape::closed_line(poly.clone(), stroke(halo, 4.0)));
+ painter.add(Shape::closed_line(poly.clone(), stroke(col, 2.0)));
+ }
+}
+
/// 45° hatch lines `gap` apart inside polygons (even-odd), in screen space.
fn hatch(polys: &[Vec], gap: f32) -> Vec<[Pos2; 2]> {
// Rotate so the hatch is horizontal, scan, rotate back.
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index f6f0d24..978a6f4 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -136,10 +136,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
figcraft_engine::textedit::key(&mut app.session, key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())?;
return Ok(json!({"ok": true}));
}
- // Vector edit mode keys (tools, nudging points, delete, join).
- if app.session.ds().is_ok_and(|d| d.vector_edit.is_some())
- && figcraft_engine::vectoredit::key(&mut app.session, key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())?
- {
+ // Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
+ if app.session.tool_key(key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())? {
return Ok(json!({"ok": true}));
}
match crate::shortcuts::parse(&spec) {
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 2bdeb2e..e1ae031 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -116,6 +116,10 @@ pub enum Icon {
Variable,
Clock,
Eraser,
+ /// Shape builder: merge regions (and the tool).
+ ShapeBuilder,
+ /// Shape builder: remove regions.
+ ShapeBuilderRemove,
Swap,
Link,
Prototype,
@@ -652,6 +656,21 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
p.line(8.0, 8.0, 8.0, 4.5);
p.line(8.0, 8.0, 10.5, 9.5);
}
+ ShapeBuilder | ShapeBuilderRemove => {
+ // Two overlapping rings; their shared lens filled (merge) or crossed out (remove).
+ let (r, d) = (4.4f32, 2.0f32);
+ let a = (d / r).acos();
+ p.circle(8.0 - d, 8.0, r);
+ p.circle(8.0 + d, 8.0, r);
+ let arc =
+ |cx: f32, t0: f32, t1: f32| (0..=8).map(move |i| t0 + (t1 - t0) * i as f32 / 8.0).map(move |t| (cx + r * t.cos(), 8.0 + r * t.sin()));
+ let lens: Vec<(f32, f32)> = arc(8.0 - d, -a, a).chain(arc(8.0 + d, std::f32::consts::PI - a, std::f32::consts::PI + a)).collect();
+ if icon == ShapeBuilder {
+ p.fill_poly(&lens);
+ } else {
+ p.line(6.2, 9.8, 9.8, 6.2);
+ }
+ }
Eraser => {
// A tilted block with a band near its tip, over a short trace line.
p.poly(&[(3.0, 10.0), (9.5, 3.5), (13.0, 7.0), (6.5, 13.5)], true);
diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs
index c9d82aa..6c9ea2d 100644
--- a/crates/ui-egui/src/shortcuts.rs
+++ b/crates/ui-egui/src/shortcuts.rs
@@ -83,15 +83,14 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Conte
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) {
return true;
}
- // Vector edit mode has its own keys (tools, nudging points, delete, join); the rest fall through.
- if app.session.ds().is_ok_and(|d| d.vector_edit.is_some()) {
- match figcraft_engine::vectoredit::key(&mut app.session, key.name(), m.shift, m.alt, m.command) {
- Ok(true) => return true,
- Ok(false) => {}
- Err(e) => {
- app.toast(e.to_string());
- return true;
- }
+ // Vector edit mode and the shape builder have their own keys (tools, nudging points, delete,
+ // join, Enter to finish); the rest fall through.
+ match app.session.tool_key(key.name(), m.shift, m.alt, m.command) {
+ Ok(true) => return true,
+ Ok(false) => {}
+ Err(e) => {
+ app.toast(e.to_string());
+ return true;
}
}
// Catalog shortcuts (exact modifier match).
diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs
index 4fb3507..f95134c 100644
--- a/crates/ui-egui/src/toolbar.rs
+++ b/crates/ui-egui/src/toolbar.rs
@@ -28,7 +28,7 @@ const GROUPS: &[Group] = &[
(ToolId::Star, Icon::Star),
],
},
- Group { key: "pen", tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil)] },
+ Group { key: "pen", tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)] },
Group { key: "text", tools: &[(ToolId::Text, Icon::Text)] },
Group { key: "comment", tools: &[(ToolId::Comment, Icon::Comment)] },
];
@@ -45,18 +45,15 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
edit_toolbar(app, ui, tool);
return;
}
+ // So does the shape builder, for its merge/remove modes.
+ if app.session.tool.tool == ToolId::ShapeBuilder {
+ builder_toolbar(app, ui);
+ return;
+ }
let cell = 32.0;
let gap = 4.0;
let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0;
- let height = 48.0;
- let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - height / 2.0), vec2(width, height));
- let painter = ui.painter();
- // Shadow + pill.
- for (i, a) in [(6.0, 10u8), (3.0, 16), (1.0, 24)] {
- painter.rect_filled(rect.expand(i).translate(vec2(0.0, i * 0.5)), CornerRadius::same((13.0 + i) as u8), Color32::from_black_alpha(a));
- }
- painter.rect_filled(rect, CornerRadius::same(13), t.panel);
- painter.rect_stroke(rect, CornerRadius::same(13), Stroke::new(1.0, t.border), StrokeKind::Inside);
+ let rect = pill(ui, canvas, width, &t);
let current = app.session.tool.tool;
let mut x = rect.min.x + 8.0;
let cy = rect.center().y;
@@ -65,18 +62,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) };
let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move);
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
- let resp = ui.interact(r, ui.id().with(("tool", g.key)), Sense::click());
- let active = in_group;
- let bg = if active {
- ACCENT
- } else if resp.hovered() {
- t.hover
- } else {
- Color32::TRANSPARENT
- };
- ui.painter().rect_filled(r, CornerRadius::same(5), bg);
- icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon });
- let resp = resp.on_hover_text(format!("{} {}", shown.label(), shown.shortcut()));
+ let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group).on_hover_text(format!("{} {}", shown.label(), shown.shortcut()));
if resp.clicked() {
app.run("tool.set", json!({"tool": shown.id()}));
}
@@ -117,7 +103,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
}
x += cell + 4.0;
}
- ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));
+ divider(ui, &t, x, cy);
x += 8.0;
// Mode switcher: Draw · Design · Prototype · Inspect (code).
let modes = [(Icon::Pencil, "Draw"), (Icon::Frame, "Design"), (Icon::Prototype, "Prototype"), (Icon::Code, "Inspect")];
@@ -177,15 +163,8 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
let t = Tokens::get(ui.ctx());
let canvas = app.canvas.rect;
let cell = 32.0;
- let done_w = 56.0;
- let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + done_w + 8.0;
- let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0));
- let painter = ui.painter();
- for (i, a) in [(6.0, 10u8), (3.0, 16), (1.0, 24)] {
- painter.rect_filled(rect.expand(i).translate(vec2(0.0, i * 0.5)), CornerRadius::same((13.0 + i) as u8), Color32::from_black_alpha(a));
- }
- painter.rect_filled(rect, CornerRadius::same(13), t.panel);
- painter.rect_stroke(rect, CornerRadius::same(13), Stroke::new(1.0, t.border), StrokeKind::Inside);
+ let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0;
+ let rect = pill(ui, canvas, width, &t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
// Holding Cmd with the Move tool bends: show it.
@@ -197,16 +176,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
} else {
format!("{} {}", tool.label(), pretty_shortcut(tool.shortcut()))
};
- let resp = ui.interact(r, ui.id().with(("vtool", tool.id())), Sense::click()).on_hover_text(tip);
let active = tool == current && !held || held && tool == VTool::Bend;
- let bg = if active {
- ACCENT
- } else if resp.hovered() {
- t.hover
- } else {
- Color32::TRANSPARENT
- };
- ui.painter().rect_filled(r, CornerRadius::same(5), bg);
let icon = match tool {
VTool::Move => Icon::Move,
VTool::Pen => Icon::Pen,
@@ -216,23 +186,96 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
VTool::Cut => Icon::Cut,
VTool::Eraser => Icon::Eraser,
};
- icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon });
- if resp.clicked() {
+ if tool_cell(ui, &t, r, ("vtool", tool.id()), icon, active).on_hover_text(tip).clicked() {
app.run("vector.edit.tool", json!({"tool": tool.id()}));
}
x += cell + 4.0;
}
- ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));
- x += 12.0;
- let done = Rect::from_min_size(pos2(x, cy - 14.0), vec2(done_w, 28.0));
- let dresp = ui.interact(done, ui.id().with("vdone"), Sense::click()).on_hover_text("Done editing Esc");
- ui.painter().rect_filled(done, CornerRadius::same(6), if dresp.hovered() { t.accent_hover } else { ACCENT });
- ui.painter().text(done.center(), Align2::CENTER_CENTER, "Done", crate::theme::semibold(11.5), Color32::WHITE);
- if dresp.clicked() {
+ if done_button(ui, &t, x, cy, "Done editing Esc") {
app.run("vector.edit.end", json!({}));
}
}
+/// The pill shown while the shape builder is active: Merge and Remove modes, a hint and Done.
+fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
+ let t = Tokens::get(ui.ctx());
+ let cell = 32.0;
+ let remove = app.session.tool.builder_remove;
+ let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty());
+ let alt = pretty_shortcut("Alt");
+ let hint = if !has_regions {
+ "Select overlapping shapes".to_string()
+ } else if remove {
+ format!("Click or drag to remove · {alt} merges")
+ } else {
+ format!("Click or drag to merge · {alt} removes")
+ };
+ let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);
+ let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;
+ let rect = pill(ui, app.canvas.rect, width, &t);
+ let cy = rect.center().y;
+ let mut x = rect.min.x + 8.0;
+ for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, "Merge regions"), ("remove", Icon::ShapeBuilderRemove, "Remove regions")] {
+ let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
+ if tool_cell(ui, &t, r, ("sbmode", mode), icon, (mode == "remove") == remove).on_hover_text(tip).clicked() {
+ app.run("shapeBuilder.mode", json!({"mode": mode}));
+ }
+ x += cell + 4.0;
+ }
+ divider(ui, &t, x, cy);
+ x += 12.0;
+ let w = galley.size().x;
+ ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary);
+ x += w + 12.0;
+ if done_button(ui, &t, x, cy, &format!("Done {}", pretty_shortcut("Enter"))) {
+ app.run("tool.set", json!({"tool": "move"}));
+ }
+}
+
+/// The floating pill (shadow, panel, border) `width` wide at the bottom centre of the canvas.
+fn pill(ui: &Ui, canvas: Rect, width: f32, t: &Tokens) -> Rect {
+ let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0));
+ let painter = ui.painter();
+ for (i, a) in [(6.0, 10u8), (3.0, 16), (1.0, 24)] {
+ painter.rect_filled(rect.expand(i).translate(vec2(0.0, i * 0.5)), CornerRadius::same((13.0 + i) as u8), Color32::from_black_alpha(a));
+ }
+ painter.rect_filled(rect, CornerRadius::same(13), t.panel);
+ painter.rect_stroke(rect, CornerRadius::same(13), Stroke::new(1.0, t.border), StrokeKind::Inside);
+ rect
+}
+
+/// A square tool button: accent when active, tinted on hover.
+fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Icon, active: bool) -> egui::Response {
+ let resp = ui.interact(r, ui.id().with(id), Sense::click());
+ let bg = if active {
+ ACCENT
+ } else if resp.hovered() {
+ t.hover
+ } else {
+ Color32::TRANSPARENT
+ };
+ ui.painter().rect_filled(r, CornerRadius::same(5), bg);
+ icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon });
+ resp
+}
+
+/// A vertical separator just after `x`.
+fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) {
+ ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));
+}
+
+const DONE_W: f32 = 56.0;
+
+/// A divider at `x` and the accent Done button after it; true when clicked.
+fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {
+ divider(ui, t, x, cy);
+ let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));
+ let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).on_hover_text(tip);
+ ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT });
+ ui.painter().text(done.center(), Align2::CENTER_CENTER, "Done", crate::theme::semibold(11.5), Color32::WHITE);
+ resp.clicked()
+}
+
fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
let t = Tokens::get(ui.ctx());
let item_h = 32.0;
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index c2034bb..0f52881 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -77,7 +77,7 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d
### `ui.key {key, shift?, alt?, cmd?, ctrl?}`
-A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
+A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`; with the shape builder, `Enter` finishes): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge; auto layout children move one place along the flow; with `cmd` they resize the selection). While text is being
edited, keys go to the text editor. Returns `{"command": id}` for shortcuts.
diff --git a/docs/mcp.md b/docs/mcp.md
index 9a0e7e0..6b0650f 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -155,6 +155,23 @@ read library files return an error).
- **Update**: `library.updates` lists the components and styles with a newer published version;
`library.acceptUpdates {ids? (library ids)}` pulls them in, keeping instance overrides.
+### 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):
+click or drag across regions with `pointer` to merge, add `alt` to remove; `shapeBuilder.mode
+{mode: merge|remove}` swaps what a plain click does.
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.regions","params":{"ids":[2,3]}}}}
+{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"ids":[2,3],"points":[[120,80],[160,80]]}}}}
+```
+
### Importing REST API file JSON
The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`, best with
diff --git a/docs/parity.md b/docs/parity.md
index 16b7650..b6baca0 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -1,8 +1,8 @@
# Parity report
-Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (340 commands in the engine).
+Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (357 commands in the engine).
-**194 / 200 menu items (97.0%)**
+**195 / 201 menu items (97.0%)**
## Missing
|