Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c89f7301 | ||
|
|
20c1a1621e | ||
|
|
d57134a308 | ||
|
|
9449c92136 |
No files matched your search
@@ -201,10 +201,10 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
|
||||
**Where we are (2026-10-09):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
**Where we are (2026-10-10):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
|
||||
+1
-1
@@ -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-10 (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 windoLine truncated
|
||||
_Last updated: 2026-10-10 (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 windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
@@ -1083,8 +1083,32 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
|
||||
}
|
||||
|
||||
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let corners = corners_param(p)?;
|
||||
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
|
||||
let items = match p.get("items").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(Value::Array(a)) => Some(
|
||||
a.iter()
|
||||
.map(|it| {
|
||||
let id = it
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.map(NodeId)
|
||||
.ok_or_else(|| bad("object.setLiveShape", format!("each of `items` is {{id, corners?}} with an object id, not {it}")))?;
|
||||
Ok((id, corners_param(it)?))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
),
|
||||
Some(v) => return Err(bad("object.setLiveShape", format!("`items` must be an array of {{id, corners?}}, not {v}"))),
|
||||
};
|
||||
let ids = match &items {
|
||||
Some(items) => items.iter().map(|(id, _)| *id).collect(),
|
||||
None => targets(s, p)?,
|
||||
};
|
||||
let shared = corners_param(p)?;
|
||||
let corners_of = |id: NodeId| match &items {
|
||||
Some(items) => items.iter().find(|(i, _)| *i == id).and_then(|(_, c)| c.clone()),
|
||||
None => shared.clone(),
|
||||
};
|
||||
let kind = p
|
||||
.get("kind")
|
||||
.filter(|v| !v.is_null())
|
||||
@@ -1154,6 +1178,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let partial = sel.anchors.get_mut(id);
|
||||
// The corners to edit, the Direct-Selected ones and the anchor layout before the edit.
|
||||
let Some(c) = LiveCorners::new(path, live.as_ref()) else { continue };
|
||||
let corners = corners_of(*id);
|
||||
let picked = match &corners {
|
||||
Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => {
|
||||
let n = c.base.anchor_count();
|
||||
|
||||
@@ -58,6 +58,25 @@ fn a_whole_rectangle_rounds_all_corners_and_given_corners_alone() {
|
||||
assert_eq!(corners(&s, id).1[3], CornerKind::Chamfer);
|
||||
}
|
||||
|
||||
/// #938: `items` rounds several objects in one step, each its own corners (or all of them).
|
||||
#[test]
|
||||
fn items_round_several_objects_each_its_own_corners() {
|
||||
let (mut s, a) = session();
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap());
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 6}));
|
||||
assert_eq!(corners(&s, a).0, [0.0, 0.0, 6.0, 0.0]);
|
||||
assert_eq!(corners(&s, b).0, [6.0; 4]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one step");
|
||||
for p in [
|
||||
json!({"items": 3, "radius": 1}),
|
||||
json!({"items": [{"corners": [1]}], "radius": 1}),
|
||||
json!({"items": [{"id": a.0, "corners": [9]}], "radius": 1}),
|
||||
] {
|
||||
assert!(s.execute("object.setLiveShape", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_corners_and_kinds_are_errors() {
|
||||
let (mut s, id) = session();
|
||||
|
||||
+172
-69
@@ -1,8 +1,8 @@
|
||||
//! Live Corners: the widgets inside the corners of a selected path. The Selection tool shows them
|
||||
//! on a live rectangle or polygon, Direct Selection on any path (a star, a pen path) at each corner
|
||||
//! anchor: one without handles between two straight sides. Dragging one rounds (or sharpens) the
|
||||
//! corners whose widgets show together: every corner when the whole path is selected, those with a
|
||||
//! selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
|
||||
//! Live Corners: the widgets inside the corners of the selected paths. The Selection tool shows
|
||||
//! them on live rectangles and polygons, Direct Selection on any path (a star, a pen path) at each
|
||||
//! corner anchor: one without handles between two straight sides. Dragging one rounds (or
|
||||
//! sharpens) the corners whose widgets show together, on every selected path (#938): every corner
|
||||
//! of a path selected whole, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
|
||||
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
|
||||
//! Selection and Direct Selection tools share this, and the canvas draws the widgets from the same
|
||||
//! geometry.
|
||||
@@ -38,10 +38,10 @@ struct Widget {
|
||||
at: Point,
|
||||
}
|
||||
|
||||
/// The corner widgets of a selection that is exactly one editable path with corners.
|
||||
/// The corner widgets of one selected path.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CornerWidgets {
|
||||
pub id: NodeId,
|
||||
struct PathWidgets {
|
||||
id: NodeId,
|
||||
/// Maps the corners' space into the document (a live rectangle's own; else none).
|
||||
xf: Affine,
|
||||
/// The widgets that show (and which a drag edits), in anchor order.
|
||||
@@ -50,11 +50,10 @@ pub struct CornerWidgets {
|
||||
all: bool,
|
||||
}
|
||||
|
||||
impl CornerWidgets {
|
||||
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
|
||||
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
|
||||
let [id] = selection.objects[..] else { return None };
|
||||
impl PathWidgets {
|
||||
/// The widgets of path `id` at `zoom` (screen px per document point), if it has any: any path
|
||||
/// with Direct Selection (`any_path`), live rectangles and polygons with Selection.
|
||||
fn of(doc: &Document, selection: &Selection, id: NodeId, zoom: f64, any_path: bool) -> Option<Self> {
|
||||
if !doc.is_editable(id) {
|
||||
return None;
|
||||
}
|
||||
@@ -86,13 +85,36 @@ impl CornerWidgets {
|
||||
(!widgets.is_empty()).then_some(Self { id, xf: lc.xf, widgets, all })
|
||||
}
|
||||
|
||||
/// The corners whose widgets show: anchor indices of the path's uncut outline.
|
||||
fn corners(&self) -> Vec<usize> {
|
||||
self.widgets.iter().map(|w| w.corner.index).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner widgets of the selected paths that have some.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CornerWidgets {
|
||||
paths: Vec<PathWidgets>,
|
||||
}
|
||||
|
||||
impl CornerWidgets {
|
||||
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
|
||||
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
|
||||
let paths: Vec<_> = selection.objects.iter().filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect();
|
||||
(!paths.is_empty()).then_some(Self { paths })
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
|
||||
/// corners wider than `max` degrees (in the document) hide; none if all do.
|
||||
pub fn within_angle(mut self, max: f64) -> Option<Self> {
|
||||
let n = self.widgets.len();
|
||||
self.widgets.retain(|w| w.angle <= max + 1e-9);
|
||||
self.all &= self.widgets.len() == n;
|
||||
(!self.widgets.is_empty()).then_some(self)
|
||||
for p in &mut self.paths {
|
||||
let n = p.widgets.len();
|
||||
p.widgets.retain(|w| w.angle <= max + 1e-9);
|
||||
p.all &= p.widgets.len() == n;
|
||||
}
|
||||
self.paths.retain(|p| !p.widgets.is_empty());
|
||||
(!self.paths.is_empty()).then_some(self)
|
||||
}
|
||||
|
||||
/// The widgets on show: [`Self::of`] with View → Show Corner Widget `on`, less those wider
|
||||
@@ -111,30 +133,61 @@ impl CornerWidgets {
|
||||
|
||||
/// The centres of the widgets that show.
|
||||
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
|
||||
self.widgets.iter().map(|w| w.at)
|
||||
self.visible_on().map(|(_, at)| at)
|
||||
}
|
||||
|
||||
/// The corners whose widgets show: anchor indices of the path's uncut outline.
|
||||
/// The centres of the widgets that show, each with the object it belongs to.
|
||||
pub fn visible_on(&self) -> impl Iterator<Item = (NodeId, Point)> + '_ {
|
||||
self.paths.iter().flat_map(|p| p.widgets.iter().map(move |w| (p.id, w.at)))
|
||||
}
|
||||
|
||||
/// The corners whose widgets show, path after path: anchor indices of each path's uncut
|
||||
/// outline.
|
||||
pub fn corners(&self) -> Vec<usize> {
|
||||
self.widgets.iter().map(|w| w.corner.index).collect()
|
||||
self.paths.iter().flat_map(PathWidgets::corners).collect()
|
||||
}
|
||||
|
||||
/// The widget nearest to `p` within `tol` (document units).
|
||||
fn hit(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
let d = |k: &usize| self.widgets.get(*k).map_or(f64::INFINITY, |w| w.at.distance(p));
|
||||
(0..self.widgets.len()).filter(|k| d(k) <= tol).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
/// The widget nearest to `p` within `tol` (document units): its path's and its own index.
|
||||
fn hit(&self, p: Point, tol: f64) -> Option<(usize, usize)> {
|
||||
let d = |&(i, k): &(usize, usize)| self.widget(i, k).map_or(f64::INFINITY, |w| w.at.distance(p));
|
||||
self.paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, path)| (0..path.widgets.len()).map(move |k| (i, k)))
|
||||
.filter(|ik| d(ik) <= tol)
|
||||
.min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
}
|
||||
|
||||
/// `object.setLiveShape` params setting `key` on the shown corners (every corner unless some
|
||||
/// are hidden, which `corners` then leaves out).
|
||||
fn widget(&self, path: usize, k: usize) -> Option<&Widget> {
|
||||
self.paths.get(path).and_then(|p| p.widgets.get(k))
|
||||
}
|
||||
|
||||
/// `object.setLiveShape` params setting `key` on the shown corners (every corner of a path
|
||||
/// unless some are hidden, which `corners` then leaves out): one path by its `id`, several as
|
||||
/// `items`.
|
||||
fn command(&self, key: &str, value: Value) -> Value {
|
||||
let mut p = Map::new();
|
||||
p.insert("id".into(), json!(self.id.0));
|
||||
p.insert(key.into(), value);
|
||||
if !self.all {
|
||||
p.insert("corners".into(), json!(self.corners()));
|
||||
let corners = |p: &PathWidgets| (!p.all).then(|| p.corners());
|
||||
let mut out = Map::new();
|
||||
match self.paths.as_slice() {
|
||||
[p] => {
|
||||
out.insert("id".into(), json!(p.id.0));
|
||||
if let Some(c) = corners(p) {
|
||||
out.insert("corners".into(), json!(c));
|
||||
}
|
||||
}
|
||||
paths => {
|
||||
let items: Vec<Value> = paths
|
||||
.iter()
|
||||
.map(|p| match corners(p) {
|
||||
Some(c) => json!({ "id": p.id.0, "corners": c }),
|
||||
None => json!({ "id": p.id.0 }),
|
||||
})
|
||||
.collect();
|
||||
out.insert("items".into(), Value::Array(items));
|
||||
}
|
||||
}
|
||||
Value::Object(p)
|
||||
out.insert(key.into(), value);
|
||||
Value::Object(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,11 +204,22 @@ pub fn over_widget(cx: &ToolContext, p: Point, any_path: bool) -> bool {
|
||||
CornerWidgets::for_tool(cx, any_path).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
|
||||
}
|
||||
|
||||
/// A double-click on a corner widget opens the Corners dialog for the shown corners.
|
||||
/// A double-click on a corner widget opens the Corners dialog for the shown corners of its path.
|
||||
pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action> {
|
||||
let w = CornerWidgets::for_tool(cx, any_path)?;
|
||||
w.hit(p, cx.tol(5.0))?;
|
||||
Some(Action::Dialog(DIALOG.into(), json!({ "id": w.id.0, "corners": w.corners() })))
|
||||
let (i, _) = w.hit(p, cx.tol(5.0))?;
|
||||
let path = w.paths.get(i)?;
|
||||
Some(Action::Dialog(DIALOG.into(), json!({ "id": path.id.0, "corners": path.corners() })))
|
||||
}
|
||||
|
||||
/// A path's uncut outline with its corners' radii and kinds, and the corners' space (to outline
|
||||
/// the corners at their largest).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Outline {
|
||||
base: PathData,
|
||||
radii: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
xf: Affine,
|
||||
}
|
||||
|
||||
/// Dragging a corner widget: the radius follows the pointer along the corner's bisector. An
|
||||
@@ -163,13 +227,10 @@ pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CornerDrag {
|
||||
widgets: CornerWidgets,
|
||||
/// The dragged widget (index into the shown ones).
|
||||
widget: usize,
|
||||
/// The path's uncut outline with its corners' radii and kinds (to outline the corners at
|
||||
/// their largest).
|
||||
base: PathData,
|
||||
radii: Vec<f64>,
|
||||
kinds: Vec<CornerKind>,
|
||||
/// The dragged widget: its path's and its own index among the shown ones.
|
||||
widget: (usize, usize),
|
||||
/// Each shown path's outline, in the order of [`CornerWidgets`]' paths.
|
||||
outlines: Vec<Outline>,
|
||||
start: Point,
|
||||
began: bool,
|
||||
alt: bool,
|
||||
@@ -185,20 +246,29 @@ impl CornerDrag {
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent, any_path: bool) -> Option<Self> {
|
||||
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
|
||||
let widget = widgets.hit(p, cx.tol(5.0))?;
|
||||
let radius = widgets.widgets.get(widget)?.radius;
|
||||
let lc = cx.doc.node(widgets.id).and_then(LiveCorners::of)?;
|
||||
let n = lc.base.anchor_count();
|
||||
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
|
||||
let base = lc.base.into_owned();
|
||||
Some(Self { widgets, widget, base, radii, kinds, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
|
||||
let radius = widgets.widget(widget.0, widget.1)?.radius;
|
||||
let outlines = widgets
|
||||
.paths
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let lc = cx.doc.node(w.id).and_then(LiveCorners::of)?;
|
||||
let n = lc.base.anchor_count();
|
||||
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
|
||||
Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf })
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p })
|
||||
}
|
||||
|
||||
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
|
||||
/// corner's bisector (in the corners' own units), from square to fully round; and whether the
|
||||
/// travel reached fully round.
|
||||
fn radius_at(&self, p: Point) -> (f64, bool) {
|
||||
let Some(w) = self.widgets.widgets.get(self.widget) else { return (self.radius, self.at_limit) };
|
||||
let inv = self.widgets.xf.inverse();
|
||||
let (path, k) = self.widget;
|
||||
let (Some(w), Some(xf)) = (self.widgets.widget(path, k), self.widgets.paths.get(path).map(|p| p.xf)) else {
|
||||
return (self.radius, self.at_limit);
|
||||
};
|
||||
let inv = xf.inverse();
|
||||
let max = w.corner.max_radius();
|
||||
let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start);
|
||||
(r.min(max).max(0.0), max > 0.0 && r >= max)
|
||||
@@ -206,25 +276,27 @@ impl CornerDrag {
|
||||
|
||||
/// The cuts of the edited corners that are at their largest radius, in document coordinates.
|
||||
fn corners_at_limit(&self) -> BezPath {
|
||||
let shown = &self.widgets.widgets;
|
||||
let mut radii = self.radii.clone();
|
||||
for w in shown {
|
||||
if let Some(r) = radii.get_mut(w.corner.index) {
|
||||
*r = self.radius;
|
||||
}
|
||||
}
|
||||
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
|
||||
let (path, sources) = cut_corners(&self.base, &radii, &self.kinds);
|
||||
let mut out = BezPath::new();
|
||||
for (sp, from) in path.transformed(self.widgets.xf).subpaths.iter().zip(&sources) {
|
||||
// A corner's cut is the segment between its two anchors (the last one, for a closed
|
||||
// subpath's first corner).
|
||||
for i in 0..sp.segment_count() {
|
||||
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
|
||||
if a == b && a.is_some_and(full) {
|
||||
let c = sp.segment(i);
|
||||
out.move_to(c.p0);
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
for (shown, o) in self.widgets.paths.iter().zip(&self.outlines) {
|
||||
let shown = &shown.widgets;
|
||||
let mut radii = o.radii.clone();
|
||||
for w in shown {
|
||||
if let Some(r) = radii.get_mut(w.corner.index) {
|
||||
*r = self.radius;
|
||||
}
|
||||
}
|
||||
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9);
|
||||
let (path, sources) = cut_corners(&o.base, &radii, &o.kinds);
|
||||
for (sp, from) in path.transformed(o.xf).subpaths.iter().zip(&sources) {
|
||||
// A corner's cut is the segment between its two anchors (the last one, for a
|
||||
// closed subpath's first corner).
|
||||
for i in 0..sp.segment_count() {
|
||||
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
|
||||
if a == b && a.is_some_and(full) {
|
||||
let c = sp.segment(i);
|
||||
out.move_to(c.p0);
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +321,7 @@ impl CornerDrag {
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began {
|
||||
vec![Action::Commit]
|
||||
} else if let Some(w) = self.widgets.widgets.get(self.widget).filter(|_| self.alt) {
|
||||
} else if let Some(w) = self.widgets.widget(self.widget.0, self.widget.1).filter(|_| self.alt) {
|
||||
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
|
||||
} else {
|
||||
vec![]
|
||||
@@ -359,6 +431,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #938: with two rectangles selected, both show their widgets, and a drag on either rounds
|
||||
/// the corners of both in one step; with Direct Selection, each its shown corners.
|
||||
#[test]
|
||||
fn widgets_of_several_selected_paths_drag_together() {
|
||||
let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((300.0, 100.0)) };
|
||||
let b = d.alloc_id();
|
||||
let mut n = Node::path(b, live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
}
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 1, n).unwrap();
|
||||
let mut s = selected(a);
|
||||
s.add(b);
|
||||
assert_eq!(pts(&CornerWidgets::of(&d, &s, 1.0, false).unwrap()).len(), 8);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 390.0, 191.0));
|
||||
let preview = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 375.0, 176.0));
|
||||
assert!((radius(&preview[1]) - 15.0).abs() < 1e-9);
|
||||
let Action::Preview(_, v) = &preview[1] else { panic!() };
|
||||
assert_eq!(v["items"], json!([{"id": a.0}, {"id": b.0}]));
|
||||
// Direct Selection with one anchor of the first: its corner, and the whole second.
|
||||
s.anchors.insert(a, [(0, 2)].into());
|
||||
let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap();
|
||||
assert_eq!(w.corners(), [2, 0, 1, 2, 3]);
|
||||
assert_eq!(w.command("radius", json!(5.0)), json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 5.0}));
|
||||
}
|
||||
|
||||
/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also
|
||||
/// with a radius past half the shorter side; a drag past fully round stops every corner there
|
||||
/// and outlines them in red.
|
||||
|
||||
@@ -1701,8 +1701,8 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
app.session.prefs.hide_corner_widget_above,
|
||||
)
|
||||
{
|
||||
let color = c32(st.doc.layer_color(w.id));
|
||||
for sp in w.visible().map(|q| xf.to_screen(q)) {
|
||||
for (id, q) in w.visible_on() {
|
||||
let (sp, color) = (xf.to_screen(q), c32(st.doc.layer_color(id)));
|
||||
p.circle_filled(sp, 3.0, Color32::WHITE);
|
||||
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
|
||||
p.circle_filled(sp, 1.0, color);
|
||||
|
||||
+5
-4
@@ -1397,7 +1397,7 @@ size. The journal entry of a scaling command records the `strokes` and `corners`
|
||||
|
||||
## Live Corners
|
||||
|
||||
`object.setLiveShape {id?, ids?, radius?, kind?, corners?}` sets the corners of any path (one undo step): a live
|
||||
`object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live
|
||||
rectangle's or polygon's, a star's, a pen path's. A corner is an anchor without handles between two straight sides
|
||||
(not an open path's ends, not a smooth anchor, not one the sides run straight on through). `radius` (pt) and `kind`
|
||||
(`round`, `invertedRound` or `chamfer`) go to the `corners` given, else to the corners holding a Direct-Selected
|
||||
@@ -1413,9 +1413,10 @@ draws no larger than takes the cut halfway along the corner's shorter side (half
|
||||
same limit for its four corners), the corners stay circular through uneven scales (the radius scales by the mean scale,
|
||||
or keeps its size with Scale Corners off), and a rectangle from a file that kept an uneven scale in its transform
|
||||
(elliptical corners) gets circular ones in document units when its corners are next set. Dragging a corner widget
|
||||
rounds the corners whose widgets show (every corner, or the Direct-Selected ones), outlining in red those that reach
|
||||
their limit; the Selection tool shows the widgets of live rectangles and polygons, the Direct Selection tool those of
|
||||
any path. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
|
||||
rounds the corners whose widgets show (every corner, or the Direct-Selected ones) on every selected path at once,
|
||||
outlining in red those that reach their limit; the Selection tool shows the widgets of live rectangles and polygons,
|
||||
the Direct Selection tool those of any path. Several objects each with their own corners are one call:
|
||||
`items: [{id, corners?}]` in place of `ids` and `corners`. Alt-clicking one cycles their kind and double-clicking one opens Corners (`ui.corners {id?, corners?}`,
|
||||
dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
|
||||
|
||||
```json
|
||||
|
||||
Reference in new issue
Block a user