diff --git a/ROADMAP.md b/ROADMAP.md index a0f3eddd..42bc0019 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 30745fa2..921d1e84 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -250,7 +250,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { // Edges come back from the planar map within a few hundredths of the outlines they follow. let cut_tol = step * 0.2; let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let union = if picked.is_empty() { PathData::default() } else { po::merge_regions(&picked) }; + let faces = po::FaceMerger::new(&map.shapes); + let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; // The merged shape takes the look of the front-most object over the first region, or (for an // area only lines enclose) of the front-most object and the current fill. let covered = picked.iter().find_map(|r| r.top()); @@ -278,7 +279,17 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { // its outline, shared edges from every shape along them). let in_union = picked.iter().any(|r| r.sources.contains(&i)); let mut rest = if in_union { - po::boolean(&sh.path, sh.rule, &union, FillRule::NonZero, po::BoolOp::Difference) + // What is left of it: its faces not merged or deleted, united. They share their + // edges with the merged faces exactly, where subtracting the merged shape left + // slivers of the outline along them (#569). + let kept: Vec<&po::Region> = map + .regions + .iter() + .enumerate() + .filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k)) + .map(|(_, (r, ..))| r) + .collect(); + if kept.is_empty() { PathData::default() } else { faces.merge(&kept) } } else { b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) }; diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 04af2e0b..e077ff02 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -704,3 +704,67 @@ fn image_trace_object_remembers_its_settings() { let id = st.selection.in_paint_order(&st.doc)[0]; assert!(st.doc.node(id).unwrap().trace.is_none()); } + +/// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals +/// overlapping) merges every region the drags touch into one shape whose outline is only the +/// merged area's border: no edge between merged regions stays in, as a stray line or a spur. +#[test] +fn shape_builder_merges_rosette_rings_cleanly() { + let mut s = session(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + let c = [400.0, 320.0]; + let mut petals = vec![]; + for k in 0..8 { + let id = + s.execute("shape.ellipse", &json!({"x": c[0] - 60.0, "y": c[1] - 172.0, "width": 120, "height": 324})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.rotate", &json!({"angle": 45.0 * f64::from(k), "origin": c})).unwrap(); + petals.push(id); + } + s.execute("select.set", &json!({"ids": petals})).unwrap(); + // Drags around the ring of petals, from its inside out; the outer ones miss the petals. + let mut merged = None; + for radius in [110.0, 150.0, 190.0, 230.0] { + let pts: Vec<[f64; 2]> = (0..=120) + .map(|i| { + let a = std::f64::consts::TAU * f64::from(i) / 120.0; + [c[0] + radius * a.cos(), c[1] + radius * a.sin()] + }) + .collect(); + if let Ok(r) = s.execute("shapeBuilder.merge", &json!({"points": pts})) { + merged = r["merged"].as_u64().map(NodeId); + } + } + let (ring, rule) = outline(&node(&s, merged.expect("the drags merged"))); + let inside = |p: kurbo::Point| kurbo::Shape::winding(&ring.to_bezpath(), p) != 0; + for k in 0..8 { + let a = std::f64::consts::FRAC_PI_4 * f64::from(k) - std::f64::consts::FRAC_PI_2; + let tip = kurbo::Point::new(c[0] + 165.0 * a.cos(), c[1] + 165.0 * a.sin()); + assert!(inside(tip), "petal {k}'s tip at {tip:?} is in the merged shape"); + } + // What is left of the petals stays out of the merged area (a piece left over it is a stray + // outline inside it). + let merged = merged.unwrap(); + for id in s.doc().unwrap().selection.objects.clone().into_iter().filter(|id| *id != merged) { + let (p, r) = outline(&node(&s, id)); + let over = vectorcraft_pathops::boolean(&p, r, &ring, rule, vectorcraft_pathops::BoolOp::Intersect); + let a = vectorcraft_pathops::area(&over, FillRule::NonZero); + assert!(a < 1.0, "{id:?} overlaps the merged shape by {a} pt²"); + } + // No outline is a sliver or carries a spur (2 × area / length: its mean width). + for id in s.doc().unwrap().selection.objects.clone() { + let (p, r) = outline(&node(&s, id)); + for sp in &p.subpaths { + let one = PathData::new(vec![sp.clone()]); + let width = 2.0 * vectorcraft_pathops::area(&one, r) / one.length(); + assert!(width > 1.0, "{id:?} has an outline {width:.3} pt wide on average, {:.1} pt long", one.length()); + } + } + // The ring is one outline around the centre (the regions the drags missed aside): the + // petals' tips are no outlines of their own beside it, apart by a hairline. + let big = ring.subpaths.iter().filter(|sp| vectorcraft_pathops::area(&PathData::new(vec![(*sp).clone()]), rule) > 100.0).count(); + assert_eq!(big, 2, "{:?}", ring.subpaths.iter().map(|sp| vectorcraft_pathops::area(&PathData::new(vec![sp.clone()]), rule)).collect::>()); + // An inner edge would be a second outline over the merged area: normalising would drop it. + let clean = vectorcraft_pathops::normalize(&ring, rule); + assert!((ring.length() - clean.length()).abs() < 1.0, "{} pt of outline, {} without inner edges", ring.length(), clean.length()); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 97f00aef..0e67a96d 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -19,7 +19,7 @@ pub use edit::{ split_into_grid, }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; -pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; +pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder}; /// Errors from fallible operations. diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index ea36e569..fa11c145 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -7,7 +7,7 @@ use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _}; use linesweeper::topology::ContourIdx; use vectorcraft_geom::{FillRule, PathData}; -use crate::boolean::{Arrangement, Multi, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all}; +use crate::boolean::{Arrangement, Multi, Seg, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez, segs_to_subpath, unite_all}; /// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); /// results carry the key of the object whose paint they keep. @@ -264,6 +264,63 @@ pub fn merge_regions(regions: &[&Region]) -> PathData { unite_all(&v) } +/// Merges faces of the planar arrangement of a set of shapes (as [`regions`] gives them) into one +/// path, from the arrangement itself, made once for every merge of the set: the faces' contours +/// share their edges exactly, so the edges between merged faces vanish. Uniting the faces' tidied +/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where +/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut) +/// fall back to [`merge_regions`]. +pub struct FaceMerger { + /// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes. + arr: Option, + shapes: usize, +} + +impl FaceMerger { + pub fn new(shapes: &[Shape]) -> Self { + let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2); + Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len() } + } + + /// The union of `faces`. + pub fn merge(&self, faces: &[&Region]) -> PathData { + self.exact(faces).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces)) + } + + fn exact(&self, faces: &[&Region]) -> Option { + let arr = self.arr.as_ref()?; + // Each face by its coverage and a point inside it. + let mut picks: Vec<(Vec, Point)> = Vec::with_capacity(faces.len()); + for r in faces { + let mut mask = vec![false; self.shapes]; + for &s in &r.sources { + *mask.get_mut(s)? = true; + } + picks.push((mask, crate::planar::interior_point(&r.path)?)); + } + let mut raw = BezPath::new(); + let mut done: Vec<&[bool]> = vec![]; + for (mask, _) in &picks { + if done.contains(&mask.as_slice()) { + continue; + } + done.push(mask); + let c = arr.contours(|k| k == mask.as_slice()); + for group in c.grouped() { + let mut face = BezPath::new(); + for i in group { + face.extend(c[i].path.iter()); + } + if picks.iter().any(|(m, p)| m == mask && face.winding(*p) != 0) { + raw.extend(face.iter()); + } + } + } + let merged = normalize_bez(&raw, FillRule::NonZero).ok()?; + Some(all_contours_to_path(&merged, &arr.tidy)) + } +} + /// Pathfinder Outline: split every shape's boundary at junctions of the arrangement. fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec { let junctions = arr.junctions();