diff --git a/ROADMAP.md b/ROADMAP.md index 32627e6f..621d8766 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -15,18 +15,18 @@ start with [`docs/gaps.md`](docs/gaps.md). |---|---|---| | Menu/command breadth | **100%** (204 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Feature breadth | **~84%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | -| Ready for real work | **~51%** | estimated, same doc (written dimension weights and scores) | +| Ready for real work | **~52%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~50%** | estimated: weekly areas 72.3% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Mainstream practitioner | **~49%** | estimated: weekly areas 72.4% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~170–305 h** one agent | estimated | -| Remaining to full parity | **~301–587 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~289–564 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~51% | ~296–569 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | -| Mainstream practitioner | ~50% | ~131–245 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~52% | ~294–566 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -46,7 +46,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human: **Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked -against real Figma documents. Ready-for-real-work is ~51%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs +against real Figma documents. Ready-for-real-work is ~52%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs ~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h) and @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 61% (core editor 72%) | 41–86 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 63% (core editor 72%) | 29–68 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -75,7 +75,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) | | Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | | Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) | -| Shapes & vector | 98% | 72% | 2–4 | [target-app-parity](docs/target-app-parity.md) | +| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | | Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | | Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | @@ -91,7 +91,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | -| Figma Draw extras | 80% | 55% | 2–5 | [gaps](docs/gaps.md) | +| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | ## Languages @@ -128,6 +128,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit + mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several + Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per + point, and the Pen connecting to its own points or continuing a selected open path. - **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide, Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames), @@ -213,6 +217,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9a9621ee..00f7d559 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -753,7 +753,10 @@ impl Node { StrokeAlign::Outside => w, }; // Arrowheads are sized by the weight, whatever a width profile does. - let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() { w.max(self.stroke.weight) * 3.0 } else { w }; + let point_arrows = + matches!(&self.kind, NodeKind::Vector { network } if network.vertices.iter().any(|v| v.cap.is_some_and(StrokeCap::is_arrow))); + let caps = + if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w }; // Right-angled or smooth outlines (boxes, ellipses) end their mitres on the grown box. let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like(); let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) }; diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 8f2230b8..e071b808 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -64,6 +64,10 @@ pub enum StrokeJoin { Round, } +impl StrokeJoin { + pub const ALL: [StrokeJoin; 3] = [StrokeJoin::Miter, StrokeJoin::Bevel, StrokeJoin::Round]; +} + /// Stroke geometry shared by every stroke paint of a node. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 6cd9ab0d..fd0a0f7e 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -1,9 +1,12 @@ //! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus //! filled regions made of closed loops. -use figcraft_geom::{BezPath, Point}; +use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv}; +use figcraft_geom::{BezPath, Point, Vec2}; use serde::{Deserialize, Serialize}; +use crate::{StrokeCap, StrokeJoin}; + /// 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")] @@ -40,11 +43,17 @@ pub struct Vertex { /// Handle mirroring; `None` infers it from the handles (see [`VectorNetwork::mirroring`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub mirror: Option, + /// The cap where a stroke ends at this point; `None` takes the layer's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cap: Option, + /// The join where the stroke turns at this point; `None` takes the layer's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub join: Option, } impl Vertex { pub fn new(x: f64, y: f64) -> Self { - Self { x, y, radius: 0.0, mirror: None } + Self { x, y, radius: 0.0, mirror: None, cap: None, join: None } } pub fn point(&self) -> Point { Point::new(self.x, self.y) @@ -88,16 +97,33 @@ pub struct Region { } /// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again -/// at `b`, and an arc with handles `ta`/`tb` joins them. +/// at `b`, and an arc with handles `ta`/`tb` joins them. `cut1`/`cut2`: where each segment is cut, +/// in its own parameter (curved segments keep the rest of their curve). struct Corner { v: usize, s1: usize, + s1_at_a: bool, s2: usize, s2_at_a: bool, a: Point, b: Point, ta: (f64, f64), tb: (f64, f64), + cut1: f64, + cut2: f64, +} + +/// Accuracy of arc lengths along curved segments (local units). +const ARCLEN_ACC: f64 = 1e-6; + +/// A run of the stroke (see [`VectorNetwork::stroke_runs`]): its path and the points it starts and +/// ends at (the same point when it is closed). +#[derive(Clone, Debug, PartialEq)] +pub struct StrokeRun { + pub path: BezPath, + pub start: usize, + pub end: usize, + pub closed: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] @@ -315,10 +341,11 @@ impl VectorNetwork { } } - /// The network with its rounded points drawn in: each point with a corner radius between two - /// straight segments becomes an arc (the radius shrinks so the arc fits in half of each - /// segment, like a polygon's corners). `None` when no point is rounded. - /// shortcut: points next to a curved segment stay sharp; round them by trimming the curve. + /// The network with its rounded points drawn in: each sharp point with a corner radius becomes + /// an arc between its two segments (the radius shrinks so the arc fits in half of each + /// segment, like a polygon's corners). A curved segment is cut where the arc meets it and keeps + /// the rest of its curve, the arc leaving along its direction there. `None` when no point is + /// rounded. pub fn rounded(&self) -> Option { if !self.vertices.iter().any(|v| v.radius > 0.0) { return None; @@ -336,6 +363,28 @@ impl VectorNetwork { for v in &mut out.vertices { v.radius = 0.0; } + // Curved segments keep the part of their curve between the cuts at their ends. + let mut cuts: Vec<(f64, f64)> = vec![(0.0, 1.0); self.segments.len()]; + for c in &corners { + for (s, at_a, t) in [(c.s1, c.s1_at_a, c.cut1), (c.s2, c.s2_at_a, c.cut2)] { + if let Some(k) = cuts.get_mut(s) { + if at_a { + k.0 = t; + } else { + k.1 = t; + } + } + } + } + for (i, &(t0, t1)) in cuts.iter().enumerate() { + let (Some(c), Some(s)) = (self.cubic(i), out.segments.get_mut(i)) else { continue }; + if (t0, t1) == (0.0, 1.0) || s.is_straight() { + continue; + } + let q = c.subsegment(t0..t1); + s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y); + s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y); + } for c in corners { if let Some(v) = out.vertices.get_mut(c.v) { (v.x, v.y) = (c.a.x, c.a.y); @@ -364,35 +413,64 @@ impl VectorNetwork { Some(out) } - /// How point `v` (its `incident` segments) is rounded, if it is. + /// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments + /// run on smoothly or fold back. fn corner(&self, v: usize, incident: &[(usize, bool)]) -> Option { let r = self.vertices.get(v)?.radius; let [(s1, a1), (s2, a2)] = *incident else { return None }; - let (x, y) = (self.segments.get(s1)?, self.segments.get(s2)?); - if !(r > 0.0 && r.is_finite()) || s1 == s2 || !x.is_straight() || !y.is_straight() { + if !(r > 0.0 && r.is_finite()) || s1 == s2 { return None; } - let p = self.vertices.get(v)?.point(); - let far = |s: &Segment, at_a: bool| self.vertices.get(if at_a { s.b } else { s.a }).map(Vertex::point); - let (v1, v2) = (far(x, a1)? - p, far(y, a2)? - p); - let (l1, l2) = (v1.hypot(), v2.hypot()); - if l1 < 1e-9 || l2 < 1e-9 { - return None; - } - let (u1, u2) = (v1 / l1, v2 / l2); + let (c1, c2) = (self.outward(s1, a1)?, self.outward(s2, a2)?); + let (u1, u2) = (start_dir(&c1)?, start_dir(&c2)?); let angle = u1.dot(u2).clamp(-1.0, 1.0).acos(); if angle < 1e-6 || std::f64::consts::PI - angle < 1e-6 { return None; } - let (mut t, max_t, mut r) = (r / (angle / 2.0).tan(), l1.min(l2) / 2.0, r); - if t > max_t { - r *= max_t / t; - t = max_t; + let (l1, l2) = (length(&c1), length(&c2)); + if !(l1 > 1e-9 && l2 > 1e-9) { + return None; } - // A cubic arc: handles of 4/3·tan(θ/4)·r along the edges, θ the turn at the point. - let k = 4.0 / 3.0 * ((std::f64::consts::PI - angle) / 4.0).tan() * r; - let (h1, h2) = (u1 * -k, u2 * -k); - Some(Corner { v, s1, s2, s2_at_a: a2, a: p + u1 * t, b: p + u2 * t, ta: (h1.x, h1.y), tb: (h2.x, h2.y) }) + let t = (r / (angle / 2.0).tan()).min(l1.min(l2) / 2.0); + let ((a, d1, k1), (b, d2, k2)) = (trim(&c1, t, l1), trim(&c2, t, l2)); + // A cubic arc: handles of 4/3·tan(φ/4)·ρ along each end's direction, φ the turn between + // them and ρ the radius of the circle through both ends (the corner radius between lines). + let turn = (-d1.dot(d2)).clamp(-1.0, 1.0).acos(); + let chord = (b - a).hypot(); + let k = if turn < 1e-9 { chord / 3.0 } else { 4.0 / 3.0 * (turn / 4.0).tan() * chord / (2.0 * (turn / 2.0).sin()) }; + if !(k.is_finite() && a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) { + return None; + } + let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t }; + Some(Corner { + v, + s1, + s1_at_a: a1, + s2, + s2_at_a: a2, + a, + b, + ta: (d1.x * k, d1.y * k), + tb: (d2.x * k, d2.y * k), + cut1: own(a1, k1), + cut2: own(a2, k2), + }) + } + + /// Segment `seg` as a cubic running away from its `a` end (`at_a`) or from its `b` end. + fn outward(&self, seg: usize, at_a: bool) -> Option { + let c = self.cubic(seg)?; + Some(if at_a { c } else { CubicBez::new(c.p3, c.p2, c.p1, c.p0) }) + } + + /// The unit direction segment `seg` leaves its `a` end (`at_a`) or its `b` end in. + pub fn tangent_out(&self, seg: usize, at_a: bool) -> Option { + start_dir(&self.outward(seg, at_a)?) + } + + /// Whether any point has a stroke cap or join of its own. + pub fn has_point_styles(&self) -> bool { + self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some()) } /// The fill path: every region's loops. @@ -439,9 +517,36 @@ impl VectorNetwork { return r.stroke_path(); } let mut path = BezPath::new(); - let n = self.segments.len(); - let mut used = vec![false; n]; - let degree = |v: usize| self.segments.iter().filter(|s| s.a == v || s.b == v).count(); + for run in self.runs(&|_| false) { + path.extend(run.path.elements().iter().copied()); + } + path + } + + /// The stroke as runs for points with caps and joins of their own (rounded points drawn in): + /// like [`Self::stroke_path`], but a run also breaks at a point with a join of its own (left + /// open there, so the join can be drawn on its own). + pub fn stroke_runs(&self) -> Vec { + if let Some(r) = self.rounded() { + return r.stroke_runs(); + } + self.runs(&|v| self.vertices.get(v).is_some_and(|x| x.join.is_some())) + } + + /// Segments chained into runs: a run goes on through points of degree 2 unless `breaks` says + /// to stop there; a run that comes back to its start without a break is closed. + fn runs(&self, breaks: &dyn Fn(usize) -> bool) -> Vec { + let mut out = Vec::new(); + let mut used = vec![false; self.segments.len()]; + let mut degrees = vec![0usize; self.vertices.len()]; + for s in &self.segments { + for v in [s.a, s.b] { + if let Some(d) = degrees.get_mut(v) { + *d += 1; + } + } + } + let degree = |v: usize| degrees.get(v).copied().unwrap_or(0); let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { self.segments.iter().enumerate().find_map(|(i, s)| { if used.get(i).copied().unwrap_or(true) { @@ -456,14 +561,17 @@ impl VectorNetwork { } }) }; - // Start runs at endpoints / junctions first, then whatever remains (pure loops). - let mut starts: Vec = (0..self.vertices.len()).filter(|v| degree(*v) != 2).collect(); - starts.extend((0..self.vertices.len()).filter(|v| degree(*v) == 2)); + let stop = |v: usize| degree(v) != 2 || breaks(v); + // Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops). + let mut starts: Vec = (0..self.vertices.len()).filter(|v| stop(*v)).collect(); + starts.extend((0..self.vertices.len()).filter(|v| !stop(*v))); for v0 in starts { while let Some((si, rev)) = next_from(v0, &used) { let Some(start) = self.vertices.get(v0) else { break }; + let mut path = BezPath::new(); path.move_to(start.point()); - let mut cur; + let mut cur = v0; + let mut closed = false; let mut seg = Some((si, rev)); while let Some((i, r)) = seg { if let Some(u) = used.get_mut(i) { @@ -472,15 +580,17 @@ impl VectorNetwork { let Some(s) = self.segments.get(i) else { break }; self.push_seg(&mut path, s, r); cur = if r { s.a } else { s.b }; - if cur == v0 { + if cur == v0 && !breaks(v0) { path.close_path(); + closed = true; break; } - seg = if degree(cur) == 2 { next_from(cur, &used) } else { None }; + seg = if stop(cur) { None } else { next_from(cur, &used) }; } + out.push(StrokeRun { path, start: v0, end: cur, closed }); } } - path + out } /// Translate/scale every vertex and handle (used when the node is resized). @@ -541,3 +651,31 @@ impl VectorNetwork { } } } + +/// The direction a curve leaves its start in: along its first handle, else toward its second +/// control point or its end; `None` for a curve of no length. +fn start_dir(c: &CubicBez) -> Option { + [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// Whether a cubic is a straight segment (no handles). +fn is_line(c: &CubicBez) -> bool { + c.p1 == c.p0 && c.p2 == c.p3 +} + +fn length(c: &CubicBez) -> f64 { + if is_line(c) { (c.p3 - c.p0).hypot() } else { c.arclen(ARCLEN_ACC) } +} + +/// The point `t` along curve `c` (`len` long) from its start, the unit direction back toward the +/// start there, and its parameter. +fn trim(c: &CubicBez, t: f64, len: f64) -> (Point, Vec2, f64) { + if is_line(c) { + let u = (c.p3 - c.p0) / len; + return (c.p0 + u * t, -u, t / len); + } + let k = c.inv_arclen(t, ARCLEN_ACC).clamp(0.0, 1.0); + let d = c.deriv().eval(k).to_vec2(); + let back = if d.hypot() > 1e-9 { -d / d.hypot() } else { start_dir(c).map(|u| -u).unwrap_or_default() }; + (c.eval(k), back, k) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7bb630e6..ee0f8481 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -506,13 +506,8 @@ 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 => {} + if self.vector_edit.as_mut().is_some_and(|ve| doc.page_of(ve.id) != Some(page) || !ve.clamp_doc(&doc)) { + self.vector_edit = None; } } } @@ -1422,6 +1417,8 @@ mod variables_io_tests; #[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] +mod vector_multi_behaviour_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index c9fbc26b..eee555bb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -223,6 +223,8 @@ pub struct Overlay { /// 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, + /// The edited layer `vector_hover` is on (several layers can be in edit mode). + pub vector_hover_on: Option, /// Vector edit mode, Variable width tool: the width point, handle or path under the pointer. pub width_hover: Option, pub lasso: Vec<[f64; 2]>, @@ -346,6 +348,8 @@ pub struct PenPath { /// Anchors with (in, out) handle offsets. pub points: Vec<(Point, Vec2, Vec2)>, pub closed: bool, + /// The path ends connected to this anchor of its own (a vector network). + pub join: Option, } #[derive(Clone, Debug, Default)] @@ -1636,9 +1640,12 @@ impl Session { textedit::select_word(self, idx)?; return Ok(()); } - // Shapes and vectors: enter vector edit mode. + // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { - return crate::vectoredit::begin(self, target); + let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default(); + ids.retain(|i| *i != target); + ids.insert(0, target); + return crate::vectoredit::begin_many(self, &ids); } // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); @@ -1747,6 +1754,18 @@ impl Session { let ds = self.ds()?; (ds.doc.clone(), ds.page) }; + // With an open path selected, a click on one of its end points continues it in edit mode. + if self.tool.pen.is_none() + && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) + { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + return crate::vectoredit::set_state(self, |e| { + e.anchor = Some(v); + e.vertices = vec![v]; + }); + } let pen = self.tool.pen.get_or_insert_with(PenPath::default); // Clicking the first anchor closes the path. if pen.points.len() > 2 @@ -1757,6 +1776,13 @@ impl Session { self.pen_update()?; return self.pen_finish(); } + // Clicking another of its own anchors (not the last) connects there and ends the path. + let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; + if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { + pen.join = Some(k); + self.pen_update()?; + return self.pen_finish(); + } if pen.parent.is_none() { pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); } @@ -1822,6 +1848,11 @@ impl Session { if !pen.closed { net.regions.clear(); } + // Anchor k is vertex k of the open path. + if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { + let from = pts.len() - 1; + crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); + } let id = match pen.id { Some(id) if doc.contains(id) => id, _ => { @@ -2045,3 +2076,13 @@ fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Opt #[allow(dead_code)] fn unused(_: BTreeMap) {} + +/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). +fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { + sel.iter().find_map(|id| { + let net = crate::vectoredit::network(doc, *id)?; + let w = doc.world_transform(*id); + let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); + (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) + }) +} diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs new file mode 100644 index 00000000..1d2075b1 --- /dev/null +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -0,0 +1,464 @@ +//! Vector multi-edit and edit-mode tail behaviour suite, written from the public documentation of +//! design tools: editing several vector layers at once, selecting several Bezier handles and +//! dragging them together, rounding a point next to a curved segment, a cap and a join per point, +//! and what the Pen does on existing points outside edit mode. Each rule is a table row (or a step) +//! with a one-line derivation; where the documentation is silent the row says so and pins today's +//! behaviour. + +// The tables read best as tuples. +#![allow(clippy::type_complexity)] + +use figcraft_doc::{NodeId, NodeKind, StrokeCap, StrokeJoin, VectorNetwork}; +use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, ParamCurveNearest}; +use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use crate::vectoredit::{VectorEdit, network}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn net(s: &Session, id: NodeId) -> VectorNetwork { + network(s.doc().unwrap(), id).unwrap().clone() +} + +fn editing(s: &Session) -> Option { + s.ds().unwrap().vector_edit.clone() +} + +#[derive(Clone, Copy)] +struct Mods { + shift: bool, + cmd: bool, +} + +const NONE: Mods = Mods { shift: false, cmd: false }; +const SHIFT: Mods = Mods { shift: true, cmd: false }; +const CMD: Mods = Mods { shift: false, cmd: true }; + +fn ev(kind: PointerKind, p: (f64, f64), m: Mods) -> PointerEvent { + let mut e = PointerEvent::at(kind, p.0, p.1, 1.0); + (e.shift, e.cmd) = (m.shift, m.cmd); + e +} + +fn click(s: &mut Session, p: (f64, f64), m: Mods) { + s.pointer(&ev(PointerKind::Down, p, m)).unwrap(); + s.pointer(&ev(PointerKind::Up, p, m)).unwrap(); +} + +fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64), m: Mods) { + s.pointer(&ev(PointerKind::Down, a, m)).unwrap(); + s.pointer(&ev(PointerKind::Move, ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0), m)).unwrap(); + s.pointer(&ev(PointerKind::Move, b, m)).unwrap(); + s.pointer(&ev(PointerKind::Up, b, m)).unwrap(); +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap() +} + +/// The point at page position `p` of vector `id`. +fn point_at(s: &Session, id: NodeId, p: (f64, f64)) -> usize { + let w = s.doc().unwrap().world_transform(id); + net(s, id).vertices.iter().position(|v| (w * v.point() - Point::new(p.0, p.1)).hypot() < 1e-6).unwrap() +} + +fn page_pos(s: &Session, id: NodeId, v: usize) -> Point { + s.doc().unwrap().world_transform(id) * net(s, id).vertices[v].point() +} + +fn near(a: Point, b: (f64, f64)) -> bool { + (a - Point::new(b.0, b.1)).hypot() < 1e-6 +} + +/// Each edited layer's selected points, the active layer first. +fn selected(s: &Session) -> Vec<(NodeId, Vec)> { + let e = editing(s).unwrap(); + e.parts() + .into_iter() + .map(|p| { + let mut v = p.vertices; + v.sort_unstable(); + (p.id, v) + }) + .collect() +} + +fn selected_in(s: &Session, id: NodeId) -> Vec { + selected(s).into_iter().find(|(i, _)| *i == id).map(|(_, v)| v).unwrap_or_default() +} + +#[test] +fn editing_several_vectors_at_once() { + // (how edit mode is entered with two shapes selected, derivation) + let rows: Vec<(&str, &str)> = vec![ + ("command", "the edit command takes every selected shape"), + ("double-click", "double-clicking one of several selected shapes edits them all"), + ("Enter", "Enter with several shapes selected edits them all"), + ]; + for (how, why) in rows { + let mut s = Session::new(); + let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 200.0)); + s.set_selection(vec![a, b]); + match how { + "command" => { + ex(&mut s, "vector.edit.begin", json!({})); + } + "double-click" => s.pointer(&ev(PointerKind::DoubleClick, (50.0, 50.0), NONE)).unwrap(), + _ => { + let ids = crate::vectoredit::enter_targets(s.doc().unwrap(), &s.selection()).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"ids": ids})); + } + } + let e = editing(&s).unwrap_or_else(|| panic!("{why}: in edit mode")); + let mut layers: Vec = e.parts().iter().map(|p| p.id).collect(); + layers.sort_unstable(); + assert_eq!(layers, vec![a, b], "{why}"); + assert_eq!(e.id, a, "{why}: the layer clicked or first selected is the active one"); + let doc = s.doc().unwrap(); + assert!([a, b].iter().all(|i| matches!(doc.node(*i).unwrap().kind, NodeKind::Vector { .. })), "{why}: both are vectors now"); + s.undo().unwrap(); + let doc = s.doc().unwrap(); + assert!([a, b].iter().all(|i| matches!(doc.node(*i).unwrap().kind, NodeKind::Rectangle)), "{why}: converting both is one undo step"); + } + // Only layers that can be edited as vectors take part: a frame in the selection keeps Enter + // from editing points. + let mut s = Session::new(); + let a = rect(&mut s, 0.0); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 50, "height": 50}))["id"]).unwrap(); + assert!( + crate::vectoredit::enter_targets(s.doc().unwrap(), &[a, f]).is_none(), + "silent: a frame in the selection leaves Enter to select its children (pinned)" + ); + + // Points across both layers. + let mut s = Session::new(); + let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 200.0)); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + let (atl, btl) = (point_at(&s, a, (0.0, 0.0)), point_at(&s, b, (200.0, 0.0))); + // (step, active layer after, a's points, b's points, derivation) + let steps: Vec<(&str, NodeId, Vec, Vec, &str)> = vec![ + ("click b", b, vec![], vec![btl], "a click on another layer's point selects it there"), + ("shift-click a", a, vec![atl], vec![btl], "Shift-click adds points from any edited layer"), + ("click a", a, vec![atl], vec![btl], "a press on a selected point keeps the selection to drag it"), + ("marquee", a, vec![atl, point_at(&s, a, (100.0, 0.0))], vec![btl, point_at(&s, b, (300.0, 0.0))], "a marquee selects points in every layer"), + ]; + for (what, active, sa, sb, why) in steps { + match what { + "click b" => click(&mut s, (200.0, 0.0), NONE), + "shift-click a" => click(&mut s, (0.0, 0.0), SHIFT), + "click a" => click(&mut s, (0.0, 0.0), NONE), + _ => drag(&mut s, (-20.0, -20.0), (320.0, 10.0), NONE), + } + let mut sa = sa; + let mut sb = sb; + sa.sort_unstable(); + sb.sort_unstable(); + assert_eq!((editing(&s).unwrap().id, selected_in(&s, a), selected_in(&s, b)), (active, sa, sb), "{why}"); + } + // Arrows nudge every selected point, one undo step for all layers. + ex(&mut s, "vector.edit.select", json!({"vertices": [atl]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); + assert_eq!((selected_in(&s, a), selected_in(&s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer"); + assert!(s.tool_key("Right", false, false, false).unwrap()); + assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer"); + s.undo().unwrap(); + assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer"); + // Dragging one selected point drags them all. + let active = editing(&s).unwrap().id; + let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) }; + drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE); + assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all"); + s.undo().unwrap(); + assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step"); + // Corner radius, mirroring and deleting reach every layer's selected points. + ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10})); + assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer"); + ex(&mut s, "vector.edit.mirroring", json!({"mode": "angle"})); + assert!( + net(&s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle) + && net(&s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle), + "mirroring applies to every layer" + ); + assert!(s.tool_key("Delete", false, false, false).unwrap()); + assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer"); + s.undo().unwrap(); + assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (4, 4), "one undo step"); + // The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer. + drag(&mut s, (250.0, 100.0), (250.0, 130.0), CMD); + assert!(net(&s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer"); + assert!(net(&s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments"); + // Select all takes every edited layer's points. + let r = ex(&mut s, "vector.edit.selectAll", json!({})); + assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)"); + // The pen draws in the active layer only. + ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": []})); + ex(&mut s, "vector.edit.tool", json!({"tool": "pen"})); + click(&mut s, (50.0, 300.0), NONE); + assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)"); + ex(&mut s, "vector.edit.penEnd", json!({})); + ex(&mut s, "vector.edit.tool", json!({"tool": "move"})); + // A click on empty canvas inside an edited layer's area keeps edit mode; far away it ends. + click(&mut s, (250.0, 50.0), NONE); + assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays"); + let st = ex(&mut s, "vector.edit.state", json!({})); + assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer"); + click(&mut s, (900.0, 900.0), NONE); + assert!(editing(&s).is_none(), "a click away from every edited layer leaves edit mode"); +} + +#[test] +fn several_handles_drag_together() { + // An open curve through (0,0), (100,0) and (200,0); its end handles point to (30,-30) and (170,30). + let mut s = Session::new(); + let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 C30 -30 70 -30 100 0 C130 30 170 30 200 0"}))["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + ex(&mut s, "vector.edit.selectAll", json!({})); + let handles = |s: &Session| { + let mut h = editing(s).unwrap().handles; + h.sort_unstable_by_key(|x| (x.0, x.1)); + h + }; + // (gesture, handles selected after, derivation) + let steps: Vec<(&str, Mods, Vec<(usize, bool)>, &str)> = vec![ + ("first", NONE, vec![(0, true)], "a click on a handle selects it"), + ("last", SHIFT, vec![(0, true), (1, false)], "Shift-click adds another handle"), + ("last", SHIFT, vec![(0, true)], "Shift-click on a selected handle takes it out"), + ("last", SHIFT, vec![(0, true), (1, false)], "and adds it again"), + ]; + for (which, m, want, why) in steps { + click(&mut s, if which == "first" { (30.0, -30.0) } else { (170.0, 30.0) }, m); + assert_eq!(handles(&s), want, "{why}"); + } + // Dragging one selected handle moves the others the same way. + drag(&mut s, (30.0, -30.0), (40.0, -20.0), NONE); + let n = net(&s, id); + let ok = |t: (f64, f64), w: (f64, f64)| (t.0 - w.0).abs() < 1e-6 && (t.1 - w.1).abs() < 1e-6; + assert!(ok(n.segments[0].ta, (40.0, -20.0)), "the dragged handle follows the pointer: {:?}", n.segments[0].ta); + assert!(ok(n.segments[1].tb, (-20.0, 40.0)), "the other selected handle moves by as much: {:?}", n.segments[1].tb); + s.undo().unwrap(); + let n = net(&s, id); + assert!(ok(n.segments[0].ta, (30.0, -30.0)) && ok(n.segments[1].tb, (-30.0, 30.0)), "dragging them is one undo step"); + // The command moves the selected handles by a page offset. + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1, 2], "handles": [[0, true], [1, false]]})); + ex(&mut s, "vector.edit.moveHandles", json!({"dx": 5, "dy": 0})); + let n = net(&s, id); + assert!(ok(n.segments[0].ta, (35.0, -30.0)) && ok(n.segments[1].tb, (-25.0, 30.0)), "moveHandles moves every selected handle"); + // A click on a point (not a handle) clears the handles. + click(&mut s, (100.0, 0.0), NONE); + assert!(handles(&s).is_empty(), "selecting a point clears the handle selection"); +} + +/// The smallest distance from `p` to `path`'s segments. +fn distance(path: &BezPath, p: Point) -> f64 { + path.segments().map(|sg| sg.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::MAX, f64::min) +} + +/// The direction of `c` at `t` (along the chord where a straight segment's handles vanish). +fn tangent(c: &CubicBez, t: f64) -> Vec2 { + let d = c.deriv().eval(t).to_vec2(); + if d.hypot() > 1e-9 { d } else { c.p3 - c.p0 } +} + +#[test] +fn rounding_points_next_to_curves() { + // A corner at (100, 0) between a straight edge from (0, 0) and a curve down to (0, 100). + let corner_path = "M0 0 L100 0 C100 60 60 100 0 100 Z"; + // (path, point, radius, rounded, derivation) + let rows: Vec<(&str, (f64, f64), f64, bool, &str)> = vec![ + (corner_path, (100.0, 0.0), 20.0, true, "a sharp point rounds whatever its segments' shape"), + ("M0 0 C100 0 100 100 100 100 L0 100 Z", (100.0, 100.0), 20.0, true, "a curve without a handle at the point still meets it at a corner"), + ("M0 0 C0 -40 100 -40 100 0 C100 40 200 40 200 0", (100.0, 0.0), 20.0, false, "a smooth point has no corner to round"), + ]; + for (path, at, r, rounded, why) in rows { + let mut s = Session::new(); + let id = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + let v = point_at(&s, id, at); + let before = net(&s, id); + ex(&mut s, "vector.edit.cornerRadius", json!({"radius": r, "vertices": [v]})); + let n = net(&s, id); + let w = s.doc().unwrap().world_transform(id); + let mut stroke = n.stroke_path(); + stroke.apply_affine(w); + let gap = distance(&stroke, Point::new(at.0, at.1)); + assert_eq!(gap > 2.0, rounded, "{why}: the outline passes {gap} from the point"); + if !rounded { + continue; + } + // The arc meets each segment where the original passed and turns smoothly into it. + let drawn = n.rounded().unwrap(); + let original: Vec<_> = (0..before.segments.len()).filter_map(|i| before.cubic(i)).collect(); + let on_original = |p: Point| original.iter().map(|c| c.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::MAX, f64::min); + let arc = drawn.segments.len() - 1; + let c = drawn.cubic(arc).unwrap(); + for (end, t) in [(c.p0, 0.0), (c.p3, 1.0)] { + assert!(on_original(end) < 0.05, "{why}: the arc ends on the original outline ({})", on_original(end)); + let d = tangent(&c, t); + // The neighbouring piece's tangent where the arc meets it. + let piece = (0..arc) + .filter_map(|i| drawn.cubic(i)) + .find_map(|q| { + if (q.p0 - end).hypot() < 1e-6 { + Some(tangent(&q, 0.0)) + } else if (q.p3 - end).hypot() < 1e-6 { + Some(tangent(&q, 1.0)) + } else { + None + } + }) + .unwrap(); + let cross = (d.x * piece.y - d.y * piece.x) / (d.hypot() * piece.hypot()); + assert!(cross.abs() < 0.02, "{why}: the arc turns smoothly into the next segment ({cross})"); + } + // The trimmed curve still follows the original one. + for i in 0..arc { + let q = drawn.cubic(i).unwrap(); + assert!(on_original(q.eval(0.5)) < 0.1, "{why}: segment {i} keeps to the original outline"); + } + assert!(n.fill_contains(w.inverse() * Point::new(50.0, 40.0)), "{why}: the inside stays filled"); + } +} + +/// Bounds of `id`'s painted stroke on the page. +fn stroke_bounds(s: &Session, id: NodeId) -> Rect { + let doc = s.doc().unwrap(); + let n = doc.node(id).unwrap(); + let (area, _) = figcraft_render::draw::stroke_fill(doc, n).unwrap(); + let mut p = (*area).clone(); + p.apply_affine(doc.world_transform(id)); + p.bounding_box() +} + +#[test] +fn a_cap_and_a_join_per_point() { + // A 100-wide open line, stroke 10 centred, no caps on the layer. + let mut s = Session::new(); + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]], "closed": false}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"strokeWeight": 10, "strokeAlign": "center", "stroke": "#000000"}})); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + let (start, end) = (point_at(&s, id, (0.0, 0.0)), point_at(&s, id, (100.0, 0.0))); + // (point, cap, left edge, right edge, derivation) + let rows: Vec<(usize, Value, f64, f64, &str)> = vec![ + (start, json!("square"), -5.0, 100.0, "a point's cap applies at that end only"), + (end, json!("round"), -5.0, 105.0, "the other end takes its own cap"), + (start, Value::Null, 0.0, 105.0, "clearing a point's cap goes back to the layer's"), + ]; + for (v, cap, x0, x1, why) in rows { + ex(&mut s, "vector.edit.strokeCap", json!({"cap": cap, "vertices": [v]})); + let b = stroke_bounds(&s, id); + assert!((b.x0 - x0).abs() < 0.2 && (b.x1 - x1).abs() < 0.2, "{why}: {b:?}"); + } + let st = s.doc().unwrap().node(id).unwrap().stroke.clone(); + assert_eq!((st.cap_start, st.cap_end), (StrokeCap::None, StrokeCap::None), "the layer's caps are untouched"); + ex(&mut s, "vector.edit.strokeCap", json!({"cap": "triangleArrow", "vertices": [end]})); + let b = stroke_bounds(&s, id); + assert!(b.x1 > 101.0 && b.y1 > 6.0, "an arrow cap on one point draws an arrowhead there: {b:?}"); + s.undo().unwrap(); + assert!((stroke_bounds(&s, id).x1 - 105.0).abs() < 0.2, "setting a point's cap is one undo step"); + + // A V with a sharp tip at (50, 0); the layer joins with a miter. + let mut s = Session::new(); + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 100], [50, 0], [100, 100]], "closed": false}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"strokeWeight": 10, "strokeAlign": "center", "stroke": "#000000", "strokeJoin": "miter"}})); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + let tip = point_at(&s, id, (50.0, 0.0)); + let half = (50f64 / 100f64).atan(); + // (join, how far the stroke reaches above the tip, derivation) + let rows: Vec<(Value, f64, &str)> = vec![ + (Value::Null, 5.0 / half.sin(), "without a join of its own a point takes the layer's miter"), + (json!("bevel"), 5.0 * half.sin(), "a point's bevel cuts its corner off"), + (json!("round"), 5.0, "a point's round join reaches half the weight"), + ]; + for (join, reach, why) in rows { + ex(&mut s, "vector.edit.strokeJoin", json!({"join": join, "vertices": [tip]})); + let got = -stroke_bounds(&s, id).y0; + assert!((got - reach).abs() < 0.3, "{why}: {got} vs {reach}"); + } + assert_eq!(s.doc().unwrap().node(id).unwrap().stroke.join, StrokeJoin::Miter, "the layer's join is untouched"); + // Points keep their cap and join in the file. + let n = net(&s, id); + let back: VectorNetwork = serde_json::from_value(serde_json::to_value(&n).unwrap()).unwrap(); + assert_eq!(back.vertices[tip].join, Some(StrokeJoin::Round), "a point's join is saved"); + // SVG writes such strokes as outlines (attributes would apply one join to the whole path). + let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[id], &figcraft_svg::SvgOptions::default()).unwrap(); + assert!(!svg.contains("stroke-width"), "the SVG stroke is an outline"); + // Several layers at once. + let mut s = Session::new(); + let a = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]], "closed": false}))["id"]).unwrap(); + let b = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 50], [100, 50]], "closed": false}))["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.edit.strokeCap", json!({"cap": "round"})); + assert!([a, b].iter().all(|i| net(&s, *i).vertices.iter().all(|v| v.cap == Some(StrokeCap::Round))), "caps reach every edited layer"); + assert!(s.execute("vector.edit.strokeCap", &json!({"cap": "zigzag"})).is_err(), "an unknown cap is refused"); +} + +#[test] +fn the_pen_on_existing_points_outside_edit_mode() { + let pen = |s: &mut Session, pts: &[(f64, f64)]| { + s.set_tool(ToolId::Pen); + for p in pts { + click(s, *p, NONE); + } + }; + let layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.clone(); + // Clicking the first point closes the path, which fills. + let mut s = Session::new(); + pen(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 0.0)]); + let id = layers(&s)[0]; + assert!(s.tool.pen.is_none() && net(&s, id).regions.len() == 1, "clicking the first point closes the path"); + // Clicking one of the path's own middle points connects there and ends the path. + let mut s = Session::new(); + pen(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (100.0, 0.0)]); + let id = layers(&s)[0]; + let n = net(&s, id); + let mid = point_at(&s, id, (100.0, 0.0)); + assert!(s.tool.pen.is_none(), "connecting to the path's own point ends it"); + assert_eq!((n.vertices.len(), n.incident(mid).len()), (4, 3), "the path branches at the point it reached (a vector network)"); + assert!(n.regions.is_empty(), "silent: the loop it makes isn't filled, as in edit mode (pinned)"); + // With an open path selected, the Pen continues it from an end point. + let mut s = Session::new(); + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]], "closed": false}))["id"]).unwrap(); + s.set_selection(vec![id]); + pen(&mut s, &[(100.0, 0.0), (100.0, 100.0)]); + let e = editing(&s).expect("the pen continues the path in edit mode"); + assert_eq!((e.id, net(&s, id).vertices.len(), layers(&s).len()), (id, 3, 1), "the new point extends the same layer"); + assert!(e.anchor.is_some(), "and the pen keeps going from it"); + // Another layer's end point while drawing: docs silent, the new path's point lands on it. + let mut s = Session::new(); + let other = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 200], [100, 200]], "closed": false}))["id"]).unwrap(); + s.set_selection(vec![]); + pen(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 200.0)]); + s.pen_finish().unwrap(); + assert_eq!((layers(&s).len(), net(&s, other).vertices.len()), (2, 2), "silent: the paths stay separate layers (pinned)"); +} + +#[test] +fn edit_mode_commands_survive_junk_params() { + // Every vector command, run in multi-edit mode with points and handles selected, on junk. + let junk = [ + json!({}), + json!(null), + json!({"vertices": [-1, 1e12, "a"], "segments": [1e300], "handles": [[1e300, "x"], [-1, true], "y", [0]], "id": "nope", "ids": [1e9]}), + json!({"cap": 7, "join": [1], "dx": "NaN", "dy": 1e308, "x": null, "y": -1e308, "radius": -5, "mode": 3}), + json!({"handles": [[0, true], [99999, false]], "dx": 1e308, "dy": -1e308, "cap": "round", "join": "bevel", "vertices": [0, 99999]}), + ]; + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("vector.")) { + for j in &junk { + let mut s = Session::new(); + let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 200.0)); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.edit.select", json!({"handles": [[0, true]], "add": true})); + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index 443eb4bb..f239dd4a 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -26,6 +26,7 @@ use serde::Serialize; use serde_json::{Value, json}; use crate::cmd::CommandSpec; +use crate::cmd::props::enum_of; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; @@ -91,13 +92,18 @@ impl VTool { } } -/// Vector edit mode state: the vector being edited, its selected points and segments, the active -/// edit tool and the pen's open end. +/// Vector edit mode state: the vector being edited (the active layer), its selected points, +/// segments and Bezier handles, the active edit tool, the pen's open end, and the other layers +/// edited alongside it. #[derive(Clone, Debug, PartialEq)] pub struct VectorEdit { pub id: NodeId, pub vertices: Vec, pub segments: Vec, + /// Selected Bezier handles of the active layer: (segment, the handle at its `a` end). + pub handles: Vec<(usize, bool)>, + /// The other layers in edit mode (several vectors edited at once), each with its own points. + pub others: Vec, pub tool: VTool, /// Pen: the point the next click continues from. pub anchor: Option, @@ -107,21 +113,111 @@ pub struct VectorEdit { pub width_point: Option, } -impl VectorEdit { - pub fn new(id: NodeId) -> Self { - Self { id, vertices: vec![], segments: vec![], tool: VTool::Move, anchor: None, pen_out: None, width_point: None } +/// A layer in edit mode with its selected points and segments. +#[derive(Clone, Debug, PartialEq)] +pub struct EditPart { + pub id: NodeId, + pub vertices: Vec, + pub segments: Vec, +} + +impl EditPart { + 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); } - /// Drop indices that no longer exist (after undo or an edit). + fn is_empty(&self) -> bool { + self.vertices.is_empty() && self.segments.is_empty() + } +} + +impl VectorEdit { + pub fn new(id: NodeId) -> Self { + Self { + id, + vertices: vec![], + segments: vec![], + handles: vec![], + others: vec![], + tool: VTool::Move, + anchor: None, + pen_out: None, + width_point: None, + } + } + + /// Drop indices that no longer exist in the active layer's network (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); + self.handles.retain(|(s, _)| *s < ns); if self.anchor.is_some_and(|a| a >= nv) { self.anchor = None; self.pen_out = None; } } + + /// [`Self::clamp`] every edited layer against `doc`, dropping other layers that are gone or no + /// longer vectors. False when the active layer is. + pub fn clamp_doc(&mut self, doc: &Document) -> bool { + let Some(net) = network(doc, self.id) else { return false }; + self.clamp(net); + self.others.retain_mut(|o| network(doc, o.id).map(|n| o.clamp(n)).is_some()); + true + } + + /// Every edited layer with its selection, the active one first. + pub fn parts(&self) -> Vec { + std::iter::once(EditPart { id: self.id, vertices: self.vertices.clone(), segments: self.segments.clone() }) + .chain(self.others.clone()) + .collect() + } + + /// Whether `id` is one of the edited layers. + pub fn edits(&self, id: NodeId) -> bool { + self.id == id || self.others.iter().any(|o| o.id == id) + } + + /// Whether another layer than the active one has points or segments selected. + pub fn others_selected(&self) -> bool { + self.others.iter().any(|o| !o.is_empty()) + } + + /// Make edited layer `id` the active one, keeping every layer's selection (the pen's open end + /// and the handle selection belong to the layer left). False when `id` isn't edited. + pub fn activate(&mut self, id: NodeId) -> bool { + if id == self.id { + return true; + } + let Some(o) = self.others.iter_mut().find(|o| o.id == id) else { return false }; + std::mem::swap(&mut o.id, &mut self.id); + std::mem::swap(&mut o.vertices, &mut self.vertices); + std::mem::swap(&mut o.segments, &mut self.segments); + self.handles.clear(); + self.anchor = None; + self.pen_out = None; + self.width_point = None; + true + } + + /// Clear the points, segments and handles selected in every layer. + pub fn clear_selection(&mut self) { + self.vertices.clear(); + self.segments.clear(); + self.handles.clear(); + for o in &mut self.others { + o.vertices.clear(); + o.segments.clear(); + } + } + + /// The edit state of another edited layer, for drawing its overlay. + pub fn view(&self, part: &EditPart) -> VectorEdit { + VectorEdit { vertices: part.vertices.clone(), segments: part.segments.clone(), tool: self.tool, ..VectorEdit::new(part.id) } + } } /// What a pointer is over in edit mode. @@ -161,6 +257,7 @@ pub struct EditOverlay { pub handles: Vec, pub selected_vertices: Vec, pub selected_segments: Vec, + pub selected_handles: Vec<(usize, bool)>, pub anchor: Option, } @@ -282,6 +379,14 @@ pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option nearest_seg.map(|(seg, t, _)| VHit::Segment { seg, t }) } +/// [`hit`] on every edited layer: the active one first, then the others (front to back as listed). +pub fn hit_any(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<(NodeId, VHit)> { + if let Some(h) = hit(doc, ve, p, tol) { + return Some((ve.id, h)); + } + ve.others.iter().find_map(|o| hit(doc, &ve.view(o), p, tol).map(|h| (o.id, h))) +} + /// Vertices inside a page-space rectangle. pub fn vertices_in(doc: &Document, id: NodeId, r: Rect) -> Vec { let Some(net) = network(doc, id) else { return vec![] }; @@ -322,29 +427,61 @@ pub fn overlay(doc: &Document, ve: &VectorEdit) -> Option { handles, selected_vertices: ve.vertices.clone(), selected_segments: ve.segments.clone(), + selected_handles: ve.handles.clone(), anchor: ve.anchor, }) } /// Start editing `id` as a vector. pub fn begin(s: &mut Session, id: NodeId) -> Result<()> { + begin_many(s, &[id]) +} + +/// The selected layers Enter or a double-click edits as vectors: every one of them, when each +/// can be edited as a vector (`None` otherwise, or for an empty selection). +pub fn enter_targets(doc: &Document, sel: &[NodeId]) -> Option> { + let editable = |id: &NodeId| doc.node(*id).is_some_and(|n| n.kind.is_shape() || matches!(n.kind, NodeKind::BooleanOp { .. })); + (!sel.is_empty() && sel.iter().all(editable)).then(|| sel.to_vec()) +} + +/// Start editing `ids` as vectors at once (shapes are converted, in one undo step); the first is +/// the active layer. Layers that can't be edited as vectors are left out (an error when none can). +pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> { let before = s.ds()?.doc.clone(); + let mut edited = Vec::new(); + let mut first_err = None; { let doc = s.doc_mut()?; - ensure_vector(doc, id)?; + for id in ids.iter().take(MAX_EDITED) { + if edited.contains(id) { + continue; + } + match ensure_vector(doc, *id) { + Ok(()) => edited.push(*id), + Err(e) => first_err = first_err.or(Some(e)), + } + } } + let Some((&id, rest)) = edited.split_first() else { + return Err(first_err.unwrap_or_else(|| bad("vector.edit.begin", "select a shape"))); + }; let ds = s.ds_mut()?; - let edited = (!std::sync::Arc::ptr_eq(&before, &ds.doc)).then(|| (ds.uid, ds.selection.clone(), ds.page)); - ds.vector_edit = Some(VectorEdit::new(id)); - ds.selection = vec![id]; + let converted = (!std::sync::Arc::ptr_eq(&before, &ds.doc)).then(|| (ds.uid, ds.selection.clone(), ds.page)); + let mut ve = VectorEdit::new(id); + ve.others = rest.iter().map(|o| EditPart { id: *o, vertices: vec![], segments: vec![] }).collect(); + ds.vector_edit = Some(ve); + ds.selection = edited.clone(); ds.text_edit = None; ds.revision += 1; - if let Some((uid, selection, page)) = edited { + if let Some((uid, selection, page)) = converted { s.record(uid, Some("Edit vector"), before, selection, page); } Ok(()) } +/// At most this many layers are edited at once. +const MAX_EDITED: usize = 500; + pub fn end(s: &mut Session) -> Result<()> { let ds = s.ds_mut()?; ds.vector_edit = None; @@ -407,27 +544,75 @@ fn index(cmd: &str, p: &Value, key: &str) -> Result { Ok(v as usize) } -/// Delete the selected points and segments (`heal` keeps paths through deleted points closed). -fn delete_selection(s: &mut Session, verts: Vec, segs: Vec, heal: bool) -> Result<()> { - let id = editing(s)?.id; - edit(s.doc_mut()?, id, |net| { - // Segments first (their indices refer to the network before points go). - let segs: Vec = - segs.into_iter().filter(|si| net.segments.get(*si).is_some_and(|x| !verts.contains(&x.a) && !verts.contains(&x.b))).collect(); - let pts: Vec = verts.iter().filter_map(|v| net.vertices.get(*v)).map(|v| v.point()).collect(); - ops::remove_segments(net, &segs); - // Point indices may have shifted: find them again by position. - let now: Vec = pts.iter().filter_map(|p| net.vertices.iter().position(|v| v.point() == *p)).collect(); - ops::delete_vertices(net, &now, heal); - })?; +/// Delete the points and segments of `parts` (`heal` keeps paths through deleted points closed). +fn delete_selection(s: &mut Session, parts: Vec, heal: bool) -> Result<()> { + let doc = s.doc_mut()?; + for EditPart { id, vertices: verts, segments: segs } in parts { + edit(doc, id, |net| { + // Segments first (their indices refer to the network before points go). + let segs: Vec = + segs.into_iter().filter(|si| net.segments.get(*si).is_some_and(|x| !verts.contains(&x.a) && !verts.contains(&x.b))).collect(); + let pts: Vec = verts.iter().filter_map(|v| net.vertices.get(*v)).map(|v| v.point()).collect(); + ops::remove_segments(net, &segs); + // Point indices may have shifted: find them again by position. + let now: Vec = pts.iter().filter_map(|p| net.vertices.iter().position(|v| v.point() == *p)).collect(); + ops::delete_vertices(net, &now, heal); + })?; + } set_state(s, |ve| { - ve.vertices.clear(); - ve.segments.clear(); + ve.clear_selection(); ve.anchor = None; ve.pen_out = None; }) } +/// The points a command acts on, per layer: `vertices` names points of the active layer; without +/// it, every edited layer's selected points (with `ends`, the ends of selected segments too). +fn point_sets(s: &Session, p: &Value, ends: bool) -> Result)>> { + let ve = editing(s)?; + if let Some(v) = indices(p, "vertices") { + return Ok(vec![(ve.id, v)]); + } + let doc = s.doc()?; + Ok(ve + .parts() + .into_iter() + .filter_map(|part| { + let pts = if ends { points_of(network(doc, part.id)?, &part.vertices, &part.segments) } else { part.vertices }; + (!pts.is_empty()).then_some((part.id, pts)) + }) + .collect()) +} + +/// Edit each layer's network with its points (`f` gets the layer's world transform too). +fn edit_sets(s: &mut Session, sets: Vec<(NodeId, Vec)>, f: impl Fn(&mut VectorNetwork, &[usize], Affine)) -> Result<()> { + let doc = s.doc_mut()?; + for (id, pts) in sets { + let w = doc.world_transform(id); + edit(doc, id, |net| f(net, &pts, w))?; + } + Ok(()) +} + +/// A page offset in the local units of a layer with world transform `w`. +pub(crate) fn local_offset(w: Affine, d: Vec2) -> Vec2 { + let inv = w.inverse(); + inv * (Point::ZERO + d) - inv * Point::ZERO +} + +/// `[[segment, start], ...]` from params. +fn handle_list(p: &Value, key: &str) -> Option> { + match get(p, key) { + Some(Value::Array(a)) => Some( + a.iter() + .filter_map(|h| Some((h.get(0).and_then(f64_of).filter(|x| (0.0..1e9).contains(x))? as usize, h.get(1)?.as_bool()?))) + .take(100_000) + .collect(), + ), + _ => None, + } +} + /// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show). pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option { let net = network(doc, ve.id)?; @@ -444,8 +629,13 @@ fn default_points(s: &Session, ve: &VectorEdit) -> Vec { /// Selected points plus the ends of selected segments. pub fn selected_points(net: &VectorNetwork, ve: &VectorEdit) -> Vec { - let mut v = ve.vertices.clone(); - for s in ve.segments.iter().filter_map(|i| net.segments.get(*i)) { + points_of(net, &ve.vertices, &ve.segments) +} + +/// Points `vertices` plus the ends of `segments`, sorted. +pub(crate) fn points_of(net: &VectorNetwork, vertices: &[usize], segments: &[usize]) -> Vec { + let mut v = vertices.to_vec(); + for s in segments.iter().filter_map(|i| net.segments.get(*i)) { v.extend([s.a, s.b]); } v.sort_unstable(); @@ -533,11 +723,18 @@ fn local(s: &Session, id: NodeId, p: &Value, cmd: &str) -> Result { pub fn specs() -> Vec { vec![ - spec!("vector.edit.begin", "Edit vector", "{id?} (converts shapes to vectors)", false, |s, p| { - let id = targets(s, p).first().copied().ok_or_else(|| bad("vector.edit.begin", "select a shape"))?; - begin(s, id)?; - Ok(json!({"id": id})) - }), + spec!( + "vector.edit.begin", + "Edit vector", + "{id? | ids?} (default the selection; several layers are edited at once, the first active; converts shapes to vectors)", + false, + |s, p| { + let ids = targets(s, p); + begin_many(s, &ids)?; + let ve = editing(s)?; + Ok(json!({"id": ve.id, "layers": ve.parts().iter().map(|p| p.id).collect::>()})) + } + ), spec!("vector.edit.end", "Done editing", "{}", false, |s, _| { end(s)?; Ok(json!({"ok": true})) @@ -548,8 +745,11 @@ pub fn specs() -> Vec { let o = overlay(doc, &ve).map(|o| serde_json::to_value(o).unwrap_or(Value::Null)).unwrap_or(Value::Null); let net = network(doc, ve.id).map(|n| serde_json::to_value(n).unwrap_or(Value::Null)).unwrap_or(Value::Null); let mirroring: Vec<&str> = network(doc, ve.id).map(|n| (0..n.vertices.len()).map(|v| n.mirroring(v).id()).collect()).unwrap_or_default(); + let layers: Vec = ve.parts().iter().map(|p| p.id).collect(); + let others: Vec = ve.others.iter().map(|o| json!({"id": o.id, "vertices": o.vertices, "segments": o.segments})).collect(); Ok(json!({"id": ve.id, "tool": ve.tool.id(), "overlay": o, "network": net, "mirroring": mirroring, - "widthPoints": width_handles(doc, &ve, 1.0), "widthPoint": ve.width_point})) + "widthPoints": width_handles(doc, &ve, 1.0), "widthPoint": ve.width_point, "layers": layers, "others": others, + "handles": ve.handles})) }), spec!("vector.edit.tool", "Vector edit tool", "{tool: move|pen|bend|paint|lasso|cut|eraser|width}", false, |s, p| { editing(s)?; @@ -557,17 +757,32 @@ pub fn specs() -> Vec { set_tool(s, t)?; Ok(json!({"tool": t.id()})) }), - spec!("vector.edit.select", "Select points", "{vertices?: [i, ...], segments?: [i, ...], add?}", false, |s, p| { - editing(s)?; + spec!( + "vector.edit.select", + "Select points", + "{vertices?: [i, ...], segments?: [i, ...], handles?: [[segment, start], ...], id? (an edited layer, made active), add?}", + false, + |s, p| { + let ve = editing(s)?; + let layer = id(p, "id").unwrap_or(ve.id); + if !ve.edits(layer) { + return Err(bad("vector.edit.select", "that layer isn't being edited")); + } let (v, sg, add) = (indices(p, "vertices").unwrap_or_default(), indices(p, "segments").unwrap_or_default(), boolean(p, "add").unwrap_or(false)); + let handles = handle_list(p, "handles").unwrap_or_default(); set_state(s, |ve| { if !add { - ve.vertices.clear(); - ve.segments.clear(); + ve.clear_selection(); } + ve.activate(layer); ve.vertices.extend(v); ve.segments.extend(sg); + for h in handles { + if !ve.handles.contains(&h) { + ve.handles.push(h); + } + } })?; let doc = s.doc()?.clone(); let ds = s.ds_mut()?; @@ -581,30 +796,37 @@ pub fn specs() -> Vec { ve.clamp(net); } Ok(json!({"ok": true})) - }), - spec!("vector.edit.selectAll", "Select all points", "{}", false, |s, _| { - let ve = editing(s)?; - let n = network(s.doc()?, ve.id).map(|n| n.vertices.len()).unwrap_or(0); - set_state(s, |ve| { - ve.vertices = (0..n).collect(); - ve.segments.clear(); - })?; - Ok(json!({"count": n})) - }), - spec!("vector.edit.moveVertices", "Move points", "{vertices?, dx, dy} (local units; default the selected points)", true, |s, p| { - let ve = editing(s)?; - let v = indices(p, "vertices").unwrap_or_else(|| default_points(s, &ve)); - let d = Vec2::new(num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); - edit(s.doc_mut()?, ve.id, |net| ops::move_vertices(net, &v, d))?; - Ok(json!({"ok": true})) - }), - spec!("vector.edit.nudge", "Nudge points", "{dx, dy} (page units)", true, |s, p| { + } + ), + spec!("vector.edit.selectAll", "Select all points", "{} (in every edited layer)", false, |s, _| { let ve = editing(s)?; let doc = s.doc()?; - let v = network(doc, ve.id).map(|n| selected_points(n, &ve)).unwrap_or_default(); - let inv = doc.world_transform(ve.id).inverse(); - let d = inv * (Point::ZERO + Vec2::new(num_or(p, "dx", 0.0), num_or(p, "dy", 0.0))) - inv * Point::ZERO; - edit(s.doc_mut()?, ve.id, |net| ops::move_vertices(net, &v, d))?; + let counts: Vec = ve.parts().iter().map(|p| network(doc, p.id).map_or(0, |n| n.vertices.len())).collect(); + set_state(s, |ve| { + ve.clear_selection(); + ve.vertices = (0..counts.first().copied().unwrap_or(0)).collect(); + for (o, n) in ve.others.iter_mut().zip(counts.iter().skip(1)) { + o.vertices = (0..*n).collect(); + } + })?; + Ok(json!({"count": counts.iter().sum::()})) + }), + spec!( + "vector.edit.moveVertices", + "Move points", + "{vertices?, dx, dy} (local units; default the selected points of every edited layer)", + true, + |s, p| { + let sets = point_sets(s, p, true)?; + let d = Vec2::new(num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); + edit_sets(s, sets, |net, v, _| ops::move_vertices(net, v, d))?; + Ok(json!({"ok": true})) + } + ), + spec!("vector.edit.nudge", "Nudge points", "{dx, dy} (page units; the selected points of every edited layer)", true, |s, p| { + let sets = point_sets(s, &json!({}), true)?; + let d = Vec2::new(num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); + edit_sets(s, sets, |net, v, w| ops::move_vertices(net, v, local_offset(w, d)))?; Ok(json!({"ok": true})) }), spec!("vector.edit.position", "Point position", "{x?, y?} (the selected points' top-left, in the parent's coordinates)", true, |s, p| { @@ -632,25 +854,41 @@ pub fn specs() -> Vec { })?; Ok(json!({"ok": true})) }), + spec!( + "vector.edit.moveHandles", + "Move handles", + "{dx, dy (page units), handles?: [[segment, start], ...] (default the selected handles), independent?}", + true, + |s, p| { + let ve = editing(s)?; + let handles = handle_list(p, "handles").unwrap_or_else(|| ve.handles.clone()); + if handles.is_empty() { + return Err(bad("vector.edit.moveHandles", "select handles")); + } + let doc = s.doc_mut()?; + let d = local_offset(doc.world_transform(ve.id), Vec2::new(num_or(p, "dx", 0.0), num_or(p, "dy", 0.0))); + let independent = boolean(p, "independent").unwrap_or(false); + edit(doc, ve.id, |net| ops::move_handles(net, &handles, d, independent))?; + Ok(json!({"ok": true})) + } + ), spec!("vector.edit.mirroring", "Handle mirroring", "{mode: none|angle|angleAndLength, vertices?}", true, |s, p| { - let ve = editing(s)?; let mode = string(p, "mode") .and_then(|m| HandleMirroring::from_id(&m)) .ok_or_else(|| bad("vector.edit.mirroring", "mode: none, angle or angleAndLength"))?; - let v = indices(p, "vertices").unwrap_or_else(|| default_points(s, &ve)); - edit(s.doc_mut()?, ve.id, |net| ops::set_mirroring(net, &v, mode))?; + let sets = point_sets(s, p, true)?; + edit_sets(s, sets, |net, v, _| ops::set_mirroring(net, v, mode))?; Ok(json!({"ok": true})) }), spec!("vector.edit.smooth", "Smooth or sharpen points", "{vertices?, smooth? (default true)}", true, |s, p| { - let ve = editing(s)?; let smooth = boolean(p, "smooth").unwrap_or(true); - let v = indices(p, "vertices").unwrap_or_else(|| ve.vertices.clone()); - edit(s.doc_mut()?, ve.id, |net| { + let sets = point_sets(s, p, false)?; + edit_sets(s, sets, |net, v, _| { for vi in v { if smooth { - ops::auto_smooth(net, vi); + ops::auto_smooth(net, *vi); } else { - ops::make_sharp(net, vi); + ops::make_sharp(net, *vi); } } })?; @@ -676,33 +914,50 @@ pub fn specs() -> Vec { })?; Ok(json!({"vertex": v})) }), - spec!("vector.deleteHeal", "Delete and heal selection", "{vertices?}", true, |s, p| { - let ve = editing(s)?; - delete_selection(s, indices(p, "vertices").unwrap_or(ve.vertices), vec![], true)?; + spec!("vector.deleteHeal", "Delete and heal selection", "{vertices?} (default the selected points of every edited layer)", true, |s, p| { + let parts = point_sets(s, p, false)?.into_iter().map(|(id, vertices)| EditPart { id, vertices, segments: vec![] }).collect(); + delete_selection(s, parts, true)?; Ok(json!({"ok": true})) }), - spec!("vector.edit.delete", "Delete points", "{vertices?, segments?} (default the selection)", true, |s, p| { + spec!("vector.edit.delete", "Delete points", "{vertices?, segments?} (default the selection in every edited layer)", true, |s, p| { let ve = editing(s)?; - let (v, sg) = match (indices(p, "vertices"), indices(p, "segments")) { - (None, None) => (ve.vertices, ve.segments), - (v, sg) => (v.unwrap_or_default(), sg.unwrap_or_default()), + let parts = match (indices(p, "vertices"), indices(p, "segments")) { + (None, None) => ve.parts(), + (v, sg) => vec![EditPart { id: ve.id, vertices: v.unwrap_or_default(), segments: sg.unwrap_or_default() }], }; - delete_selection(s, v, sg, false)?; + delete_selection(s, parts, false)?; Ok(json!({"ok": true})) }), spec!("vector.edit.cornerRadius", "Point corner radius", "{radius, vertices?}", true, |s, p| { - let ve = editing(s)?; let r = req_num("vector.edit.cornerRadius", p, "radius")?.clamp(0.0, 1e6); - let v = indices(p, "vertices").unwrap_or_else(|| default_points(s, &ve)); - edit(s.doc_mut()?, ve.id, |net| { - for i in v { - if let Some(x) = net.vertices.get_mut(i) { - x.radius = r; - } - } - })?; + let sets = point_sets(s, p, true)?; + edit_sets(s, sets, |net, v, _| set_points(net, v, |x| x.radius = r))?; Ok(json!({"ok": true})) }), + spec!( + "vector.edit.strokeCap", + "Point stroke cap", + "{cap: none|round|square|lineArrow|triangleArrow|reversedTriangle|circleFilled|diamondFilled|null (the layer's), vertices?} — where the stroke ends at these points", + true, + |s, p| { + let cap = point_style::("vector.edit.strokeCap", p, "cap")?; + let sets = point_sets(s, p, true)?; + edit_sets(s, sets, |net, v, _| set_points(net, v, |x| x.cap = cap))?; + Ok(json!({"ok": true})) + } + ), + spec!( + "vector.edit.strokeJoin", + "Point stroke join", + "{join: miter|bevel|round|null (the layer's), vertices?} — how the stroke turns at these points", + true, + |s, p| { + let join = point_style::("vector.edit.strokeJoin", p, "join")?; + let sets = point_sets(s, p, true)?; + edit_sets(s, sets, |net, v, _| set_points(net, v, |x| x.join = join))?; + Ok(json!({"ok": true})) + } + ), spec!( "vector.edit.paint", "Paint bucket", @@ -790,20 +1045,19 @@ pub fn specs() -> Vec { } _ => vec![], }; - let found = vertices_in_polygon(s.doc()?, ve.id, &poly); + let doc = s.doc()?; + let found: Vec<(NodeId, Vec)> = ve.parts().iter().map(|part| (part.id, vertices_in_polygon(doc, part.id, &poly))).collect(); let add = boolean(p, "add").unwrap_or(false); set_state(s, |ve| { if !add { - ve.vertices.clear(); - ve.segments.clear(); + ve.clear_selection(); } - for v in &found { - if !ve.vertices.contains(v) { - ve.vertices.push(*v); - } + for (id, vs) in &found { + add_points(ve, *id, vs); } })?; - Ok(json!({"vertices": found})) + Ok(json!({"vertices": found.first().map(|f| f.1.clone()).unwrap_or_default(), + "layers": found.iter().map(|(id, v)| json!({"id": id, "vertices": v})).collect::>()})) }), spec!( "vector.edit.pen", @@ -886,3 +1140,33 @@ pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result } Ok(filled) } + +/// Add points `vs` of edited layer `id` to its selection. +pub(crate) fn add_points(ve: &mut VectorEdit, id: NodeId, vs: &[usize]) { + let list = if id == ve.id { Some(&mut ve.vertices) } else { ve.others.iter_mut().find(|o| o.id == id).map(|o| &mut o.vertices) }; + if let Some(list) = list { + for v in vs { + if !list.contains(v) { + list.push(*v); + } + } + } +} + +/// Set something on points `v` of a network. +fn set_points(net: &mut VectorNetwork, v: &[usize], f: impl Fn(&mut Vertex)) { + for i in v { + if let Some(x) = net.vertices.get_mut(*i) { + f(x); + } + } +} + +/// A point's own cap or join from `key`: a name, or `null` for the layer's. +fn point_style(cmd: &str, p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Err(bad(cmd, format!("`{key}` required (null for the layer's)"))), + Some(Value::Null) => Ok(None), + Some(v) => enum_of(v).map(Some).ok_or_else(|| bad(cmd, format!("unknown {key}"))), + } +} diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index a2248216..093d0854 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -100,6 +100,17 @@ pub fn set_handle(net: &mut VectorNetwork, seg: usize, start: bool, rel: (f64, f net.set_handle(*oi, *ostart, (mirrored.x, mirrored.y)); } +/// Move handles `hs` (segment, the handle at its `a` end) by `d` from where they are now, each as +/// [`set_handle`] would. +pub fn move_handles(net: &mut VectorNetwork, hs: &[(usize, bool)], d: Vec2, independent: bool) { + let from: Vec> = hs.iter().map(|(s, st)| net.handle(*s, *st)).collect(); + for (&(seg, start), h) in hs.iter().zip(from) { + if let Some(h) = h { + set_handle(net, seg, start, (h.0 + d.x, h.1 + d.y), independent); + } + } +} + /// Set the mirroring of vertices and make their handles agree with it. pub fn set_mirroring(net: &mut VectorNetwork, verts: &[usize], mode: HandleMirroring) { for &vi in verts { diff --git a/crates/engine/src/vectoredit/point_snap.rs b/crates/engine/src/vectoredit/point_snap.rs index 43d2d162..c772290b 100644 --- a/crates/engine/src/vectoredit/point_snap.rs +++ b/crates/engine/src/vectoredit/point_snap.rs @@ -26,6 +26,19 @@ const MAX_MOVING: usize = 256; /// dragged; their segments' midpoints move too), the points and segment midpoints of the other /// visible vectors on `page`, the layer targets around it and `extra` page points. pub fn targets(doc: &Document, page: NodeId, edited: Option, moving: &[usize], extra: &[Point]) -> Targets { + targets_moving(doc, page, edited, moving, extra, &[]) +} + +/// [`targets`] while points of other layers move too (`others`: each layer's moving points, which +/// aren't targets). +pub fn targets_moving( + doc: &Document, + page: NodeId, + edited: Option, + moving: &[usize], + extra: &[Point], + others: &[(NodeId, Vec)], +) -> Targets { // Points are snapped on the page. let mut t = Targets::gather_in(doc, page, edited.as_slice(), figcraft_geom::Affine::IDENTITY); let mut budget = MAX_POINTS; @@ -36,9 +49,9 @@ pub fn targets(doc: &Document, page: NodeId, edited: Option, moving: &[u if let Some(id) = edited { add_vector(doc, id, &moving.iter().copied().collect(), &mut t, &mut budget); } - let none = BTreeSet::new(); for id in visible_vectors(doc, page).into_iter().filter(|v| Some(*v) != edited) { - add_vector(doc, id, &none, &mut t, &mut budget); + let skip: BTreeSet = others.iter().find(|(o, _)| *o == id).map(|(_, v)| v.iter().copied().collect()).unwrap_or_default(); + add_vector(doc, id, &skip, &mut t, &mut budget); } t } diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index e51e6c0f..b446cf74 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -10,7 +10,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2}; use super::point_snap; use super::transform::{self, BoxHit, PointBox}; -use super::{VHit, VTool, VectorEdit, edit, editing, hit, network, ops, set_state}; +use super::{EditPart, VHit, VTool, VectorEdit, edit, editing, hit, hit_any, local_offset, network, ops, points_of, set_state}; use crate::snap::Targets; use crate::tools::{Cursor, Gesture, Handle, PointerEvent, PointerKind}; use crate::{Result, Session}; @@ -18,20 +18,26 @@ use crate::{Result, Session}; /// An in-progress edit-mode gesture (cheap to clone: snap targets are shared). #[derive(Clone, Debug)] pub(crate) enum VGesture { - /// Drag points (also a segment's ends). `split` re-adds a point on a segment first; a click - /// without a drag clears the point selection when `click_clears` (inside the transform box). + /// Drag points (also a segment's ends) of the active layer and `others`' points with them. + /// `split` re-adds a point on a segment first; a click without a drag clears the point + /// selection when `click_clears` (inside the transform box). Points { start: Point, verts: Vec, + others: Arc)>>, split: Option<(usize, f64)>, moved: bool, click_clears: bool, snap: Arc, }, + /// Drag a handle (`from`: where it was, relative to its point); the other selected handles + /// (`group`, with where they were) move by as much. Handle { seg: usize, start: bool, independent: bool, + from: (f64, f64), + group: Arc>, targets: Arc, }, /// The transform box: a corner or edge scales the points in it. `pick`: a Shift-press on the @@ -60,13 +66,14 @@ pub(crate) enum VGesture { moved: bool, smooth: bool, }, + /// Marquee and lasso: `base` is each edited layer's selection to add to. Marquee { start: Point, - base: Vec, + base: Vec, }, Lasso { points: Vec, - base: Vec, + base: Vec, }, Pen { plan: PenPlan, @@ -91,6 +98,9 @@ pub(crate) enum VGesture { Width(super::width::WidthGesture), } +/// A handle (segment, the one at its `a` end) and where it was, relative to its point. +type HandleFrom = ((usize, bool), (f64, f64)); + /// Snapping for dragged points: their page positions at the start, the one under the pointer (kept /// on whole pixels) and the targets. #[derive(Clone, Debug)] @@ -186,7 +196,7 @@ impl Session { let doc = self.doc()?.clone(); if ve.anchor.is_some() { super::escape(self)?; - } else if hit(&doc, &ve, ev.p(), tol).is_none() && ve.tool == VTool::Move { + } else if hit_any(&doc, &ve, ev.p(), tol).is_none() && ve.tool == VTool::Move { super::end(self)?; } Ok(true) @@ -205,24 +215,91 @@ impl Session { Ok(ds.interaction.as_ref().map_or_else(|| ds.doc.clone(), |it| it.doc.clone())) } - /// A gesture dragging points `verts` of vector `id` from `start` (positions and snap targets - /// taken from the document as it is now). - fn points_gesture(&self, id: NodeId, start: Point, verts: Vec, split: Option<(usize, f64)>, click_clears: bool) -> Result { + /// A gesture dragging points `verts` of vector `id` from `start`, and points of other edited + /// layers with them (positions and snap targets taken from the document as it is now). + fn points_gesture( + &self, + id: NodeId, + start: Point, + verts: Vec, + others: Vec<(NodeId, Vec)>, + split: Option<(usize, f64)>, + click_clears: bool, + ) -> Result { let ds = self.ds()?; - let w = ds.doc.world_transform(id); - let from: Vec = - network(&ds.doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default(); + let page_points = |id: NodeId, verts: &[usize]| -> Vec { + let w = ds.doc.world_transform(id); + network(&ds.doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default() + }; + let mut from = page_points(id, &verts); + for (o, v) in &others { + from.extend(page_points(*o, v)); + } let grab = from.iter().copied().min_by(|a, b| (*a - start).hypot().total_cmp(&(*b - start).hypot())); - let targets = point_snap::targets(&ds.doc, ds.page, Some(id), &verts, &[]); - Ok(VGesture::Points { start, verts, split, moved: false, click_clears, snap: Arc::new(PointSnap { from, grab, targets }) }) + let targets = point_snap::targets_moving(&ds.doc, ds.page, Some(id), &verts, &[], &others); + Ok(VGesture::Points { + start, + verts, + others: Arc::new(others), + split, + moved: false, + click_clears, + snap: Arc::new(PointSnap { from, grab, targets }), + }) } - /// A gesture dragging handle `start` of segment `seg` (snapping clear of its own point). - fn handle_gesture(&self, id: NodeId, seg: usize, start: bool, independent: bool) -> Result { + /// A gesture dragging handle `start` of segment `seg` (snapping clear of its own point) and the + /// handles in `group` with it. + fn handle_gesture(&self, id: NodeId, seg: usize, start: bool, independent: bool, group: &[(usize, bool)]) -> Result { let ds = self.ds()?; - let own: Vec = network(&ds.doc, id).and_then(|n| n.segments.get(seg)).map(|s| vec![if start { s.a } else { s.b }]).unwrap_or_default(); + let net = network(&ds.doc, id); + let own: Vec = net.and_then(|n| n.segments.get(seg)).map(|s| vec![if start { s.a } else { s.b }]).unwrap_or_default(); let targets = Arc::new(point_snap::targets(&ds.doc, ds.page, Some(id), &own, &[])); - Ok(VGesture::Handle { seg, start, independent, targets }) + let from = net.and_then(|n| n.handle(seg, start)).unwrap_or_default(); + let group: Vec = group.iter().filter(|h| **h != (seg, start)).filter_map(|h| Some((*h, net?.handle(h.0, h.1)?))).collect(); + Ok(VGesture::Handle { seg, start, independent, from, group: Arc::new(group), targets }) + } + + /// Every edited layer's selected points (segment ends included) other than the active one's. + fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { + ve.others + .iter() + .filter_map(|o| { + let v = points_of(network(doc, o.id)?, &o.vertices, &o.segments); + (!v.is_empty()).then_some((o.id, v)) + }) + .collect() + } + + /// Each edited layer's selection to add to with Shift (empty selections otherwise). + fn select_base(ve: &VectorEdit, shift: bool) -> Vec { + ve.parts().into_iter().map(|p| EditPart { vertices: if shift { p.vertices } else { vec![] }, segments: vec![], id: p.id }).collect() + } + + /// Select `base` plus the points `found` in each layer (marquee and lasso). + fn select_parts(&mut self, base: &[EditPart], found: impl Fn(&Document, NodeId) -> Vec, clear_segments: bool) -> Result<()> { + let doc = self.doc()?.clone(); + set_state(self, |e| { + for part in base { + let mut v = part.vertices.clone(); + for i in found(&doc, part.id) { + if !v.contains(&i) { + v.push(i); + } + } + if part.id == e.id { + e.vertices = v; + if clear_segments { + e.segments.clear(); + } + } else if let Some(o) = e.others.iter_mut().find(|o| o.id == part.id) { + o.vertices = v; + if clear_segments { + o.segments.clear(); + } + } + } + }) } /// Start a transform box gesture. @@ -232,7 +309,7 @@ impl Session { let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) }; match bh { BoxHit::Inside => { - let g = self.points_gesture(ve.id, ev.p(), b.points, None, true)?; + let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?; self.begin_interaction("Move points")?; self.vgesture(g); } @@ -255,10 +332,13 @@ impl Session { let tol = 6.0 / ev.zoom.max(1e-6); let width_tool = ve.tool == VTool::Width; let h = if width_tool { None } else { hit(&doc, ve, ev.p(), tol) }; + // Other edited layers' parts light up too (a press there makes that layer active). + let shown = if width_tool { None } else { h.map(|h| (ve.id, h)).or_else(|| hit_any(&doc, ve, ev.p(), tol)) }; // A pen click on empty canvas lands where it snaps. let pen_at = if ve.tool == VTool::Pen && h.is_none() && !ev.shift { Some(self.snap_pen(ev, Some(ve.id), &[])?) } else { None }; let ov = &mut self.tool.overlay; - ov.vector_hover = h; + ov.vector_hover = shown.map(|x| x.1); + ov.vector_hover_on = shown.map(|x| x.0); ov.width_hover = None; ov.face.clear(); ov.rubber = None; @@ -325,6 +405,27 @@ impl Session { fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result { let doc = self.doc()?.clone(); let tol = 6.0 / ev.zoom.max(1e-6); + // A press on another edited layer's point, handle or segment (or, with the paint bucket, + // area) makes it the active layer; a pen drawing a path keeps to its own layer. + let other = match hit_any(&doc, ve, ev.p(), tol) { + Some((id, _)) => Some(id), + None if ve.tool == VTool::Paint => { + let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some()); + (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten() + } + None => None, + }; + let switched; + let ve = match other { + Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => { + set_state(self, |e| { + e.activate(id); + })?; + switched = editing(self)?; + &switched + } + _ => ve, + }; let h = hit(&doc, ve, ev.p(), tol); self.tool.overlay.vector_hover = None; self.tool.overlay.face.clear(); @@ -347,9 +448,28 @@ impl Session { match tool { VTool::Move => match h { Some(VHit::Handle { seg, start }) => { - let g = self.handle_gesture(id, seg, start, ev.alt)?; - self.begin_interaction("Edit handle")?; - self.vgesture(g); + // Shift-click adds or takes out a handle; a press on a selected one drags them all. + let key = (seg, start); + let handles = if ev.shift { + let mut hs = ve.handles.clone(); + if let Some(i) = hs.iter().position(|x| *x == key) { + hs.remove(i); + } else { + hs.push(key); + } + hs + } else if ve.handles.contains(&key) { + ve.handles.clone() + } else { + vec![key] + }; + let grabbed = handles.contains(&key); + let g = self.handle_gesture(id, seg, start, ev.alt, &handles)?; + set_state(self, |e| e.handles = handles)?; + if grabbed { + self.begin_interaction("Edit handle")?; + self.vgesture(g); + } } Some(VHit::Vertex { index }) => { let verts = if ev.shift { @@ -366,8 +486,14 @@ impl Session { vec![index] }; let keep_segs = ev.shift; + // Shift, or a press on a selected point, keeps (and drags) other layers' points. + let keep_others = ev.shift || ve.vertices.contains(&index); set_state(self, |e| { + if !keep_others { + e.clear_selection(); + } e.vertices = verts.clone(); + e.handles.clear(); if !keep_segs { e.segments.clear(); } @@ -380,7 +506,8 @@ impl Session { ) }) .unwrap_or_default(); - let g = self.points_gesture(id, ev.p(), moving, None, false)?; + let others = if keep_others { Self::others_moving(&doc, ve) } else { vec![] }; + let g = self.points_gesture(id, ev.p(), moving, others, None, false)?; self.begin_interaction("Move points")?; self.vgesture(g); } @@ -395,10 +522,10 @@ impl Session { })?; let Some(v) = v else { return Ok(true) }; set_state(self, |e| { + e.clear_selection(); e.vertices = vec![v]; - e.segments.clear(); })?; - let g = self.points_gesture(id, ev.p(), vec![v], Some((seg, t)), false)?; + let g = self.points_gesture(id, ev.p(), vec![v], vec![], Some((seg, t)), false)?; self.vgesture(g); } else if ev.shift { set_state(self, |e| { @@ -411,34 +538,31 @@ impl Session { } else { let ends = network(&doc, id).and_then(|n| n.segments.get(seg)).map(|s| vec![s.a, s.b]).unwrap_or_default(); set_state(self, |e| { - e.vertices.clear(); + e.clear_selection(); e.segments = vec![seg]; })?; - let g = self.points_gesture(id, ev.p(), ends, None, false)?; + let g = self.points_gesture(id, ev.p(), ends, vec![], None, false)?; self.begin_interaction("Move segment")?; self.vgesture(g); } } 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())); + // Outside every edited layer (and not shift): leave edit mode. + let inside = + ve.parts().iter().any(|p| doc.world_render_bounds(p.id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()))); if !inside && !ev.shift { super::end(self)?; return Ok(false); } - let base = if ev.shift { ve.vertices.clone() } else { vec![] }; - set_state(self, |e| { - e.vertices = base.clone(); - if !ev.shift { - e.segments.clear(); - } - })?; - self.vgesture(VGesture::Marquee { start: ev.p(), base }); + if !ev.shift { + set_state(self, VectorEdit::clear_selection)?; + } + self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) }); } }, VTool::Bend => match h { Some(VHit::Handle { seg, start }) => { - let g = self.handle_gesture(id, seg, start, true)?; + let g = self.handle_gesture(id, seg, start, true, &[])?; self.begin_interaction("Edit handle")?; self.vgesture(g); } @@ -447,8 +571,8 @@ impl Session { network(&doc, id).is_some_and(|n| n.incident(index).iter().any(|(s, st)| n.handle(*s, *st).is_some_and(|h| h != (0.0, 0.0)))); let origin = network(&doc, id).and_then(|n| n.vertices.get(index)).map(|v| v.point()).unwrap_or_default(); set_state(self, |e| { + e.clear_selection(); e.vertices = vec![index]; - e.segments.clear(); })?; self.begin_interaction("Bend")?; self.vgesture(VGesture::BendVertex { v: index, origin, moved: false, smooth }); @@ -457,10 +581,7 @@ impl Session { self.begin_interaction("Bend")?; self.vgesture(VGesture::BendSegment { seg, t }); } - None => { - let base = if ev.shift { ve.vertices.clone() } else { vec![] }; - self.vgesture(VGesture::Marquee { start: ev.p(), base }); - } + None => self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) }), }, VTool::Pen => { let at = if h.is_none() && !ev.shift { self.snap_pen(ev, Some(id), &[])?.0 } else { ev.p() }; @@ -498,9 +619,8 @@ impl Session { } VTool::Width => self.width_down(ve, ev)?, VTool::Lasso => { - let base = if ev.shift { ve.vertices.clone() } else { vec![] }; self.tool.overlay.lasso = vec![[ev.x, ev.y]]; - self.vgesture(VGesture::Lasso { points: vec![ev.p()], base }); + self.vgesture(VGesture::Lasso { points: vec![ev.p()], base: Self::select_base(ve, ev.shift) }); } // A click cuts there when the pointer goes up; a drag cuts across (see `cut_up`). VTool::Cut => { @@ -591,7 +711,7 @@ impl Session { let lp = inv * ev.p(); let to_local = |d: Vec2| inv * (Point::ZERO + d) - inv * Point::ZERO; match g { - VGesture::Points { start, verts, split, click_clears, snap, .. } => { + VGesture::Points { start, verts, others, split, click_clears, snap, .. } => { let mut d = ev.p() - start; if d.hypot() * ev.zoom < 1.0 { return Ok(()); @@ -614,11 +734,16 @@ impl Session { ops::split_segment(net, seg, t); } ops::move_vertices(net, &verts, ld); - }) + })?; + for (o, v) in others.iter() { + let od = local_offset(doc.world_transform(*o), d); + edit(doc, *o, |net| ops::move_vertices(net, v, od))?; + } + Ok(()) })?; - self.vgesture(VGesture::Points { start, verts, split, moved: true, click_clears, snap }); + self.vgesture(VGesture::Points { start, verts, others, split, moved: true, click_clears, snap }); } - VGesture::Handle { seg, start, independent, targets } => { + VGesture::Handle { seg, start, independent, from, group, targets } => { let alt = independent || ev.alt; let (c, guides) = point_snap::snap(&targets, &[ev.p()], Some(ev.p()), ev.zoom, modes, None); self.tool.overlay.guides = guides; @@ -627,7 +752,13 @@ impl Session { edit(doc, id, |net| { let Some(s) = net.segments.get(seg).copied() else { return }; let Some(v) = net.vertices.get(if start { s.a } else { s.b }).copied() else { return }; - ops::set_handle(net, seg, start, (lp.x - v.x, lp.y - v.y), alt); + let rel = (lp.x - v.x, lp.y - v.y); + ops::set_handle(net, seg, start, rel, alt); + // The other selected handles move as far from where they were. + let d = (rel.0 - from.0, rel.1 - from.1); + for ((gs, gst), h) in group.iter() { + ops::set_handle(net, *gs, *gst, (h.0 + d.0, h.1 + d.1), alt); + } }) })?; } @@ -666,29 +797,14 @@ impl Session { VGesture::Marquee { start, 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 super::vertices_in(self.doc()?, id, r) { - if !v.contains(&i) { - v.push(i); - } - } - set_state(self, |e| e.vertices = v)?; + self.select_parts(&base, |doc, id| super::vertices_in(doc, id, r), false)?; } VGesture::Lasso { mut points, base } => { if points.last().is_none_or(|l| (*l - ev.p()).hypot() * ev.zoom >= 2.0) && points.len() < 100_000 { points.push(ev.p()); } self.tool.overlay.lasso = points.iter().map(|p| [p.x, p.y]).collect(); - let mut v = base.clone(); - for i in super::vertices_in_polygon(self.doc()?, id, &points) { - if !v.contains(&i) { - v.push(i); - } - } - set_state(self, |e| { - e.vertices = v; - e.segments.clear(); - })?; + self.select_parts(&base, |doc, id| super::vertices_in_polygon(doc, id, &points), true)?; self.vgesture(VGesture::Lasso { points, base }); } VGesture::Erase { mut stamps, radius } => { @@ -793,7 +909,14 @@ impl Session { VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } | VGesture::BoxRotate { .. } => { self.commit_interaction()? } - VGesture::Marquee { .. } | VGesture::Lasso { .. } => {} + // The active layer moves to one with points selected when it has none. + VGesture::Marquee { .. } | VGesture::Lasso { .. } => set_state(self, |e| { + if e.vertices.is_empty() + && let Some(id) = e.others.iter().find(|o| !o.vertices.is_empty()).map(|o| o.id) + { + e.activate(id); + } + })?, VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?, VGesture::BoxScale { pick, .. } => { let ds = self.ds()?; diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index bce8b2ca..1d5f27a6 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -66,9 +66,10 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option { } /// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more -/// points that aren't all in one place. +/// points that aren't all in one place, all in the active layer. +/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for. pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option { - if ve.tool != VTool::Move || ve.anchor.is_some() { + if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() { return None; } point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS)) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eec3a2ca..eea21d21 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -158,6 +158,10 @@ impl Headless { "select.children" if self.session.tool.pen.is_some() => { let _ = self.session.pen_finish(); } + // Enter on shapes edits them as vectors (several at once). + "select.children" if self.vector_targets().is_some() => { + self.execute("vector.edit.begin", &json!({"ids": self.vector_targets()}))?; + } id => { self.execute(id, &json!({}))?; } @@ -184,6 +188,11 @@ impl Headless { } /// The selected layer when exactly one text layer is selected. + /// The selected layers Enter edits as vectors (see `vectoredit::enter_targets`). + fn vector_targets(&self) -> Option> { + figcraft_engine::vectoredit::enter_targets(self.session.doc().ok()?, &self.session.selection()) + } + fn single_text(&self) -> Option { let sel = self.session.selection(); let doc = self.session.doc().ok()?; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index d712fb55..553fce04 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -11,13 +11,14 @@ pub mod brush; mod brush_tests; #[cfg(test)] mod ops_tests; +mod points; #[cfg(test)] mod sides_tests; pub mod width; #[cfg(test)] mod width_tests; -use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps}; +use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork}; use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; #[derive(Debug, PartialEq)] @@ -241,7 +242,7 @@ pub fn union_faces<'a>(faces: impl IntoIterator) -> Result Cap { +pub(crate) fn kcap(c: StrokeCap) -> Cap { match c { StrokeCap::Round | StrokeCap::CircleFilled => Cap::Round, StrokeCap::Square | StrokeCap::DiamondFilled => Cap::Square, @@ -249,7 +250,7 @@ fn kcap(c: StrokeCap) -> Cap { } } -fn kjoin(j: StrokeJoin) -> Join { +pub(crate) fn kjoin(j: StrokeJoin) -> Join { match j { StrokeJoin::Miter => Join::Miter, StrokeJoin::Bevel => Join::Bevel, @@ -277,15 +278,23 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath /// shape is closed (Inside/Outside only apply to closed shapes). A width profile makes the width /// vary along the path (fill the result non-zero when centred). pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) -> BezPath { + stroke_outline_of(path, props, closed_shape, None) +} + +/// [`stroke_outline`] of a shape whose stroke follows vector network `net` (`path` is its stroke +/// path): points with a cap or join of their own draw them there. +pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool, net: Option<&VectorNetwork>) -> BezPath { let w = props.weight; if !(w.is_finite() && w > 0.0) { return BezPath::new(); } + let styled = net.and_then(|n| points::Styled::new(n, props)); // Centred at `width` (twice the weight for Inside/Outside, which clip half of it away). - let centred = |width: f64| match (&props.brush, props.profile()) { - (Some(b), _) => brush::outline(path, width, props, b).unwrap_or_else(|| stroke_center(path, width, props)), - (None, Some(p)) => width::variable_outline(path, width, props, p).unwrap_or_else(|| stroke_center(path, width, props)), - (None, None) => stroke_center(path, width, props), + let centred = |width: f64| match (&styled, &props.brush, props.profile()) { + (Some(st), ..) => st.outline(width, props), + (None, Some(b), _) => brush::outline(path, width, props, b).unwrap_or_else(|| stroke_center(path, width, props)), + (None, None, Some(p)) => width::variable_outline(path, width, props, p).unwrap_or_else(|| stroke_center(path, width, props)), + (None, None, None) => stroke_center(path, width, props), }; let align = if closed_shape { props.align } else { StrokeAlign::Center }; let mut out = match align { @@ -299,7 +308,9 @@ pub fn stroke_outline(path: &BezPath, props: &StrokeProps, closed_shape: bool) - binary(&s, path, linesweeper::BinaryOp::Difference).unwrap_or(s) } }; - if !closed_shape { + if let Some(st) = styled.as_ref().filter(|_| !closed_shape) { + out.extend(st.arrowheads(w, props).elements().iter().copied()); + } else if !closed_shape { for (at_end, cap) in [(false, props.cap_start), (true, props.cap_end)] { if cap.is_arrow() && let Some(head) = arrowhead(path, cap, w, at_end) diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs new file mode 100644 index 00000000..d432da05 --- /dev/null +++ b/crates/pathops/src/points.rs @@ -0,0 +1,97 @@ +//! Strokes of vector networks whose points have caps and joins of their own: the stroke is drawn +//! run by run (a run ends at end points, junctions and points with a join of their own), each run +//! taking the caps of the points it ends at, and each point's own join is drawn over the break. + +use figcraft_doc::{StrokeCap, StrokeProps, StrokeRun, VectorNetwork}; +use figcraft_geom::kurbo::{self, BezPath, Cap}; + +use crate::{kcap, kjoin}; + +/// A network ready to stroke with its points' styles: rounded points drawn in, its runs and each +/// point's segment count. +pub(crate) struct Styled { + net: VectorNetwork, + runs: Vec, + degrees: Vec, +} + +impl Styled { + /// `None` when no point has a style of its own, or the stroke is dashed, variable or a brush. + /// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for. + pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option { + if !net.has_point_styles() || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { + return None; + } + let net = net.rounded().unwrap_or_else(|| net.clone()); + let runs = net.stroke_runs(); + let mut degrees = vec![0usize; net.vertices.len()]; + for s in &net.segments { + for v in [s.a, s.b] { + if let Some(d) = degrees.get_mut(v) { + *d += 1; + } + } + } + Some(Styled { net, runs, degrees }) + } + + /// Whether point `v` breaks a run to draw its own join. + fn breaks(&self, v: usize) -> bool { + self.degrees.get(v) == Some(&2) && self.net.vertices.get(v).is_some_and(|x| x.join.is_some()) + } + + /// The cap where a run ends at point `v`: none at a join of its own, else the point's, else + /// `layer`. + fn cap_at(&self, v: usize, layer: StrokeCap) -> StrokeCap { + if self.breaks(v) { StrokeCap::None } else { self.net.vertices.get(v).and_then(|x| x.cap).unwrap_or(layer) } + } + + /// The stroke centred at `width`. + pub(crate) fn outline(&self, width: f64, props: &StrokeProps) -> BezPath { + let mut out = BezPath::new(); + let miter = props.miter_limit.clamp(1.0, 100.0); + for run in &self.runs { + let style = kurbo::Stroke::new(width) + .with_join(kjoin(props.join)) + .with_miter_limit(miter) + .with_start_cap(kcap(self.cap_at(run.start, props.cap_start))) + .with_end_cap(kcap(self.cap_at(run.end, props.cap_end))); + out.extend(kurbo::stroke(run.path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05).elements().iter().copied()); + } + // Each point's own join: a short corner along its two segments stroked with that join. + let reach = (width * 0.05).max(0.2); + for (v, x) in self.net.vertices.iter().enumerate() { + let Some(join) = x.join.filter(|_| self.breaks(v)) else { continue }; + let inc = self.net.incident(v); + let [(s1, a1), (s2, a2)] = inc.as_slice() else { continue }; + let (Some(d1), Some(d2)) = (self.net.tangent_out(*s1, *a1), self.net.tangent_out(*s2, *a2)) else { continue }; + let p = x.point(); + let mut corner = BezPath::new(); + corner.move_to(p + d1 * reach); + corner.line_to(p); + corner.line_to(p + d2 * reach); + let style = kurbo::Stroke::new(width).with_join(kjoin(join)).with_miter_limit(miter).with_caps(Cap::Butt); + out.extend(kurbo::stroke(corner.iter(), &style, &kurbo::StrokeOpts::default(), 0.05).elements().iter().copied()); + } + out + } + + /// Arrowheads at run ends: a point's own arrow cap, and the layer's at the stroke's first start + /// and last end (as without point styles). + pub(crate) fn arrowheads(&self, weight: f64, props: &StrokeProps) -> BezPath { + let mut out = BezPath::new(); + let last = self.runs.len().saturating_sub(1); + for (k, run) in self.runs.iter().enumerate().filter(|(_, r)| !r.closed) { + let start = self.cap_at(run.start, if k == 0 { props.cap_start } else { StrokeCap::None }); + let end = self.cap_at(run.end, if k == last { props.cap_end } else { StrokeCap::None }); + for (at_end, cap) in [(false, start), (true, end)] { + if cap.is_arrow() + && let Some(head) = crate::arrowhead(&run.path, cap, weight, at_end) + { + out.extend(head.elements().iter().copied()); + } + } + } + out + } +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index dbb2b01f..f43659ef 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -151,6 +151,14 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { d.to_bits().hash(&mut h); } props.dashes.len().hash(&mut h); + // Points' own caps and joins (vector networks). + let net = match &n.kind { + NodeKind::Vector { network } if network.has_point_styles() => Some(network), + _ => None, + }; + for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() { + (v.cap.map(|c| c as u8), v.join.map(|j| j as u8)).hash(&mut h); + } for p in props.profile().map(|p| p.points.as_slice()).unwrap_or_default() { (p.t.to_bits(), p.scale.to_bits(), p.bias.to_bits()).hash(&mut h); } @@ -161,7 +169,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { if let Some(p) = locked(stroke_cache()).get(key) { return p; } - let out = Arc::new(figcraft_pathops::stroke_outline(path, props, closed)); + let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, net)); locked(stroke_cache()).insert(key, out.clone()); out } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index bd48abe8..1ae35070 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -581,6 +581,7 @@ impl W<'_> { || k.brush.is_some() || k.cap_start.is_arrow() || k.cap_end.is_arrow() + || matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles()) || !(w.is_finite() && w > 0.0) { return None; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 51f27f1d..69e9b823 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -977,6 +977,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Same as layer" = "Same as layer" "Saturation" = "Saturation" "Save" = "Save" "Save as" = "Save as" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1c0f233a..68f7edc1 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -978,6 +978,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Same as layer" = "Igual que la capa" "Saturation" = "Saturación" "Save" = "Guardar" "Save as" = "Guardar como" @@ -1802,6 +1803,7 @@ status = "draft — needs native review" "vector.edit.erase" = "Borrar" "vector.edit.lasso" = "Selección con lazo" "vector.edit.mirroring" = "Simetría de manejadores" +"vector.edit.moveHandles" = "Mover manejadores" "vector.edit.moveVertices" = "Mover puntos" "vector.edit.nudge" = "Desplazar puntos" "vector.edit.paint" = "Bote de pintura" @@ -1815,6 +1817,8 @@ status = "draft — needs native review" "vector.edit.setHandle" = "Establecer manejador" "vector.edit.smooth" = "Suavizar o afilar puntos" "vector.edit.state" = "Estado de edición vectorial" +"vector.edit.strokeCap" = "Remate del trazo en el punto" +"vector.edit.strokeJoin" = "Unión del trazo en el punto" "vector.edit.tool" = "Herramienta de edición vectorial" "vector.edit.transform" = "Transformar puntos" "vector.edit.widthPoint" = "Punto de ancho" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0ae9363b..61a8b97a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -837,7 +837,15 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ { // The Variable width tool shows width points in place of the path's points. let width_tool = ve.tool == figcraft_engine::vectoredit::VTool::Width; - vector_overlay(app, painter, &o, !width_tool); + // Other layers edited at once go under the active one; the hover shows on its own layer. + let ov = &app.session.tool.overlay; + let hover_on = |id| ov.vector_hover.filter(|_| ov.vector_hover_on.unwrap_or(ve.id) == id); + for part in &ve.others { + if let Some(po) = figcraft_engine::vectoredit::overlay(&doc, &ve.view(part)) { + vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false); + } + } + vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true); if width_tool { width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { @@ -1089,18 +1097,25 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig } } -/// Vector edit mode: the network's segments, handles and points (`points`), the hovered part, the -/// closed area under the paint bucket and the pen's rubber band. -fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::vectoredit::EditOverlay, points: bool) { +/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and +/// for the `active` layer the closed area under the paint bucket and the pen's rubber band. +fn vector_overlay( + app: &FigApp, + painter: &egui::Painter, + o: &figcraft_engine::vectoredit::EditOverlay, + points: bool, + hover: Option, + active: bool, +) { use figcraft_engine::vectoredit::VHit; let ov = &app.session.tool.overlay; let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]); // Paint bucket target: hatched like a fill to come. - if !ov.face.is_empty() { + if active && !ov.face.is_empty() { let polys: Vec> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect(); hatched(painter, &polys, ACCENT); } - let hover_seg = match ov.vector_hover { + let hover_seg = match hover { Some(VHit::Segment { seg, .. }) => Some(seg), _ => None, }; @@ -1121,14 +1136,15 @@ fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::ve for h in &o.handles { let (v, e) = (sp(&h.vertex), sp(&h.handle)); painter.line_segment([v, e], stroke(ACCENT, 1.0)); - let hovered = ov.vector_hover == Some(VHit::Handle { seg: h.seg, start: h.start }); + let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); let r = if hovered { 4.5 } else { 3.5 }; - painter.circle_filled(e, r, Color32::WHITE); + let on = o.selected_handles.contains(&(h.seg, h.start)); + painter.circle_filled(e, r, if on { ACCENT } else { Color32::WHITE }); painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); - let hovered = matches!(ov.vector_hover, Some(VHit::Vertex { index }) if index == i); + let hovered = matches!(hover, Some(VHit::Vertex { index }) if index == i); let size = if hovered { 9.0 } else { 7.0 }; let r = Rect::from_center_size(p, vec2(size, size)); let on = o.selected_vertices.contains(&i); @@ -1138,7 +1154,7 @@ fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::ve painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0)); } } - if let Some([x0, y0, x1, y1]) = ov.rubber { + if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0)); } } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 4bd4666b..086258a4 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1381,17 +1381,11 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] let join = st.as_ref().map(|s| s.join); widgets::sublabels(ui, tr("Join|stroke"), (join == Some(StrokeJoin::Miter)).then_some(tr("Miter angle"))); row(ui, 32.0, |ui| { - let label = match join { - Some(StrokeJoin::Miter) => tr("Miter"), - Some(StrokeJoin::Bevel) => tr("Bevel"), - Some(StrokeJoin::Round) => tr("Round"), - None => tr("Mixed"), - }; - let resp = widgets::dropdown(ui, label, half, None); + let resp = widgets::dropdown(ui, join.map(join_label).unwrap_or(tr("Mixed")), half, None); menu_popup(&resp).show(|ui| { - for (l, v) in [(tr("Miter"), "miter"), (tr("Bevel"), "bevel"), (tr("Round"), "round")] { - if menu_item(ui, l, "") { - app.set_props(&ids, json!({"strokeJoin": v})); + for j in StrokeJoin::ALL { + if menu_item(ui, join_label(j), "") { + app.set_props(&ids, json!({"strokeJoin": j})); ui.close(); } } @@ -1423,12 +1417,21 @@ fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] ui.add_space(4.0); } +/// A stroke join's name in the interface. +pub(crate) fn join_label(j: StrokeJoin) -> &'static str { + match j { + StrokeJoin::Miter => tr("Miter"), + StrokeJoin::Bevel => tr("Bevel"), + StrokeJoin::Round => tr("Round"), + } +} + /// The width [`cap_art`] takes at the right of a menu row (its arrows reach past the line's ends). -const CAP_ART_W: f32 = 64.0; +pub(crate) const CAP_ART_W: f32 = 64.0; /// A short stroke ending in `cap` (on its left end for a start point), drawn at the right of a menu /// row. -fn cap_art(p: &egui::Painter, r: Rect, col: Color32, cap: StrokeCap, start: bool) { +pub(crate) fn cap_art(p: &egui::Painter, r: Rect, col: Color32, cap: StrokeCap, start: bool) { let y = r.center().y; let (left, right) = (r.max.x - 58.0, r.max.x - 14.0); // The capped end, the plain end, and the direction from the stroke into its cap. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 397cf69b..93d6ece8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -265,9 +265,16 @@ pub fn opacity_digit(app: &mut FigApp, key: Key, now: f64) -> bool { true } -/// Enter on a single selected text layer starts editing it. +/// Enter on a single selected text layer starts editing it; on shapes, edits them as vectors. fn enter_text(app: &mut FigApp) -> bool { let sel = app.session.selection(); + if sel.len() > 1 { + let ids = app.session.doc().ok().and_then(|d| figcraft_engine::vectoredit::enter_targets(d, &sel)); + return ids.is_some_and(|ids| { + app.run("vector.edit.begin", json!({"ids": ids})); + true + }); + } if sel.len() != 1 { return false; } diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index b3a2724a..13f1ac8d 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -1,16 +1,17 @@ //! Design panel in vector edit mode: the selected points' position and dimensions (their -//! transform box), corner radius and handle mirroring, or the selected width point with the -//! Variable width tool (in place of the layer's position and dimensions). +//! transform box), corner radius, handle mirroring and the stroke's cap and join at them, or the +//! selected width point with the Variable width tool (in place of the layer's position and +//! dimensions). use egui::{Id, Ui}; -use figcraft_doc::{Document, HandleMirroring}; +use figcraft_doc::{Document, HandleMirroring, StrokeCap, StrokeJoin}; use figcraft_engine::vectoredit::{self, VectorEdit}; use serde_json::{Value, json}; use crate::FigApp; use crate::i18n::tr; use crate::icons::Icon; -use crate::right::{menu_item, menu_popup, row}; +use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row}; use crate::widgets::{self, FieldEdit, Lead, PAD}; /// Run a point command from a field: typed values are one step, scrubs one step when released. @@ -110,17 +111,12 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { point_field(app, "vector.edit.resize", e, |v| json!({"height": v.max(0.0)})); }); } - let common = |f: &dyn Fn(usize) -> Option| -> Option { - let mut it = pts.iter().map(|v| f(*v)); - let first = it.next().flatten()?; - it.all(|x| x == Some(first)).then_some(first) - }; - let radius = common(&|v| net.vertices.get(v).map(|x| x.radius)); - let mirror = { - let mut it = pts.iter().map(|v| net.mirroring(*v)); - let first = it.next(); - first.filter(|m| it.all(|x| x == *m)) - }; + let vertex = |v: &usize| net.vertices.get(*v).copied(); + let radius = same(pts.iter().map(|v| vertex(v).map(|x| x.radius))).flatten(); + let mirror = same(pts.iter().map(|v| net.mirroring(*v))); + // A point's own cap and join (`Some(None)`: the layer's; `None`: mixed). + let cap = same(pts.iter().map(|v| vertex(v).map(|x| x.cap))).flatten(); + let join = same(pts.iter().map(|v| vertex(v).map(|x| x.join))).flatten(); widgets::sublabels(ui, tr("Corner radius"), None); row(ui, 32.0, |ui| { let e = @@ -144,5 +140,46 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { } }); }); + // The stroke where it ends and turns at these points. + let own = |set: Option>| match set { + None => tr("Mixed"), + Some(None) => tr("Same as layer"), + Some(Some(l)) => l, + }; + widgets::sublabels(ui, tr("End point"), Some(tr("Join|stroke"))); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, own(cap.map(|c| c.map(|c| tr(c.label())))), col, None); + menu_popup(&resp).show(|ui| { + if menu_item(ui, tr("Same as layer"), "") { + app.run("vector.edit.strokeCap", json!({"cap": null})); + ui.close(); + } + for c in StrokeCap::ALL { + if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, false)) { + app.run("vector.edit.strokeCap", json!({"cap": c})); + ui.close(); + } + } + }); + let resp = widgets::dropdown(ui, own(join.map(|j| j.map(join_label))), col, None); + menu_popup(&resp).show(|ui| { + if menu_item(ui, tr("Same as layer"), "") { + app.run("vector.edit.strokeJoin", json!({"join": null})); + ui.close(); + } + for j in StrokeJoin::ALL { + if menu_item(ui, join_label(j), "") { + app.run("vector.edit.strokeJoin", json!({"join": j})); + ui.close(); + } + } + }); + }); ui.add_space(8.0); } + +/// The value every item shares, if they all share one. +fn same(mut it: impl Iterator) -> Option { + let first = it.next()?; + it.all(|x| x == first).then_some(first) +} diff --git a/docs/gaps.md b/docs/gaps.md index 3b485f3e..af61353c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -22,7 +22,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 6 | Plugins: no distribution or widgets; Figma's plugins don't run | Ecosystem | 15–40 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | -| 9 | Figma Draw: multi-edit, pen pressure on macOS/Linux tablets (brushes, pressure, text on a path and pattern fills landed) | Feature | 2–5 | [target-app-parity](target-app-parity.md) | +| 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | | 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | | 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | @@ -152,8 +152,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe desktop app). - **Estimate:** 10–20 h, partly human (hardware). -### 9. Figma Draw: multi-edit and tablet pressure -- **Missing:** multi-edit; pen pressure on macOS and Linux tablets (winit reports none there). +### 9. Figma Draw: tablet pressure on macOS and Linux +- **Missing:** pen pressure on macOS and Linux tablets (winit reports none there). +- **Landed (2026-10-12):** multi-edit: several vector layers in edit mode at once (Enter or + double-click with them selected, `vector.edit.begin {ids}`); points selected, marqueed, dragged, + nudged, deleted, mirrored and rounded across layers in one undo step; the pen, bend, paint bucket, + cut and eraser work in the layer last pressed (`vector_multi_behaviour_tests`). - **Landed:** text on a path (`text.onPath`, `text.pathOptions`, `text.removeFromPath`; the Design panel's Typography section; canvas, PNG, SVG and PDF as outlines; SVG `` imports as text on a path), pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's @@ -161,9 +165,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe eight presets drawn in code; canvas, PNG, SVG, PDF and outline stroke) and pen pressure for the Pencil (Windows pens and browser styluses; recorded as the stroke's width profile, which brushes follow). -- **Evidence:** no pressure input anywhere; strokes have uniform and variable widths only. +- **Evidence:** winit reports no pressure on macOS and Linux tablets. - **Impact:** illustration-flavoured work moved into Figma Draw in 2025. -- **Estimate:** 5–10 h. +- **Estimate:** 1–3 h once winit (or a platform tablet API behind it) reports pressure there. ### 10. Team libraries: no team-wide lists or accounts; analytics are counts - **Missing:** libraries listed for a whole team or organization (each file enables a library by @@ -192,6 +196,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). ### 13. Vector tail: none left +- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, + sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, + PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a + selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around + points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. - **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked; "Select layer" lists the group only where its result is), live @@ -314,6 +323,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Gap 9 2–5 h → 1–3 h (pen pressure on macOS and Linux tablets left, blocked on winit). Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | diff --git a/docs/mcp.md b/docs/mcp.md index 808ac4b0..507832f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -265,9 +265,14 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: ### Editing vectors Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): -`vector.edit.begin {id}` (converts shapes to vectors), `vector.edit.state` (page-space overlay, the -local network, each point's handle mirroring and the width points), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut|eraser|width}`, -`vector.edit.select {vertices?, segments?, add?}`, `vector.edit.selectAll`, `vector.edit.moveVertices`, +`vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers +are edited at once, the first active; `key` Enter with shapes selected does the same), +`vector.edit.state` (page-space overlay of the active layer, its local network, each point's handle +mirroring, the width points, `layers` (every edited layer, the active first), `others` (the other +layers' selected points and segments) and `handles` (the selected Bezier handles as +`[segment, start]`)), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut|eraser|width}`, +`vector.edit.select {vertices?, segments?, handles?: [[segment, start], ...], id? (an edited layer, +made active), add?}`, `vector.edit.selectAll` (every edited layer's points), `vector.edit.moveVertices`, `vector.edit.nudge`, `vector.edit.position {x?, y?}`, `vector.edit.setHandle`, `vector.edit.mirroring {mode: none|angle|angleAndLength}`, `vector.edit.smooth {smooth?}`, `vector.edit.bend`, `vector.edit.addVertex`, `vector.edit.connect {a, b}`, `vector.edit.pen {x, y, dx?, dy?}` (continues @@ -279,9 +284,28 @@ page line crosses; parts that come apart move to layers of their own above, list `vector.edit.erase {points, radius?}` (the eraser), `vector.edit.lasso {points}`, `vector.edit.delete`, `vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing), -`vector.edit.cornerRadius` (rounds the selected points that join two straight segments, drawn and -exported; `node.set {cornerRadius}` on a vector rounds every point), `vector.edit.end`. Point -indices refer to `network.vertices` in `vector.edit.state`; local coordinates are the layer's own. +`vector.edit.cornerRadius` (rounds the selected sharp points, next to straight or curved segments: a +curve is cut where the arc meets it and keeps the rest of its shape; drawn and exported; +`node.set {cornerRadius}` on a vector rounds every point), `vector.edit.strokeCap {cap, vertices?}` +and `vector.edit.strokeJoin {join, vertices?}` (the stroke's cap where it ends at these points and +its join where it turns there, `null` for the layer's; drawn, exported and outlined), +`vector.edit.moveHandles {dx, dy, handles?}` (moves the selected handles by a page offset), +`vector.edit.end`. Point indices refer to `network.vertices` in `vector.edit.state`; local +coordinates are the layer's own. + +Several layers at once: with more than one layer in edit mode, a press on another layer's point, +handle or segment makes it the active layer; Shift-click and marquee or lasso select points in any of +them; dragging a selected point, the arrows, Delete, `vector.edit.nudge`, `vector.edit.moveVertices`, +`vector.edit.delete`, `vector.deleteHeal`, `vector.edit.mirroring`, `vector.edit.smooth`, +`vector.edit.cornerRadius`, `vector.edit.strokeCap` and `vector.edit.strokeJoin` reach every layer's +selected points in one undo step (a `vertices` list names points of the active layer only). The pen, +bend, paint bucket, cut, eraser, join, position and the transform box work in the active layer; the +box hides while points are selected in more than one layer. Shift-click Bezier handles to select +several; dragging one drags them all. + +The Pen tool outside edit mode: clicking the path's first point closes it; clicking another of its +own points (not the last) connects there and ends the path; with an open vector selected, a click on +one of its end points continues that path in edit mode. `vector.deleteHeal` fits the healed curve through where the deleted point was, keeping the neighbours' tangents. With `pointer`, the Paint bucket and Cut tools also drag (across areas to paint, across paths to divide), one undo step each; `shift` on a selected point at a corner of the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index da82f691..433d10a1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -17,11 +17,11 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c |---|---|---| | Menu/command breadth | **100%** (204 / 204 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) | | Feature breadth (does each Figma Design feature exist, weighted) | **~84%** (core editor areas 1–16: ~94%) | estimated from the area table below | -| Ready for real work | **~51%** | estimated (dimension table below: written weights × written per-dimension scores) | +| Ready for real work | **~52%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | -| Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.3% × discounts 0.9 × 0.9 × 0.85) | +| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 72.4% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~301–587 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~289–564 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~51% | ~296–569 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | -| Mainstream practitioner | ~50% | ~131–245 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~52% | ~294–566 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -97,7 +97,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 98% | 72% | Corner smoothing on polygons, stars and vectors; a join or cap per point; rounding points beside curved segments; several handles dragged at once; vector QA with real files (behaviour suite from the documentation, corner radius and star ratio handles, rounded vector points drawn, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 2–4 | +| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side; inner shadow and background blur on groups (behaviour suite from the documentation, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | @@ -113,16 +113,16 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat ([collaboration-parity](collaboration-parity.md)) | 45–90 | | 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | -| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | +| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **143–301** | -| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 41–87 | +| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **128–276** | +| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 33–75 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 61% | 41–86 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | +| Features (depth) | 35 | 63% | 29–68 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~51%** | **~301–587** | | | +| **Ready for real work** | 100 | **~52%** | **~289–564** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **72.3%**. +**2. Average depth** ("Ready" column, weighted): **72.4%**. **3. Discounts for what still stops real work:** @@ -197,14 +197,14 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -72.3% × 0.90 × 0.90 × 0.85 = **~50%** (estimated). +72.4% × 0.90 × 0.90 × 0.85 = **~49%** (estimated; 49.9%, rounded down). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is private, 1 star, PRs only from maintainers). No outside evidence either way; nothing raises the number. -**Why it is lower than ready for real work (~51%), unusually.** The full number averages +**Why it is lower than ready for real work (~52%), unusually.** The full number averages dimensions additively, so the missing `.fig` exchange costs only the file-format dimension's 12% weight, and FigCraft earns points the mainstream number doesn't count (platforms 75%: Linux, FreeBSD and the web, where Figma has no desktop app). The mainstream number applies file exchange @@ -612,6 +612,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 17b7de1d..a082c348 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -58,8 +58,8 @@ interaction feel not yet compared. | Frame (presets panel, swap orientation), section, slice | done | | | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | -| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; the Pen outside edit mode closes only on its first point | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`). Not yet: several vectors edited at once, several handles dragged together, a join or cap per point | +| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`) | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`). Not yet: the transform box around points of several layers | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -104,6 +104,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes | | 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite and fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame; a guide can be selected, deleted, nudged and removed from the context menu, Alt-dragged into a copy, and measured from with Alt while dragging; Auto column and row counts; layout grids hidden on rotated frames; outline mode clicks reach layers behind. Outline mode back to partial (two documented options missing) | | 2026-10-11 | minor | Shapes and vector behaviour suite and fixes: corner radius and star ratio handles on the canvas, Count and Ratio fields, Alt draws lines from the centre, paint bucket and cut drags, delete and heal along the curve, rounded vector points drawn, Shift-click deselects a point at a box corner, the miter field shows degrees, individual corners only for rectangles and frames | | 2026-10-11 | minor | Paint and effects behaviour suite: the blend menu's Normal sets Normal (it set pass-through), a blend menu for each paint in the colour picker, the eyedropper's rules covered by tests |