- Real vector networks: the Pen and Pencil tools, vector edit mode with bend, split, heal-on-delete and handle mirroring, 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, 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 aa680c68..1444dd4d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,7 +9,7 @@ continuous Claude Opus 5.5 agent work (including builds and the CI gate), for **
**4–6 parallel agents** on disjoint crates. They are counted from the remaining work in the parity
table below and updated as work lands.
-_Last updated: 2026-10-09 (end of day 1: foundation, renderer, engine, UI3 shell)._
+_Last updated: 2026-10-10 (vector edit mode tools)._
## Where we are
@@ -38,8 +38,12 @@ dimension measured side by side.
### Where we're lacking (priority order)
-1. **Vector edit mode** (Enter on a vector): vertex/handle editing, bend, paint bucket, lasso,
- cut/erase, vector networks with branching. 25–35 h.
+1. **Vector edit mode**: *landed:* edit-mode toolbar (move, pen, bend, paint bucket, lasso, cut),
+ 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. *Left:* eraser, variable
+ width strokes, shape builder, transform box for several points, point snapping. 12–18 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.
*Left:* inline IME preedit, variable font axes, visual QA. 8–12 h.
@@ -62,7 +66,8 @@ dimension measured side by side.
### Where we're going
-- **Next:** vector edit mode (1) and text depth (2) in parallel with the fidelity pass (7).
+- **Next:** text depth (2) and auto layout depth (3) in parallel with the fidelity pass (7); the
+ rest of vector editing (1).
- **Then:** auto layout/components/prototyping depth (3–5), interop (6).
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
@@ -111,7 +116,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Canvas & navigation | 6 | 60% | rulers/guides dragging, pixel preview, minimap-free perf at scale | 8–12 |
| Selection & transform | 8 | 55% | rotated multi-select handles, scale tool polish, measure hover polish, nudge-resize | 12–18 |
| Layers panel | 5 | 55% | search/filter, multi-select ranges, collapse all, thumbnails, drag polish | 8–12 |
-| Shapes & vector | 8 | 30% | vector edit mode, bend/paint bucket, live boolean editing, arc handles on canvas | 30–40 |
+| Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 |
| Paint | 7 | 55% | gradient handles on canvas, image crop mode, document colours, colour models | 12–18 |
| Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 |
| Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 |
@@ -125,7 +130,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
-Weighted feature depth ≈ **43%**.
+Weighted feature depth ≈ **45%**.
## Milestones
@@ -136,7 +141,7 @@ Weighted feature depth ≈ **43%**.
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~60% |
| M2 Layout depth | ~45% |
| M3 Components & styles depth | ~40% |
-| M4 Vector & text depth | ~30% |
+| M4 Vector & text depth | ~45% |
| M5 Prototype & present | ~30% |
| M6 Interop & dev mode | ~35% |
| M7 Release (signed builds for all platforms, README screenshots) | started |
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 7306318c..14faa869 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -16,6 +16,7 @@ pub mod proto;
pub mod styles;
pub mod text;
pub mod vector;
+pub mod vector_faces;
pub use comments::*;
pub use figcraft_color as color;
@@ -25,6 +26,7 @@ pub use props::*;
pub use styles::*;
pub use text::*;
pub use vector::*;
+pub use vector_faces::Face;
use figcraft_color::Color;
use figcraft_geom::{Affine, Point, Rect};
diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs
index 064317a6..7c441fbd 100644
--- a/crates/doc/src/vector.rs
+++ b/crates/doc/src/vector.rs
@@ -4,6 +4,32 @@
use figcraft_geom::{BezPath, Point};
use serde::{Deserialize, Serialize};
+/// How a vertex's two bezier handles follow each other when one is dragged.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub enum HandleMirroring {
+ /// Handles move independently (a corner).
+ #[default]
+ None,
+ /// The other handle stays opposite but keeps its own length.
+ Angle,
+ /// The other handle mirrors angle and length (a symmetric smooth point).
+ AngleAndLength,
+}
+
+impl HandleMirroring {
+ pub fn id(self) -> &'static str {
+ match self {
+ HandleMirroring::None => "none",
+ HandleMirroring::Angle => "angle",
+ HandleMirroring::AngleAndLength => "angleAndLength",
+ }
+ }
+ pub fn from_id(s: &str) -> Option {
+ [HandleMirroring::None, HandleMirroring::Angle, HandleMirroring::AngleAndLength].into_iter().find(|m| m.id().eq_ignore_ascii_case(s))
+ }
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Vertex {
pub x: f64,
@@ -11,11 +37,14 @@ pub struct Vertex {
/// Per-vertex corner radius (0 = sharp).
#[serde(default)]
pub radius: f64,
+ /// Handle mirroring; `None` infers it from the handles (see [`VectorNetwork::mirroring`]).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub mirror: Option,
}
impl Vertex {
pub fn new(x: f64, y: f64) -> Self {
- Self { x, y, radius: 0.0 }
+ Self { x, y, radius: 0.0, mirror: None }
}
pub fn point(&self) -> Point {
Point::new(self.x, self.y)
@@ -92,9 +121,27 @@ impl VectorNetwork {
net.vertices.push(Vertex::new(p.x, p.y));
net.vertices.len() - 1
};
+ // A subpath that ends back on its start point without a ClosePath (e.g. ellipse outlines)
+ // is closed too, so it keeps its fill.
+ let close_open =
+ |net: &mut VectorNetwork, loop_segs: &mut Vec, all_loops: &mut Vec>, first_v: Option, cur_v: Option| {
+ let (Some(a), Some(f)) = (cur_v, first_v) else { return };
+ let (Some(pa), Some(pf)) = (net.vertices.get(a).map(Vertex::point), net.vertices.get(f).map(Vertex::point)) else { return };
+ if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() {
+ return;
+ }
+ for s in net.segments.iter_mut() {
+ if s.b == a {
+ s.b = f;
+ }
+ }
+ net.vertices.pop();
+ all_loops.push(std::mem::take(loop_segs));
+ };
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
+ close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
loop_segs.clear();
let v = push_v(&mut net, p);
first_v = Some(v);
@@ -129,7 +176,9 @@ impl VectorNetwork {
let (Some(a), Some(f)) = (cur_v, first_v) else { continue };
let close_pt = net.vertices.get(a).map(Vertex::point);
let first_pt = net.vertices.get(f).map(Vertex::point);
- if close_pt.is_some() && close_pt == first_pt && a != f {
+ // The end vertex lands on the start (up to float noise): merge them.
+ let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6);
+ if same && a != f {
// Merge the duplicate end vertex into the first one.
for s in net.segments.iter_mut() {
if s.b == a {
@@ -150,12 +199,93 @@ impl VectorNetwork {
}
}
}
+ close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
if !all_loops.is_empty() {
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
}
net
}
+ /// Segments touching vertex `v`: (segment index, `true` when `v` is the segment's `a` end).
+ pub fn incident(&self, v: usize) -> Vec<(usize, bool)> {
+ self.segments
+ .iter()
+ .enumerate()
+ .filter_map(|(i, s)| {
+ if s.a == v {
+ Some((i, true))
+ } else if s.b == v {
+ Some((i, false))
+ } else {
+ None
+ }
+ })
+ .collect()
+ }
+
+ /// The handle (relative to its vertex) at one end of a segment.
+ pub fn handle(&self, seg: usize, start: bool) -> Option<(f64, f64)> {
+ let s = self.segments.get(seg)?;
+ Some(if start { s.ta } else { s.tb })
+ }
+
+ pub fn set_handle(&mut self, seg: usize, start: bool, t: (f64, f64)) {
+ if let Some(s) = self.segments.get_mut(seg) {
+ if start {
+ s.ta = t;
+ } else {
+ s.tb = t;
+ }
+ }
+ }
+
+ /// The vertex's handle mirroring: its explicit setting, else inferred from its two handles
+ /// (opposite and equal → angle and length, opposite → angle, otherwise none).
+ pub fn mirroring(&self, v: usize) -> HandleMirroring {
+ if let Some(m) = self.vertices.get(v).and_then(|x| x.mirror) {
+ return m;
+ }
+ let inc = self.incident(v);
+ let [(s1, a1), (s2, a2)] = inc.as_slice() else { return HandleMirroring::None };
+ let (Some(h1), Some(h2)) = (self.handle(*s1, *a1), self.handle(*s2, *a2)) else { return HandleMirroring::None };
+ let (l1, l2) = (h1.0.hypot(h1.1), h2.0.hypot(h2.1));
+ if l1 < 1e-9 || l2 < 1e-9 {
+ return HandleMirroring::None;
+ }
+ // Opposite directions: the cross product is ~0 and the dot product negative.
+ let cross = (h1.0 * h2.1 - h1.1 * h2.0) / (l1 * l2);
+ let dot = (h1.0 * h2.0 + h1.1 * h2.1) / (l1 * l2);
+ if cross.abs() > 1e-3 || dot > -0.999 {
+ HandleMirroring::None
+ } else if (l1 - l2).abs() <= 1e-6 * l1.max(l2).max(1.0) {
+ HandleMirroring::AngleAndLength
+ } else {
+ HandleMirroring::Angle
+ }
+ }
+
+ /// Segment `i` as a cubic bezier.
+ pub fn cubic(&self, i: usize) -> Option {
+ let (a, c1, c2, b) = self.seg_points(self.segments.get(i)?)?;
+ Some(figcraft_geom::kurbo::CubicBez::new(a, c1, c2, b))
+ }
+
+ /// One closed loop of segments as a path.
+ pub fn loop_path(&self, lp: &[usize]) -> BezPath {
+ let mut path = BezPath::new();
+ self.append_loop(&mut path, lp);
+ path
+ }
+
+ /// Whether the rendered fill covers `p` (the renderer fills every region with one rule: even-odd
+ /// when any region asks for it).
+ pub fn fill_contains(&self, p: Point) -> bool {
+ use figcraft_geom::Shape;
+ let path = self.fill_path();
+ let w = path.winding(p);
+ if self.regions.iter().any(|r| r.winding == WindingRule::EvenOdd) { w % 2 != 0 } else { w != 0 }
+ }
+
fn seg_points(&self, s: &Segment) -> Option<(Point, Point, Point, Point)> {
let a = self.vertices.get(s.a)?.point();
let b = self.vertices.get(s.b)?.point();
diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs
new file mode 100644
index 00000000..3eb4d494
--- /dev/null
+++ b/crates/doc/src/vector_faces.rs
@@ -0,0 +1,517 @@
+//! Faces of a vector network: the closed areas between its segments, which the paint bucket fills
+//! and unfills.
+//!
+//! Faces come from a half-edge walk of the planar graph: at each vertex the walk turns to the next
+//! segment in angular order, which traces every bounded area once (positive signed area) and the
+//! outside of each connected piece once (negative). Pieces nested inside a face become its holes.
+//! Segments are assumed not to cross each other; crossing networks still give well-formed (if
+//! approximate) faces.
+
+use figcraft_geom::kurbo::{ParamCurve, ParamCurveDeriv, PathEl};
+use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
+
+use crate::vector::{Region, VectorNetwork, WindingRule};
+
+/// A bounded area: its outer loop first, then the outer loops of pieces nested inside it.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Face {
+ /// Loops of segment indices.
+ pub loops: Vec>,
+ /// Enclosed area (the outer loop's minus the holes').
+ pub area: f64,
+ /// The face's outline (all loops); fill it even-odd.
+ pub path: BezPath,
+}
+
+impl Face {
+ pub fn contains(&self, p: Point) -> bool {
+ self.path.winding(p) % 2 != 0
+ }
+
+ /// The fill region for this face.
+ pub fn region(&self) -> Region {
+ Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
+ }
+
+ /// Some point strictly inside the face (not in a hole).
+ pub fn interior_point(&self) -> Option {
+ interior_point(&self.path)
+ }
+}
+
+/// A traced cycle of half-edges (segment index, forward?).
+struct Cycle {
+ half_edges: Vec<(usize, bool)>,
+ area: f64,
+ component: usize,
+}
+
+impl VectorNetwork {
+ /// Every bounded face, holes included.
+ pub fn faces(&self) -> Vec {
+ let cycles = self.cycles();
+ // Each connected piece's outside: its most negative cycle.
+ let mut outside: std::collections::BTreeMap = std::collections::BTreeMap::new();
+ for c in cycles.iter().filter(|c| c.area < -AREA_EPS) {
+ let e = outside.entry(c.component).or_insert(c);
+ if c.area < e.area {
+ *e = c;
+ }
+ }
+ // A piece's outer boundary (loops + path), only for pieces that enclose any area.
+ let boundaries: Vec<(usize, Vec>, BezPath, f64)> = outside
+ .iter()
+ .map(|(comp, c)| {
+ let loops = self.split_loops(&c.half_edges);
+ let path = self.loops_path(&loops);
+ (*comp, loops, path, -c.area)
+ })
+ .filter(|(_, loops, _, _)| !loops.is_empty())
+ .collect();
+ let mut faces = Vec::new();
+ for c in cycles.iter().filter(|c| c.area > AREA_EPS) {
+ let loops = self.split_loops(&c.half_edges);
+ if loops.is_empty() {
+ continue;
+ }
+ let outer = self.loops_path(&loops);
+ // Pieces directly inside this face (not inside another piece that is) are its holes.
+ let inside: Vec<&(usize, Vec>, BezPath, f64)> = boundaries
+ .iter()
+ .filter(|(comp, ..)| *comp != c.component)
+ .filter(|(comp, ..)| self.component_point(&cycles, *comp).is_some_and(|p| outer.winding(p) % 2 != 0))
+ .collect();
+ let mut face = Face { loops, area: c.area, path: outer };
+ for (comp, hl, hp, ha) in &inside {
+ let nested = inside
+ .iter()
+ .any(|(other, _, op, _)| other != comp && self.component_point(&cycles, *comp).is_some_and(|p| op.winding(p) % 2 != 0));
+ if nested {
+ continue;
+ }
+ face.loops.extend(hl.iter().cloned());
+ face.path.extend(hp.elements().iter().copied());
+ face.area -= ha;
+ }
+ faces.push(face);
+ }
+ faces
+ }
+
+ /// The smallest face containing `p`.
+ pub fn face_at(&self, p: Point) -> Option {
+ self.faces().into_iter().filter(|f| f.contains(p)).min_by(|a, b| a.area.total_cmp(&b.area))
+ }
+
+ /// The paint bucket: fill the face under `p`, or unfill it when it's filled. Returns the new
+ /// state (`Some(true)` = filled), or `None` when `p` isn't inside a closed area.
+ ///
+ /// Regions that aren't single faces (e.g. from a converted shape or an import) are first
+ /// re-expressed as one region per filled face, so any face can be toggled on its own.
+ pub fn paint(&mut self, p: Point) -> Option {
+ let face = self.face_at(p)?;
+ let key = |loops: &[Vec]| -> Vec> {
+ let mut k: Vec> = loops
+ .iter()
+ .map(|l| {
+ let mut l = l.clone();
+ l.sort_unstable();
+ l
+ })
+ .collect();
+ k.sort();
+ k
+ };
+ let faces = self.faces();
+ let face_keys: Vec>> = faces.iter().map(|f| key(&f.loops)).collect();
+ if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
+ let filled: Vec = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
+ self.regions = filled;
+ }
+ let k = key(&face.loops);
+ if let Some(i) = self.regions.iter().position(|r| key(&r.loops) == k) {
+ self.regions.remove(i);
+ Some(false)
+ } else {
+ self.regions.push(face.region());
+ Some(true)
+ }
+ }
+
+ /// Trace every half-edge cycle.
+ fn cycles(&self) -> Vec {
+ let n = self.segments.len();
+ let nv = self.vertices.len();
+ let valid = |i: usize| self.segments.get(i).is_some_and(|s| s.a < nv && s.b < nv);
+ // Outgoing half-edges per vertex, sorted by angle.
+ let mut out: Vec> = vec![Vec::new(); nv];
+ for i in (0..n).filter(|i| valid(*i)) {
+ for fwd in [true, false] {
+ let Some((origin, dir, along)) = self.half_edge_dir(i, fwd) else { continue };
+ if let Some(list) = out.get_mut(origin) {
+ list.push((dir.y.atan2(dir.x), along.y.atan2(along.x), i, fwd));
+ }
+ }
+ }
+ for list in &mut out {
+ list.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
+ }
+ let origin = |i: usize, fwd: bool| self.segments.get(i).map(|s| if fwd { s.a } else { s.b });
+ let dest = |i: usize, fwd: bool| self.segments.get(i).map(|s| if fwd { s.b } else { s.a });
+ // The walk: arrive at v along (i, fwd); leave by the edge before the twin in angular order.
+ let next = |i: usize, fwd: bool| -> Option<(usize, bool)> {
+ let v = dest(i, fwd)?;
+ let list = out.get(v)?;
+ let k = list.iter().position(|e| e.2 == i && e.3 != fwd)?;
+ let e = list.get((k + list.len() - 1) % list.len())?;
+ Some((e.2, e.3))
+ };
+ let comp = self.components();
+ let idx = |i: usize, fwd: bool| i * 2 + usize::from(!fwd);
+ let mut seen = vec![false; n * 2];
+ let mut cycles = Vec::new();
+ for i in (0..n).filter(|i| valid(*i)) {
+ for fwd in [true, false] {
+ if seen.get(idx(i, fwd)).copied().unwrap_or(true) {
+ continue;
+ }
+ let mut half_edges = Vec::new();
+ let mut cur = (i, fwd);
+ let mut closed = false;
+ for _ in 0..=n * 2 {
+ if let Some(s) = seen.get_mut(idx(cur.0, cur.1)) {
+ *s = true;
+ }
+ half_edges.push(cur);
+ let Some(nx) = next(cur.0, cur.1) else { break };
+ if nx == (i, fwd) {
+ closed = true;
+ break;
+ }
+ if seen.get(idx(nx.0, nx.1)).copied().unwrap_or(true) {
+ break;
+ }
+ cur = nx;
+ }
+ if !closed {
+ continue;
+ }
+ let area = self.half_edge_path(&half_edges).area();
+ let component = origin(i, fwd).and_then(|v| comp.get(v).copied()).unwrap_or(0);
+ cycles.push(Cycle { half_edges, area: if area.is_finite() { area } else { 0.0 }, component });
+ }
+ }
+ cycles
+ }
+
+ /// Where half-edge (i, fwd) starts, its initial direction and a direction a little along it
+ /// (tie-break for tangent curves).
+ fn half_edge_dir(&self, i: usize, fwd: bool) -> Option<(usize, Vec2, Vec2)> {
+ let s = self.segments.get(i)?;
+ let c = self.cubic(i)?;
+ let (origin, p0, d, along) = if fwd {
+ (s.a, c.p0, c.deriv().eval(0.0).to_vec2(), c.eval(0.25) - c.p0)
+ } else {
+ (s.b, c.p3, -c.deriv().eval(1.0).to_vec2(), c.eval(0.75) - c.p3)
+ };
+ let fallback = if fwd { [c.p2 - p0, c.p3 - p0] } else { [c.p1 - p0, c.p0 - p0] };
+ // A zero-length segment gets a nominal direction; the walk stays a permutation, so its
+ // cycles still close (and its angle is irrelevant at the usual two-segment vertex).
+ let nominal = if fwd { Vec2::new(1.0, 0.0) } else { Vec2::new(-1.0, 0.0) };
+ let dir = std::iter::once(d).chain(fallback).find(|v| v.hypot() > 1e-9).unwrap_or(nominal);
+ Some((origin, dir, if along.hypot() > 1e-9 { along } else { dir }))
+ }
+
+ fn half_edge_path(&self, half_edges: &[(usize, bool)]) -> BezPath {
+ let mut path = BezPath::new();
+ for (k, (i, fwd)) in half_edges.iter().enumerate() {
+ let Some(c) = self.cubic(*i) else { continue };
+ let c = if *fwd { c } else { figcraft_geom::kurbo::CubicBez::new(c.p3, c.p2, c.p1, c.p0) };
+ if k == 0 {
+ path.move_to(c.p0);
+ }
+ path.curve_to(c.p1, c.p2, c.p3);
+ }
+ path.close_path();
+ path
+ }
+
+ /// A cycle's segments as closed loops: segments walked twice (dangling paths, bridges between
+ /// loops) are dropped and what's left is chained into loops.
+ fn split_loops(&self, half_edges: &[(usize, bool)]) -> Vec> {
+ let count = |i: usize| half_edges.iter().filter(|h| h.0 == i).count();
+ let kept: Vec<(usize, bool)> = half_edges.iter().copied().filter(|h| count(h.0) == 1).collect();
+ let ends = |h: (usize, bool)| self.segments.get(h.0).map(|s| if h.1 { (s.a, s.b) } else { (s.b, s.a) });
+ let mut used = vec![false; kept.len()];
+ let mut loops = Vec::new();
+ for start in 0..kept.len() {
+ if used.get(start).copied().unwrap_or(true) {
+ continue;
+ }
+ let Some(first) = kept.get(start).copied() else { continue };
+ let Some((v0, mut v)) = ends(first) else { continue };
+ if let Some(u) = used.get_mut(start) {
+ *u = true;
+ }
+ let mut lp = vec![first.0];
+ let mut pos = start;
+ while v != v0 {
+ // Prefer the next half-edge in walk order that continues from `v`.
+ let found = (1..=kept.len())
+ .map(|d| (pos + d) % kept.len())
+ .find(|j| !used.get(*j).copied().unwrap_or(true) && kept.get(*j).and_then(|h| ends(*h)).is_some_and(|(a, _)| a == v));
+ let Some(j) = found else { break };
+ if let Some(u) = used.get_mut(j) {
+ *u = true;
+ }
+ let Some(h) = kept.get(j).copied() else { break };
+ lp.push(h.0);
+ v = ends(h).map(|e| e.1).unwrap_or(v0);
+ pos = j;
+ }
+ if v == v0 {
+ loops.push(lp);
+ }
+ }
+ loops
+ }
+
+ fn loops_path(&self, loops: &[Vec]) -> BezPath {
+ let mut path = BezPath::new();
+ for lp in loops {
+ path.extend(self.loop_path(lp).elements().iter().copied());
+ }
+ path
+ }
+
+ /// Connected piece of every vertex.
+ fn components(&self) -> Vec {
+ let nv = self.vertices.len();
+ let mut parent: Vec = (0..nv).collect();
+ fn find(parent: &mut [usize], mut x: usize) -> usize {
+ for _ in 0..parent.len() {
+ let p = parent.get(x).copied().unwrap_or(x);
+ if p == x {
+ break;
+ }
+ let gp = parent.get(p).copied().unwrap_or(p);
+ if let Some(px) = parent.get_mut(x) {
+ *px = gp;
+ }
+ x = gp;
+ }
+ x
+ }
+ for s in &self.segments {
+ if s.a < nv && s.b < nv {
+ let (ra, rb) = (find(&mut parent, s.a), find(&mut parent, s.b));
+ if let Some(r) = parent.get_mut(ra) {
+ *r = rb;
+ }
+ }
+ }
+ (0..nv).map(|v| find(&mut parent, v)).collect()
+ }
+
+ /// A vertex of piece `comp` (for nesting tests).
+ fn component_point(&self, cycles: &[Cycle], comp: usize) -> Option {
+ let c = cycles.iter().find(|c| c.component == comp)?;
+ let (i, fwd) = c.half_edges.first().copied()?;
+ let s = self.segments.get(i)?;
+ self.vertices.get(if fwd { s.a } else { s.b }).map(|v| v.point())
+ }
+}
+
+const AREA_EPS: f64 = 1e-9;
+
+/// A point inside a closed path under the even-odd rule: the middle of the widest span on a few
+/// horizontal scanlines.
+pub fn interior_point(path: &BezPath) -> Option {
+ let bb: Rect = path.bounding_box();
+ if !(bb.width() > 0.0 && bb.height() > 0.0 && bb.width().is_finite() && bb.height().is_finite()) {
+ return None;
+ }
+ let mut lines: Vec<(Point, Point)> = Vec::new();
+ let (mut start, mut last) = (Point::ZERO, Point::ZERO);
+ figcraft_geom::kurbo::flatten(path.iter(), bb.width().max(bb.height()) * 1e-3, |el| match el {
+ PathEl::MoveTo(p) => {
+ start = p;
+ last = p;
+ }
+ PathEl::LineTo(p) => {
+ lines.push((last, p));
+ last = p;
+ }
+ PathEl::ClosePath => {
+ lines.push((last, start));
+ last = start;
+ }
+ _ => {}
+ });
+ let mut best: Option<(f64, Point)> = None;
+ for k in [0.5, 0.3, 0.7, 0.15, 0.85, 0.05, 0.95] {
+ // An irrational nudge keeps the scanline off vertices.
+ let y = bb.y0 + bb.height() * (k + 0.000_123_456_7);
+ let mut xs: Vec = lines
+ .iter()
+ .filter(|(p, q)| (p.y <= y && y < q.y) || (q.y <= y && y < p.y))
+ .map(|(p, q)| p.x + (y - p.y) * (q.x - p.x) / (q.y - p.y))
+ .filter(|x| x.is_finite())
+ .collect();
+ xs.sort_by(f64::total_cmp);
+ for pair in xs.chunks_exact(2) {
+ let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
+ let w = b - a;
+ if w > best.map(|b| b.0).unwrap_or(1e-9) {
+ best = Some((w, Point::new((a + b) / 2.0, y)));
+ }
+ }
+ }
+ best.map(|b| b.1)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::vector::{Segment, Vertex};
+
+ fn square(x: f64, y: f64, s: f64) -> VectorNetwork {
+ VectorNetwork::from_points(&[Point::new(x, y), Point::new(x + s, y), Point::new(x + s, y + s), Point::new(x, y + s)], true)
+ }
+
+ fn merge(a: &VectorNetwork, b: &VectorNetwork) -> VectorNetwork {
+ let mut n = a.clone();
+ let (nv, ns) = (n.vertices.len(), n.segments.len());
+ n.vertices.extend(b.vertices.iter().copied());
+ n.segments.extend(b.segments.iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..*s }));
+ n.regions
+ .extend(b.regions.iter().map(|r| Region { loops: r.loops.iter().map(|l| l.iter().map(|i| i + ns).collect()).collect(), ..r.clone() }));
+ n
+ }
+
+ #[test]
+ fn square_has_one_face_and_the_bucket_toggles_it() {
+ let mut n = square(0.0, 0.0, 100.0);
+ n.regions.clear();
+ let f = n.faces();
+ assert_eq!(f.len(), 1);
+ assert!((f[0].area - 10000.0).abs() < 1e-6);
+ assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(true));
+ assert!(n.fill_contains(Point::new(50.0, 50.0)));
+ assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(false));
+ assert!(n.regions.is_empty());
+ assert_eq!(n.paint(Point::new(150.0, 50.0)), None, "outside every face");
+ }
+
+ #[test]
+ fn a_diagonal_splits_the_square_into_two_faces() {
+ let mut n = square(0.0, 0.0, 100.0);
+ n.regions.clear();
+ n.segments.push(Segment::line(0, 2));
+ let f = n.faces();
+ assert_eq!(f.len(), 2);
+ assert!(f.iter().all(|f| (f.area - 5000.0).abs() < 1e-6));
+ assert_eq!(n.paint(Point::new(75.0, 25.0)), Some(true));
+ assert!(n.fill_contains(Point::new(75.0, 25.0)));
+ assert!(!n.fill_contains(Point::new(25.0, 75.0)));
+ }
+
+ #[test]
+ fn unfilling_part_of_a_converted_shape_keeps_the_rest() {
+ // A filled square (one region over the outline) cut by a diagonal.
+ let mut n = square(0.0, 0.0, 100.0);
+ n.segments.push(Segment::line(0, 2));
+ assert!(n.fill_contains(Point::new(75.0, 25.0)));
+ assert_eq!(n.paint(Point::new(75.0, 25.0)), Some(false));
+ assert!(!n.fill_contains(Point::new(75.0, 25.0)));
+ assert!(n.fill_contains(Point::new(25.0, 75.0)));
+ }
+
+ #[test]
+ fn nested_pieces_become_holes() {
+ let mut outer = square(0.0, 0.0, 100.0);
+ outer.regions.clear();
+ let mut inner = square(25.0, 25.0, 50.0);
+ inner.regions.clear();
+ let mut n = merge(&outer, &inner);
+ let mut areas: Vec = n.faces().iter().map(|f| f.area).collect();
+ areas.sort_by(f64::total_cmp);
+ assert_eq!(areas.len(), 2);
+ assert!((areas[0] - 2500.0).abs() < 1e-6 && (areas[1] - 7500.0).abs() < 1e-6, "{areas:?}");
+ assert_eq!(n.paint(Point::new(10.0, 10.0)), Some(true));
+ assert!(n.fill_contains(Point::new(10.0, 10.0)));
+ assert!(!n.fill_contains(Point::new(50.0, 50.0)), "the ring has a hole");
+ assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(true));
+ assert!(n.fill_contains(Point::new(50.0, 50.0)));
+ // A third square inside the inner one is the inner face's hole, not the ring's.
+ let mut core = square(45.0, 45.0, 10.0);
+ core.regions.clear();
+ let n3 = merge(&n, &core);
+ let ring = n3.face_at(Point::new(10.0, 10.0)).unwrap();
+ assert_eq!(ring.loops.len(), 2);
+ assert!((ring.area - 7500.0).abs() < 1e-6);
+ let mid = n3.face_at(Point::new(30.0, 30.0)).unwrap();
+ assert!((mid.area - 2400.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn dangling_paths_and_bridges_dont_break_faces() {
+ let mut n = square(0.0, 0.0, 100.0);
+ n.regions.clear();
+ // A spur into the square.
+ n.vertices.push(Vertex::new(50.0, 50.0));
+ n.segments.push(Segment::line(0, 4));
+ // A second square inside, bridged to the spur's end.
+ let inner = square(60.0, 60.0, 20.0);
+ let mut m = merge(&n, &inner);
+ m.regions.clear();
+ m.segments.push(Segment::line(4, 5));
+ let f = m.face_at(Point::new(10.0, 90.0)).unwrap();
+ assert!((f.area - (10000.0 - 400.0)).abs() < 1e-6, "{}", f.area);
+ assert_eq!(f.loops.len(), 2);
+ assert_eq!(m.paint(Point::new(10.0, 90.0)), Some(true));
+ assert!(m.fill_contains(Point::new(10.0, 90.0)));
+ assert!(!m.fill_contains(Point::new(70.0, 70.0)));
+ }
+
+ #[test]
+ fn curves_and_junk_networks() {
+ let mut circle = figcraft_geom::kurbo::Ellipse::new((50.0, 50.0), (50.0, 50.0), 0.0).to_path(0.01);
+ circle.close_path();
+ let mut n = VectorNetwork::from_path(&circle);
+ let close = n.vertices.iter().enumerate().any(|(i, a)| n.vertices.iter().skip(i + 1).any(|b| (a.point() - b.point()).hypot() < 1e-3));
+ assert!(!close, "the closing point merges into the first");
+ n.regions.clear();
+ eprintln!(
+ "{:?}
+{:?}",
+ n.vertices, n.segments
+ );
+ for c in n.cycles() {
+ eprintln!("cycle {:?} area {}", c.half_edges, c.area);
+ }
+ let f = n.faces();
+ assert_eq!(f.len(), 1);
+ assert!((f[0].area - std::f64::consts::PI * 2500.0).abs() < 5.0, "{}", f[0].area);
+ // Without the ClosePath (kurbo's ellipse outline) it's still one closed, filled loop.
+ let open = figcraft_geom::kurbo::Ellipse::new((50.0, 50.0), (50.0, 50.0), 0.0).to_path(0.01);
+ let o = VectorNetwork::from_path(&open);
+ assert_eq!(o.vertices.len(), n.vertices.len());
+ assert_eq!(o.regions.len(), 1);
+ assert!(o.fill_contains(Point::new(50.0, 50.0)));
+ assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(true));
+ // Junk: NaN coordinates, dangling indices, self-loops, coincident points.
+ let mut j = square(0.0, 0.0, 10.0);
+ j.vertices.push(Vertex::new(f64::NAN, 1.0));
+ j.segments.push(Segment::line(0, 4));
+ j.segments.push(Segment::line(2, 99));
+ j.segments.push(Segment::line(1, 1));
+ j.segments.push(Segment { a: 3, b: 3, ta: (5.0, 5.0), tb: (-5.0, 5.0) });
+ j.vertices.push(Vertex::new(0.0, 0.0));
+ j.segments.push(Segment::line(0, 5));
+ let _ = j.faces();
+ let _ = j.paint(Point::new(5.0, 5.0));
+ let _ = j.paint(Point::new(f64::NAN, 5.0));
+ assert!(interior_point(&BezPath::new()).is_none());
+ }
+}
diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs
index 299331fe..1d2878bf 100644
--- a/crates/engine/src/cmd/structure.rs
+++ b/crates/engine/src/cmd/structure.rs
@@ -70,8 +70,12 @@ pub fn specs() -> Vec {
}),
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
- spec!("vector.join", "Join selection", "{ids?}", true, join),
- spec!("vector.smoothJoin", "Smooth join selection", "{ids?}", true, join),
+ spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
+ crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p))
+ }),
+ spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| {
+ crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p))
+ }),
]
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index dd469c7b..41a359fa 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -205,8 +205,8 @@ pub struct DocState {
pub text_edit: Option,
/// Hovered node (canvas hover outline).
pub hover: Option,
- /// Vector edit mode: the vector being edited and its selected vertices.
- pub vector_edit: Option<(NodeId, Vec)>,
+ /// Vector edit mode: the vector being edited, its selected points and the edit tool.
+ pub vector_edit: Option,
pub uid: u64,
}
@@ -274,6 +274,15 @@ impl DocState {
{
self.text_edit = None;
}
+ // Vector edit mode ends when its layer is gone (or no longer a vector, e.g. undone).
+ match self.vector_edit.as_mut() {
+ Some(ve) if doc.page_of(ve.id) == Some(page) => match vectoredit::network(&doc, ve.id) {
+ Some(net) => ve.clamp(net),
+ None => self.vector_edit = None,
+ },
+ Some(_) => self.vector_edit = None,
+ None => {}
+ }
}
}
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
index 8247c843..a721aebb 100644
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -171,7 +171,7 @@ impl PointerEvent {
pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self {
Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom }
}
- fn p(&self) -> Point {
+ pub(crate) fn p(&self) -> Point {
Point::new(self.x, self.y)
}
fn tol(&self) -> f64 {
@@ -218,10 +218,15 @@ pub struct Overlay {
pub drop_target: Option,
/// Auto layout insertion indicator (x0, y0, x1, y1).
pub insert_line: Option<[f64; 4]>,
+ /// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the
+ /// paint bucket would fill (page-space polygons).
+ pub vector_hover: Option,
+ pub lasso: Vec<[f64; 2]>,
+ pub face: Vec>,
}
#[derive(Clone, Debug)]
-struct ResizeStart {
+pub(crate) struct ResizeStart {
/// Node id → (world transform, w, h) at the start.
nodes: Vec<(NodeId, Affine, f64, f64)>,
/// Single node: its world transform and size; else the AABB.
@@ -231,7 +236,7 @@ struct ResizeStart {
}
#[derive(Clone, Debug, Default)]
-enum Gesture {
+pub(crate) enum Gesture {
#[default]
None,
Marquee {
@@ -280,26 +285,8 @@ enum Gesture {
PenDrag {
anchor: Point,
},
- VertexDrag {
- start: Point,
- id: NodeId,
- verts: Vec,
- },
- HandleDrag {
- id: NodeId,
- seg: usize,
- start_end: bool,
- },
- Bend {
- id: NodeId,
- seg: usize,
- t: f64,
- },
- VertexMarquee {
- start: Point,
- id: NodeId,
- base: Vec,
- },
+ /// Vector edit mode gestures (handled in `vectoredit`).
+ Vector(crate::vectoredit::VGesture),
}
/// The pen tool's in-progress path (page coordinates).
@@ -315,7 +302,7 @@ pub struct PenPath {
#[derive(Clone, Debug, Default)]
pub struct ToolState {
pub tool: ToolId,
- gesture: Gesture,
+ pub(crate) gesture: Gesture,
pub overlay: Overlay,
pub pen: Option,
/// Tool to return to after a temporary tool (space → hand).
@@ -412,6 +399,9 @@ impl Session {
if self.tool.tool == ToolId::Pen && t != ToolId::Pen {
let _ = self.pen_finish();
}
+ if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
+ let _ = crate::vectoredit::end(self);
+ }
self.tool.tool = t;
self.tool.gesture = Gesture::None;
self.tool.overlay = Overlay::default();
@@ -425,17 +415,9 @@ impl Session {
/// Feed one pointer event to the current tool.
pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> {
- let tool = if ev.kind == PointerKind::Down && ev.ctrl && !ev.cmd && false { ToolId::Hand } else { self.tool.tool };
- if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().map(|d| d.vector_edit.is_some()).unwrap_or(false) {
- match ev.kind {
- PointerKind::Down => return self.vector_down(ev),
- PointerKind::DoubleClick => return crate::vectoredit::end(self),
- PointerKind::Hover => {
- self.tool.overlay.cursor = Cursor::Pen;
- return Ok(());
- }
- _ => {}
- }
+ let tool = self.tool.tool;
+ if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? {
+ return Ok(());
}
match ev.kind {
PointerKind::Hover => self.hover(ev),
@@ -776,60 +758,7 @@ impl Session {
textedit::set_caret(self, idx, true)?;
Ok(())
}
- Gesture::VertexDrag { start, id, verts } => {
- let (id, verts) = (*id, verts.clone());
- let w = self.doc()?.world_transform(id);
- let mut d = ev.p() - *start;
- if ev.shift {
- if d.x.abs() > d.y.abs() {
- d.y = 0.0;
- } else {
- d.x = 0.0;
- }
- }
- // Page delta → local delta.
- let inv = w.inverse();
- let ld = inv * (Point::ZERO + d) - inv * Point::ZERO;
- self.preview(|doc, _| {
- crate::vectoredit::move_vertices(doc, id, &verts, ld)?;
- crate::vectoredit::refit(doc, id)
- })?;
- Ok(())
- }
- Gesture::HandleDrag { id, seg, start_end } => {
- let (id, seg, se) = (*id, *seg, *start_end);
- let lp = self.doc()?.world_transform(id).inverse() * ev.p();
- let alt = ev.alt;
- self.preview(|doc, _| {
- crate::vectoredit::set_handle(doc, id, seg, se, lp, alt)?;
- crate::vectoredit::refit(doc, id)
- })?;
- Ok(())
- }
- Gesture::Bend { id, seg, t } => {
- let (id, seg, t) = (*id, *seg, *t);
- let lp = self.doc()?.world_transform(id).inverse() * ev.p();
- self.preview(|doc, _| {
- crate::vectoredit::bend(doc, id, seg, t, lp)?;
- crate::vectoredit::refit(doc, id)
- })?;
- Ok(())
- }
- Gesture::VertexMarquee { start, id, base } => {
- let r = Rect::from_points(*start, ev.p());
- self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]);
- let mut v = base.clone();
- for i in crate::vectoredit::vertices_in(self.doc()?, *id, r) {
- if !v.contains(&i) {
- v.push(i);
- }
- }
- let id = *id;
- let ds = self.ds_mut()?;
- ds.vector_edit = Some((id, v));
- ds.revision += 1;
- Ok(())
- }
+ Gesture::Vector(_) => Ok(()),
Gesture::PenDrag { anchor } => {
let out = ev.p() - *anchor;
if let Some(pen) = self.tool.pen.as_mut()
@@ -1238,8 +1167,7 @@ impl Session {
}
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
Gesture::PenDrag { .. } => {}
- Gesture::VertexDrag { .. } | Gesture::HandleDrag { .. } | Gesture::Bend { .. } => self.commit_interaction()?,
- Gesture::VertexMarquee { .. } => {}
+ Gesture::Vector(_) => self.commit_interaction()?,
}
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
@@ -1508,79 +1436,6 @@ impl Session {
Ok(())
}
- fn vector_down(&mut self, ev: &PointerEvent) -> Result<()> {
- let Some((id, sel)) = self.ds()?.vector_edit.clone() else { return Ok(()) };
- let doc = self.doc()?.clone();
- let tol = 6.0 / ev.zoom.max(1e-6);
- match crate::vectoredit::hit(&doc, id, &sel, ev.p(), tol) {
- Some(crate::vectoredit::VHit::Vertex(v)) => {
- let verts = if ev.shift {
- let mut s2 = sel.clone();
- if let Some(i) = s2.iter().position(|x| *x == v) {
- s2.remove(i);
- } else {
- s2.push(v);
- }
- s2
- } else if sel.contains(&v) {
- sel.clone()
- } else {
- vec![v]
- };
- let ds = self.ds_mut()?;
- ds.vector_edit = Some((id, verts.clone()));
- self.begin_interaction("Move points")?;
- self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts };
- }
- Some(crate::vectoredit::VHit::Handle { seg, start }) => {
- self.begin_interaction("Edit handle")?;
- self.tool.gesture = Gesture::HandleDrag { id, seg, start_end: start };
- }
- Some(crate::vectoredit::VHit::Segment { seg, t }) => {
- if ev.cmd {
- self.begin_interaction("Bend")?;
- self.tool.gesture = Gesture::Bend { id, seg, t };
- } else if ev.alt || ev.shift {
- // Add a point on the segment.
- self.begin_interaction("Add point")?;
- let mut v = 0;
- self.preview(|doc, _| {
- v = crate::vectoredit::split_segment(doc, id, seg, t)?;
- Ok(())
- })?;
- let ds = self.ds_mut()?;
- ds.vector_edit = Some((id, vec![v]));
- self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts: vec![v] };
- } else {
- // Select the segment's end points and drag them together.
- let net_seg = match &doc.node(id).map(|n| &n.kind) {
- Some(NodeKind::Vector { network }) => network.segments.get(seg).copied(),
- _ => None,
- };
- let verts: Vec = net_seg.map(|s| vec![s.a, s.b]).unwrap_or_default();
- let ds = self.ds_mut()?;
- ds.vector_edit = Some((id, verts.clone()));
- self.begin_interaction("Move segment")?;
- self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts };
- }
- }
- None => {
- // Outside the shape entirely (and not shift): leave edit mode.
- let inside = doc.world_render_bounds(id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()));
- if !inside && !ev.shift {
- crate::vectoredit::end(self)?;
- return self.move_down(ev);
- }
- let base = if ev.shift { sel } else { vec![] };
- let ds = self.ds_mut()?;
- ds.vector_edit = Some((id, base.clone()));
- ds.revision += 1;
- self.tool.gesture = Gesture::VertexMarquee { start: ev.p(), id, base };
- }
- }
- Ok(())
- }
-
/// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect.
pub fn escape(&mut self) -> Result<()> {
if self.tool.pen.is_some() {
@@ -1595,7 +1450,7 @@ impl Session {
return textedit::end(self);
}
if self.ds()?.vector_edit.is_some() {
- return crate::vectoredit::end(self);
+ return crate::vectoredit::escape(self);
}
if self.tool.tool != ToolId::Move {
self.set_tool(ToolId::Move);
diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs
index f38adb62..7b7e8fda 100644
--- a/crates/engine/src/vectoredit.rs
+++ b/crates/engine/src/vectoredit.rs
@@ -1,21 +1,114 @@
-//! Vector edit mode: editing a vector network's vertices and bezier handles on the canvas.
+//! Vector edit mode: editing a vector network's points, segments and bezier handles on the canvas,
+//! with its own tools (move, pen, bend, paint bucket, lasso, cut).
//!
//! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent).
//! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the
-//! network again.
+//! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`.
+
+pub mod ops;
+mod pointer;
+#[cfg(test)]
+mod tests;
use figcraft_doc::*;
use figcraft_geom::{Affine, Point, Rect, Vec2};
+use serde::Serialize;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
+pub(crate) use pointer::VGesture;
+
+/// The edit-mode tools (the toolbar shown while editing a vector).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)]
+pub enum VTool {
+ #[default]
+ Move,
+ Pen,
+ Bend,
+ Paint,
+ Lasso,
+ Cut,
+}
+
+impl VTool {
+ pub const ALL: [VTool; 6] = [VTool::Move, VTool::Pen, VTool::Bend, VTool::Paint, VTool::Lasso, VTool::Cut];
+ pub fn id(self) -> &'static str {
+ match self {
+ VTool::Move => "move",
+ VTool::Pen => "pen",
+ VTool::Bend => "bend",
+ VTool::Paint => "paint",
+ VTool::Lasso => "lasso",
+ VTool::Cut => "cut",
+ }
+ }
+ pub fn from_id(s: &str) -> Option {
+ VTool::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s))
+ }
+ pub fn label(self) -> &'static str {
+ match self {
+ VTool::Move => "Move",
+ VTool::Pen => "Pen",
+ VTool::Bend => "Bend",
+ VTool::Paint => "Paint bucket",
+ VTool::Lasso => "Lasso",
+ VTool::Cut => "Cut",
+ }
+ }
+ /// Catalog-style shortcut ("Shift+B"); the Bend tool is held with Cmd (Ctrl elsewhere).
+ pub fn shortcut(self) -> &'static str {
+ match self {
+ VTool::Move => "V",
+ VTool::Pen => "P",
+ VTool::Bend => "Cmd",
+ VTool::Paint => "Shift+B",
+ VTool::Lasso => "Q",
+ VTool::Cut => "X",
+ }
+ }
+}
+
+/// Vector edit mode state: the vector being edited, its selected points and segments, the active
+/// edit tool and the pen's open end.
+#[derive(Clone, Debug, PartialEq)]
+pub struct VectorEdit {
+ pub id: NodeId,
+ pub vertices: Vec,
+ pub segments: Vec,
+ pub tool: VTool,
+ /// Pen: the point the next click continues from.
+ pub anchor: Option,
+ /// Pen: the outgoing handle dragged out of `anchor`, used by the next segment.
+ pub pen_out: Option<(f64, f64)>,
+}
+
+impl VectorEdit {
+ pub fn new(id: NodeId) -> Self {
+ Self { id, vertices: vec![], segments: vec![], tool: VTool::Move, anchor: None, pen_out: None }
+ }
+
+ /// Drop indices that no longer exist (after undo or an edit).
+ pub fn clamp(&mut self, net: &VectorNetwork) {
+ let (nv, ns) = (net.vertices.len(), net.segments.len());
+ self.vertices.retain(|v| *v < nv);
+ self.segments.retain(|s| *s < ns);
+ if self.anchor.is_some_and(|a| a >= nv) {
+ self.anchor = None;
+ self.pen_out = None;
+ }
+ }
+}
+
/// What a pointer is over in edit mode.
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase", tag = "kind")]
pub enum VHit {
- Vertex(usize),
+ Vertex {
+ index: usize,
+ },
/// Handle of segment `seg`; `start` = the handle at the segment's `a` end.
Handle {
seg: usize,
@@ -27,6 +120,28 @@ pub enum VHit {
},
}
+/// A visible handle in page coordinates.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
+pub struct HandleLine {
+ pub seg: usize,
+ pub start: bool,
+ pub vertex: [f64; 2],
+ pub handle: [f64; 2],
+}
+
+/// Page-space geometry for drawing the edit overlay.
+#[derive(Clone, Debug, Default, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EditOverlay {
+ /// Cubic control points per segment.
+ pub segments: Vec<[[f64; 2]; 4]>,
+ pub vertices: Vec<[f64; 2]>,
+ pub handles: Vec,
+ pub selected_vertices: Vec,
+ pub selected_segments: Vec,
+ pub anchor: Option,
+}
+
/// Convert a shape to an editable vector (keeps paints, transform and look).
pub fn ensure_vector(doc: &mut Document, id: NodeId) -> Result<()> {
let n = doc.get(id)?.clone();
@@ -62,7 +177,7 @@ pub fn refit(doc: &mut Document, id: NodeId) -> Result<()> {
let n = doc.get_mut(id)?;
let NodeKind::Vector { network } = &mut n.kind else { return Ok(()) };
let Some(b) = network.bounds() else { return Ok(()) };
- if !(b.x0.is_finite() && b.y0.is_finite()) {
+ if !(b.x0.is_finite() && b.y0.is_finite() && b.width().is_finite() && b.height().is_finite()) {
return Ok(());
}
if b.x0.abs() > 1e-9 || b.y0.abs() > 1e-9 {
@@ -74,243 +189,75 @@ pub fn refit(doc: &mut Document, id: NodeId) -> Result<()> {
Ok(())
}
-fn network(doc: &Document, id: NodeId) -> Option<&VectorNetwork> {
+/// The network of vector `id`.
+pub fn network(doc: &Document, id: NodeId) -> Option<&VectorNetwork> {
match &doc.node(id)?.kind {
NodeKind::Vector { network } => Some(network),
_ => None,
}
}
-fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
- match &mut doc.node_mut(id)?.kind {
- NodeKind::Vector { network } => Some(network),
- _ => None,
+fn network_mut(doc: &mut Document, id: NodeId) -> Result<&mut VectorNetwork> {
+ match doc.node_mut(id).map(|n| &mut n.kind) {
+ Some(NodeKind::Vector { network }) => Ok(network),
+ _ => Err(EngineError::Other("not a vector".into())),
}
}
-/// Hit test in page coordinates with a tolerance in page units. Handles of selected vertices win,
-/// then vertices, then segments.
-pub fn hit(doc: &Document, id: NodeId, selected: &[usize], p: Point, tol: f64) -> Option {
- let net = network(doc, id)?;
- let w = doc.world_transform(id);
+/// Edit the network of `id`, then re-fit the node.
+pub fn edit(doc: &mut Document, id: NodeId, f: impl FnOnce(&mut VectorNetwork) -> T) -> Result {
+ let out = f(network_mut(doc, id)?);
+ refit(doc, id)?;
+ Ok(out)
+}
+
+/// Handles drawn and hit-tested: at selected points, and at both ends of selected segments.
+fn visible_handles(net: &VectorNetwork, ve: &VectorEdit) -> Vec<(usize, bool)> {
+ let mut out = Vec::new();
+ for (si, s) in net.segments.iter().enumerate() {
+ let seg_on = ve.segments.contains(&si);
+ for (start, vi, t) in [(true, s.a, s.ta), (false, s.b, s.tb)] {
+ if t != (0.0, 0.0) && (seg_on || ve.vertices.contains(&vi)) {
+ out.push((si, start));
+ }
+ }
+ }
+ out
+}
+
+/// Hit test in page coordinates with a tolerance in page units. Visible handles win, then
+/// vertices, then segments.
+pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option {
+ let net = network(doc, ve.id)?;
+ let w = doc.world_transform(ve.id);
let lp = w.inverse() * p;
// Tolerance in local units (approximate for non-uniform scale).
let s = (w.determinant().abs()).sqrt().max(1e-9);
let tl = tol / s;
- for (si, seg) in net.segments.iter().enumerate() {
- for (start, vi, t) in [(true, seg.a, seg.ta), (false, seg.b, seg.tb)] {
- if !selected.contains(&vi) || t == (0.0, 0.0) {
- continue;
- }
- let Some(v) = net.vertices.get(vi) else { continue };
- if (Point::new(v.x + t.0, v.y + t.1) - lp).hypot() <= tl {
- return Some(VHit::Handle { seg: si, start });
- }
+ if !(lp.x.is_finite() && lp.y.is_finite()) {
+ return None;
+ }
+ for (si, start) in visible_handles(net, ve) {
+ let (Some(seg), Some(t)) = (net.segments.get(si), net.handle(si, start)) else { continue };
+ let Some(v) = net.vertices.get(if start { seg.a } else { seg.b }) else { continue };
+ if (Point::new(v.x + t.0, v.y + t.1) - lp).hypot() <= tl {
+ return Some(VHit::Handle { seg: si, start });
}
}
- for (vi, v) in net.vertices.iter().enumerate() {
- if (v.point() - lp).hypot() <= tl {
- return Some(VHit::Vertex(vi));
- }
+ let nearest_vertex =
+ net.vertices.iter().enumerate().map(|(i, v)| (i, (v.point() - lp).hypot())).filter(|(_, d)| *d <= tl).min_by(|a, b| a.1.total_cmp(&b.1));
+ if let Some((index, _)) = nearest_vertex {
+ return Some(VHit::Vertex { index });
}
- use figcraft_geom::kurbo::{CubicBez, ParamCurveNearest};
- for (si, seg) in net.segments.iter().enumerate() {
- let (Some(a), Some(b)) = (net.vertices.get(seg.a), net.vertices.get(seg.b)) else { continue };
- let c = CubicBez::new(a.point(), Point::new(a.x + seg.ta.0, a.y + seg.ta.1), Point::new(b.x + seg.tb.0, b.y + seg.tb.1), b.point());
- let near = c.nearest(lp, 0.01);
- if near.distance_sq.sqrt() <= tl {
- return Some(VHit::Segment { seg: si, t: near.t });
- }
- }
- None
-}
-
-/// Move vertices by `d` (local units); their handles move with them.
-pub fn move_vertices(doc: &mut Document, id: NodeId, idx: &[usize], d: Vec2) -> Result<()> {
- let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?;
- for i in idx {
- if let Some(v) = net.vertices.get_mut(*i) {
- v.x += d.x;
- v.y += d.y;
- }
- }
- Ok(())
-}
-
-/// Set a handle to absolute local position `p`; the opposite handle at the same vertex mirrors
-/// (angle and length) unless `independent`.
-pub fn set_handle(doc: &mut Document, id: NodeId, seg: usize, start: bool, p: Point, independent: bool) -> Result<()> {
- let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?;
- let Some(s) = net.segments.get(seg).copied() else { return Err(EngineError::Other("no such segment".into())) };
- let vi = if start { s.a } else { s.b };
- let Some(v) = net.vertices.get(vi).copied() else { return Ok(()) };
- let rel = (p.x - v.x, p.y - v.y);
- if let Some(sm) = net.segments.get_mut(seg) {
- if start {
- sm.ta = rel;
- } else {
- sm.tb = rel;
- }
- }
- if !independent {
- // Mirror on the other segment meeting at this vertex (smooth node).
- let others: Vec<(usize, bool)> = net
- .segments
- .iter()
- .enumerate()
- .filter(|(i, _)| *i != seg)
- .filter_map(|(i, o)| {
- if o.a == vi {
- Some((i, true))
- } else if o.b == vi {
- Some((i, false))
- } else {
- None
- }
- })
- .collect();
- if let [(oi, ostart)] = others.as_slice()
- && let Some(o) = net.segments.get_mut(*oi)
- {
- let m = (-rel.0, -rel.1);
- if *ostart {
- o.ta = m;
- } else {
- o.tb = m;
- }
- }
- }
- Ok(())
-}
-
-/// Bend a segment so its curve passes through `p` at parameter `t` (the Bend tool).
-pub fn bend(doc: &mut Document, id: NodeId, seg: usize, t: f64, p: Point) -> Result<()> {
- let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?;
- let Some(s) = net.segments.get(seg).copied() else { return Ok(()) };
- let (Some(a), Some(b)) = (net.vertices.get(s.a).copied(), net.vertices.get(s.b).copied()) else { return Ok(()) };
- let t = t.clamp(0.05, 0.95);
- // Controls at the chord's thirds plus a shared offset: B(t) = L(t) + 3t(1-t)·off.
- let (a, b) = (a.point(), b.point());
- let k = 3.0 * t * (1.0 - t);
- let line = a.lerp(b, t);
- let off = (p - line) / k;
- let c1 = a + (b - a) / 3.0 + off;
- let c2 = b + (a - b) / 3.0 + off;
- if let Some(sm) = net.segments.get_mut(seg) {
- sm.ta = (c1.x - a.x, c1.y - a.y);
- sm.tb = (c2.x - b.x, c2.y - b.y);
- }
- Ok(())
-}
-
-/// Delete vertices, joining their neighbours where a vertex had exactly two segments ("heal").
-pub fn delete_vertices(doc: &mut Document, id: NodeId, idx: &[usize], heal: bool) -> Result<()> {
- let net = network_mut(doc, id).ok_or_else(|| EngineError::Other("not a vector".into()))?;
- let mut idx: Vec = idx.to_vec();
- idx.sort_unstable();
- idx.dedup();
- for &vi in idx.iter().rev() {
- if vi >= net.vertices.len() {
- continue;
- }
- let touching: Vec = net.segments.iter().enumerate().filter(|(_, s)| s.a == vi || s.b == vi).map(|(i, _)| i).collect();
- let mut new_seg = None;
- if heal && let [s1, s2] = touching.as_slice() {
- let (Some(x), Some(y)) = (net.segments.get(*s1).copied(), net.segments.get(*s2).copied()) else { continue };
- let o1 = if x.a == vi { (x.b, x.tb) } else { (x.a, x.ta) };
- let o2 = if y.a == vi { (y.b, y.tb) } else { (y.a, y.ta) };
- new_seg = Some(Segment { a: o1.0, b: o2.0, ta: o1.1, tb: o2.1 });
- }
- // Remove touching segments (descending) and fix region loops.
- let mut seg_map: Vec