Pen and anchors: Alt edits one handle, cut and convert selected anchors from the Control bar

Discord feedback: Alt with the Pen didn't move a direction handle on its own, and there was no
way to break a path at an anchor and pull the ends apart.

- Pen: Alt held over a handle end or an anchor of a selected path (the one being drawn too) hands
  the gesture to the Anchor Point tool, with the convert cursor: dragging a handle moves it alone,
  clicking a smooth anchor makes it a corner, dragging an anchor pulls out symmetric handles.
  Drawing goes on afterwards. Handles hidden by a partial anchor selection aren't grabbed.
- path.cutAtAnchors cuts at the direct-selected anchors in one undo step: a closed path opens
  there, an open one becomes one path per piece, and one anchor of each cut stays selected so a
  drag pulls the path apart. It answers {ids} and refuses (no undo step) when only open ends are
  selected. path.convertAnchors checks `to`, skips stale anchor refs instead of indexing past
  them, and keeps a smooth anchor's handles when converting to smooth.
- SubPath::cut_at and SubPath::smooth_anchor (geom) now back path.split, path.cutAtAnchors,
  path.convertAnchor and path.convertAnchors, replacing three copies of that logic.
- Control bar and Properties panel: "Convert:" corner/smooth and "Anchors:" remove, connect
  (Join), cut buttons when anchors are selected; Cut Path on the task bar and the context menu.
  New original icon dc-anchor-smooth.svg (ASSETS.md row).
- ja, zh-hans, zh-hant and es strings (cs for the context menu item), es @msg rows; docs/mcp.md
  section; ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 17:16:12 +07:00
1 parent d6f8cc879f
commit 253249717b
24 files changed
+584 -164

No files matched your search

+1
View File
@@ -90,6 +90,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-alignto-key.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-alignto-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-anchor.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-anchor-smooth.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+3 -3
View File
@@ -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-08 (after 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); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after 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); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -120,7 +120,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles; drawing then goes on.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
@@ -131,7 +131,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles: the dragged edges align with the other objects; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446).
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected.
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish also translates status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 20 C5 11 8 7 12 7 S19 11 21 20"/><path d="M5 7 H19"/><circle cx="5" cy="7" r="1.4" fill="currentColor"/><circle cx="19" cy="7" r="1.4" fill="currentColor"/><rect x="10" y="5" width="4" height="4" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 419 B

+7 -45
View File
@@ -670,34 +670,13 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Convert Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
let n = sp.anchors.len();
if ai >= n {
return Err(EngineError::Other("no such anchor".into()));
}
let prev = if ai > 0 || sp.closed { Some(sp.anchors[(ai + n - 1) % n].p) } else { None };
let next = if ai + 1 < n || sp.closed { Some(sp.anchors[(ai + 1) % n].p) } else { None };
let a = &mut sp.anchors[ai];
if !smooth {
a.retract();
return Ok(());
}
let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
match target {
Some(t) if t.distance(a.p) > 1e-9 => {
a.h_out = t;
a.h_in = a.p - (t - a.p);
a.kind = AnchorKind::Smooth;
}
_ if !smooth => a.retract(),
Some(t) if t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
Some(_) => a.retract(),
None => {
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l > 1e-9 {
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
}
sp.smooth_anchor(ai);
}
}
Ok(())
@@ -767,30 +746,13 @@ fn split(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(EngineError::Other("no such segment".into())),
};
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
if sp.closed {
sp.anchors.rotate_left(k);
let mut last = sp.anchors[0];
last.h_out = last.p;
sp.anchors[0].h_in = sp.anchors[0].p;
last.kind = AnchorKind::Corner;
sp.anchors[0].kind = AnchorKind::Corner;
sp.anchors.push(last);
sp.closed = false;
path.subpaths[si] = sp;
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
sel.set([id]);
return Ok(vec![id]);
}
let n = sp.anchors.len();
if k == 0 || k + 1 >= n {
return Err(EngineError::Other("cannot split an open path at its end point".into()));
}
let mut left = SubPath::new(sp.anchors[..=k].to_vec(), false);
let mut right = SubPath::new(sp.anchors[k..].to_vec(), false);
let lp = left.anchors[k].p;
left.anchors[k].h_out = lp;
left.anchors[k].kind = AnchorKind::Corner;
right.anchors[0].h_in = lp;
right.anchors[0].kind = AnchorKind::Corner;
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
if path.subpaths.len() == 1 {
path.subpaths[0] = left;
let node = sibling_with(d, id, PathData::single(right))?;
+67 -66
View File
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
use super::*;
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Anchor Points",
[],
None,
"{to: \"corner\"|\"smooth\"} (Control bar convert buttons)",
"{to: \"corner\"|\"smooth\"} convert the direct-selected anchors (all anchors of fully selected paths) as one undo step: corner retracts both handles, smooth pulls handles in line with the neighbours (smooth anchors keep theirs)",
has_selection,
convert_anchors
),
@@ -104,7 +104,15 @@ pub fn specs() -> Vec<CommandSpec> {
reshape
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
cmd!("path.cutAtAnchors", "Cut Path at Selected Anchor Points", [], None, "{}", has_selection, cut_at_anchors),
cmd!(
"path.cutAtAnchors",
"Cut Path at Selected Anchor Points",
[],
None,
"{} cut the paths at their direct-selected anchors (one undo step): a closed path opens there, an open one becomes one path per piece; one of each cut's two coincident anchors stays selected → {ids}",
has_anchors,
cut_at_anchors
),
]
}
@@ -386,41 +394,24 @@ fn average(s: &mut Session, p: &Value) -> Result<Value> {
}
fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchors";
let smooth = match str_param(p, "to") {
Some("smooth") => true,
Some("corner") => false,
_ => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")),
};
let t = anchor_targets(s)?;
let smooth = str_param(p, "to") == Some("smooth");
if t.is_empty() {
return Err(bad(C, "select paths or anchor points"));
}
s.edit("Convert Anchor Points", |d, _| {
for (id, v) in &t {
let path = path_mut(d, *id)?;
for &(si, ai) in v {
let sp = &path.subpaths[si];
let n = sp.anchors.len();
let prev = if ai > 0 {
Some(sp.anchors[ai - 1].p)
} else if sp.closed {
Some(sp.anchors[n - 1].p)
} else {
None
};
let next = if ai + 1 < n {
Some(sp.anchors[ai + 1].p)
} else if sp.closed {
Some(sp.anchors[0].p)
} else {
None
};
let a: &mut Anchor = &mut path.subpaths[si].anchors[ai];
let Some(sp) = path.subpaths.get_mut(si) else { continue };
if smooth {
// Handles parallel to prev→next, a third of the neighbour distances.
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l > 1e-9 {
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
}
} else {
sp.smooth_anchor(ai);
} else if let Some(a) = sp.anchors.get_mut(ai) {
a.retract();
}
}
@@ -519,40 +510,50 @@ fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
}
fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let sel = s.doc()?.selection.anchors.clone();
s.edit("Cut Path", |d, _| {
for (id, set) in &sel {
let path = path_mut(d, *id)?;
let mut out = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
let cuts: Vec<usize> = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect();
if cuts.is_empty() {
out.push(sp.clone());
continue;
}
let mut anchors = sp.anchors.clone();
if sp.closed {
anchors.rotate_left(cuts[0]);
anchors.push(anchors[0]);
}
let base = if sp.closed { cuts[0] } else { 0 };
let n = sp.anchors.len();
let rel: Vec<usize> = cuts.iter().map(|c| (c + n - base) % n).collect();
let mut cur = vec![];
for (i, a) in anchors.iter().enumerate() {
cur.push(*a);
if rel.contains(&(i % n)) && cur.len() > 1 {
out.push(SubPath::new(std::mem::take(&mut cur), false));
cur.push(*a);
}
}
if cur.len() > 1 {
out.push(SubPath::new(cur, false));
}
}
path.subpaths = out;
const C: &str = "path.cutAtAnchors";
let st = s.doc()?;
// Each selected path's subpaths cut at its selected anchors, those that cut anything.
let mut cuts: Vec<(NodeId, Vec<SubPath>, Vec<bool>)> = vec![];
for (id, set) in &st.selection.anchors {
let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let (mut subs, mut starts) = (vec![], vec![]);
for (si, sp) in pd.subpaths.iter().enumerate() {
let at: BTreeSet<usize> = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect();
let pieces = sp.cut_at(&at);
// The pieces that start at a cut: every piece of an opened closed subpath, all but the
// first of an open one.
starts.extend(pieces.iter().enumerate().map(|(k, piece)| if sp.closed { !piece.closed } else { k > 0 }));
subs.extend(pieces);
}
Ok(())
if starts.contains(&true) {
cuts.push((*id, subs, starts));
}
}
if cuts.is_empty() {
return Err(bad(C, "direct-select anchor points to cut at (not the end points of open paths)"));
}
let ids = s.edit("Cut Path", |d, sel| {
let (mut all, mut picked) = (vec![], vec![]);
for (id, subs, starts) in cuts {
// A path of one subpath becomes one path per piece (the first keeps the id); the
// pieces of a compound shape stay subpaths of it.
if d.node(id).and_then(|n| n.path_data()).is_some_and(|pd| pd.subpaths.len() == 1) {
let ids = super::draw2::replace_with_pieces(d, id, subs.into_iter().map(PathData::single).collect())?;
picked.extend(ids.iter().zip(&starts).filter(|(_, s)| **s).map(|(id, _)| (*id, (0, 0))));
all.extend(ids);
} else {
path_mut(d, id)?.subpaths = subs;
picked.extend(starts.iter().enumerate().filter(|(_, s)| **s).map(|(si, _)| (id, (si, 0))));
all.push(id);
}
}
// Each cut leaves one of its two coincident anchors selected, so a drag pulls the ends apart.
sel.clear();
for (id, a) in picked {
sel.add(id);
sel.anchors.entry(id).or_default().insert(a);
}
Ok(all)
})?;
ok()
Ok(super::draw2::ids_json(&ids))
}
+125
View File
@@ -534,3 +534,128 @@ fn remove_anchor_points_keeps_the_paths_closed() {
let spec = find_command("path.removeAnchors").unwrap();
assert_eq!(spec.menu, &["Object", "Path"][..]);
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn convert_anchors_turns_the_selected_anchors_corner_or_smooth() {
// The Control bar's convert buttons: one undo step each, other anchors left alone.
let mut s = session();
let made = s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 200}, {"x": 200, "y": 100}, {"x": 300, "y": 200}]})).unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
let undo = undo_steps(&s);
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
let a = path(&s, id).subpaths[0].anchors.clone();
assert_eq!(undo_steps(&s), undo + 1);
assert_eq!(a[1].kind, vectorcraft_geom::AnchorKind::Smooth);
let r = 100.0 * 2f64.sqrt() / 3.0;
assert!(near(a[1].h_in, Point::new(200.0 - r, 100.0)) && near(a[1].h_out, Point::new(200.0 + r, 100.0)), "{:?}", a[1]);
assert!(!a[0].has_out() && !a[2].has_in(), "the neighbours keep theirs");
// Smooth again keeps the handles; corner retracts them.
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 1, "which": "out", "x": 260, "y": 100})).unwrap();
let h = path(&s, id).subpaths[0].anchors[1];
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors[1], h);
s.execute("path.convertAnchors", &json!({"to": "corner"})).unwrap();
let a = path(&s, id).subpaths[0].anchors[1];
assert!(!a.has_in() && !a.has_out(), "{a:?}");
// Anchors that are gone (a stale selection) are skipped; a bad `to` is refused.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 99], [7, 0]]})).unwrap();
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
let undo = undo_steps(&s);
assert!(s.execute("path.convertAnchors", &json!({"to": "round"})).is_err());
assert_eq!(undo_steps(&s), undo);
}
#[test]
fn cut_at_anchors_splits_paths_and_leaves_one_end_selected() {
// Cut Path at Selected Anchor Points: an open path becomes one path per piece, and one anchor
// of each cut stays selected, so dragging it pulls the path apart there.
let mut s = session();
let anchors = json!([{"x": 100, "y": 100}, {"x": 200, "y": 100, "in": [170, 80], "out": [230, 120]}, {"x": 300, "y": 100}, {"x": 400, "y": 100}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, 2]]})).unwrap();
let undo = undo_steps(&s);
let made = s.execute("path.cutAtAnchors", &json!({})).unwrap();
let ids: Vec<NodeId> = made["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(undo_steps(&s), undo + 1);
assert_eq!((ids.len(), ids[0]), (3, id));
let pieces: Vec<Vec<Point>> = ids.iter().map(|i| path(&s, *i).subpaths[0].anchors.iter().map(|a| a.p).collect()).collect();
let p = |x: f64| Point::new(x, 100.0);
assert_eq!(pieces, vec![vec![p(100.0), p(200.0)], vec![p(200.0), p(300.0)], vec![p(300.0), p(400.0)]]);
// The curve keeps its shape: each end keeps the handle on its own side only.
let (l, r) = (path(&s, ids[0]).subpaths[0].anchors[1], path(&s, ids[1]).subpaths[0].anchors[0]);
assert!(l.h_in == Point::new(170.0, 80.0) && !l.has_out() && r.h_out == Point::new(230.0, 120.0) && !r.has_in(), "{l:?} {r:?}");
let sel = s.doc().unwrap().selection.clone();
assert_eq!(sel.objects, vec![ids[1], ids[2]]);
assert!(sel.anchors.values().all(|a| a.iter().eq([&(0, 0)])), "{sel:?}");
s.execute("path.moveAnchors", &json!({"dx": 0, "dy": 50})).unwrap();
assert_eq!(path(&s, ids[0]).subpaths[0].anchors[1].p, p(200.0));
assert_eq!(path(&s, ids[1]).subpaths[0].anchors[0].p, Point::new(200.0, 150.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paths(&s).len(), 1);
// A closed path opens at the cut, its ends on top of each other.
let r = rect(&mut s, 500.0, 100.0, 100.0, 100.0);
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 2]]})).unwrap();
s.execute("path.cutAtAnchors", &json!({})).unwrap();
let sp = &path(&s, r).subpaths[0];
assert!(!sp.closed && sp.anchors.len() == 5 && sp.anchors[0].p == sp.anchors[4].p, "{sp:?}");
assert_eq!(s.doc().unwrap().selection.anchors[&r].iter().collect::<Vec<_>>(), [&(0, 0)]);
// Only the end points of an open path selected: nothing to cut.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 3]]})).unwrap();
let undo = undo_steps(&s);
assert!(s.execute("path.cutAtAnchors", &json!({})).is_err());
assert_eq!(undo_steps(&s), undo);
}
#[test]
fn pen_with_alt_moves_one_handle_and_converts_anchors() {
// Discord feedback: Alt held with the Pen moves a direction handle on its own (and works as
// the Anchor Point tool on a selected path's anchors), while a path is being drawn too.
let alt = Mods { alt: true, ..Mods::default() };
let v = view();
let mut s = session();
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
let gesture = |s: &mut Session, pts: &[(f64, f64)], m: Mods| {
let undo = undo_steps(s);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
undo_steps(s) - undo
};
// Alt-drag the out handle: it moves alone, the in handle stays.
assert_eq!(gesture(&mut s, &[(250.0, 100.0), (260.0, 130.0), (275.0, 165.0)], alt), 1);
assert!(!s.in_interaction());
let a = path(&s, id).subpaths[0].anchors[1];
assert_eq!((a.h_in, a.h_out), (Point::new(150.0, 100.0), Point::new(275.0, 165.0)));
assert_eq!(paths(&s).len(), 1, "no new path or anchor");
// Alt-click the anchor: a corner. Alt-drag it: new symmetric handles.
assert_eq!(gesture(&mut s, &[(200.0, 100.0)], alt), 1);
let a = path(&s, id).subpaths[0].anchors[1];
assert!(!a.has_in() && !a.has_out(), "{a:?}");
gesture(&mut s, &[(200.0, 100.0), (220.0, 100.0), (240.0, 110.0)], alt);
let a = path(&s, id).subpaths[0].anchors[1];
assert_eq!((a.h_in, a.h_out), (Point::new(160.0, 90.0), Point::new(240.0, 110.0)));
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
// Drawing: place a smooth point, then Alt-drag its outgoing handle; the next click goes on
// drawing the same path.
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, &[(100.0, 400.0)], Mods::default());
gesture(&mut s, &[(200.0, 400.0), (250.0, 400.0)], Mods::default());
let new = *s.doc().unwrap().selection.objects.first().unwrap();
gesture(&mut s, &[(250.0, 400.0), (230.0, 350.0)], alt);
let a = path(&s, new).subpaths[0].anchors[1];
assert_eq!((a.h_in, a.h_out), (Point::new(150.0, 400.0), Point::new(230.0, 350.0)));
gesture(&mut s, &[(300.0, 450.0)], Mods::default());
assert_eq!(path(&s, new).subpaths[0].anchors.len(), 3);
assert_eq!(paths(&s).len(), 2);
}
+73
View File
@@ -1,5 +1,7 @@
//! The anchor-based editable path model.
use std::collections::BTreeSet;
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape};
use serde::{Deserialize, Serialize};
@@ -268,6 +270,77 @@ impl SubPath {
self.anchors.insert(seg + 1, mid);
seg + 1
}
/// Make anchor `i` smooth: handles in line with its neighbours (its one neighbour at an open
/// end), each a third of the way to that side's neighbour. A smooth anchor with both handles
/// keeps them. False when there is no such anchor or no direction (the neighbours coincide).
pub fn smooth_anchor(&mut self, i: usize) -> bool {
let n = self.anchors.len();
let prev = if i > 0 {
Some(i - 1)
} else if self.closed {
n.checked_sub(1)
} else {
None
};
let next = if i + 1 < n {
Some(i + 1)
} else if self.closed {
Some(0)
} else {
None
};
let at = |j: Option<usize>| j.and_then(|j| self.anchors.get(j)).map(|a| a.p);
let (prev, next) = (at(prev), at(next));
let Some(a) = self.anchors.get_mut(i) else { return false };
if a.kind == AnchorKind::Smooth && a.has_in() && a.has_out() {
return true;
}
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l <= 1e-9 {
return false;
}
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
true
}
/// Cut at the anchors `at`: each becomes the end of one open piece and the start of the next,
/// its handle off each piece retracted (the shape doesn't change). A closed subpath opens at
/// its first cut, so one cut leaves one piece whose ends coincide. The ends of an open subpath
/// and indices past the end don't cut; with no cut left, the subpath comes back as it is.
pub fn cut_at(&self, at: &BTreeSet<usize>) -> Vec<SubPath> {
let n = self.anchors.len();
let mut cuts = at.iter().copied().filter(|&i| i < n && (self.closed || (i > 0 && i + 1 < n))).peekable();
let Some(&first) = cuts.peek().filter(|_| n >= 2) else { return vec![self.clone()] };
// A closed subpath's anchors from the first cut round to it again; an open one's as they are.
let (run, cuts): (Vec<Anchor>, BTreeSet<usize>) = if self.closed {
(self.anchors.iter().cycle().skip(first).take(n + 1).copied().collect(), cuts.map(|c| (c + n - first) % n).chain([n]).collect())
} else {
(self.anchors.clone(), cuts.collect())
};
let mut pieces = vec![];
let mut cur: Vec<Anchor> = vec![];
for (i, a) in run.into_iter().enumerate() {
if !cuts.contains(&i) {
cur.push(a);
continue;
}
if !cur.is_empty() {
cur.push(Anchor { h_out: a.p, kind: AnchorKind::Corner, ..a });
pieces.push(SubPath::new(std::mem::take(&mut cur), false));
}
if i < n {
cur.push(Anchor { h_in: a.p, kind: AnchorKind::Corner, ..a });
}
}
if cur.len() > 1 {
pieces.push(SubPath::new(cur, false));
}
pieces
}
/// Signed area via the shoelace formula on the Bézier path (positive = clockwise in y-down).
pub fn area(&self) -> f64 {
let mut bp = BezPath::new();
+29
View File
@@ -179,6 +179,35 @@ proptest! {
prop_assert!((q.length() - p.length()).abs() < 1e-3 * p.length().max(1.0));
}
/// Cutting at anchors keeps every segment as it was, in order (a closed subpath's from its
/// first cut), in open pieces of two or more anchors. A smoothed corner gets handles on a line
/// through it.
#[test]
fn cut_at_keeps_the_segments(p in arb_path_data(), cuts in proptest::collection::btree_set(0usize..12, 0..4), pick in 0usize..100) {
let sp = &p.subpaths[pick % p.subpaths.len()];
let n = sp.anchors.len();
let pieces = sp.cut_at(&cuts);
let segs = |s: &vectorcraft_geom::SubPath| (0..s.segment_count()).map(|i| s.segment(i)).collect::<Vec<_>>();
let mut want = segs(sp);
if pieces.len() > 1 || pieces.first().is_some_and(|q| q.closed != sp.closed) {
prop_assert!(pieces.iter().all(|q| !q.closed && q.anchors.len() >= 2));
if sp.closed {
want.rotate_left(cuts.iter().copied().find(|&c| c < n).unwrap_or(0));
}
} else {
prop_assert_eq!(&pieces, &vec![sp.clone()]);
}
prop_assert_eq!(pieces.iter().flat_map(segs).collect::<Vec<_>>(), want);
let mut s = sp.clone();
let i = pick % n.max(1);
let corner = s.anchors.get(i).is_some_and(|a| a.kind == vectorcraft_geom::AnchorKind::Corner);
if s.smooth_anchor(i) && corner {
let a = s.anchors[i];
let (u, v) = (a.h_in - a.p, a.h_out - a.p);
prop_assert!(u.cross(v).abs() <= 1e-6 * (1.0 + u.hypot() * v.hypot()) && u.dot(v) <= 0.0, "{a:?}");
}
}
/// Polygon area via the shoelace formula equals the path's signed area magnitude and
/// SubPath::area.
#[test]
+1
View File
@@ -15,6 +15,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub(crate) use anchor::hit_handle;
pub use curvature::CurvatureTool;
pub use family::FamilyTool;
pub use gesture::GestureTool;
+5 -3
View File
@@ -49,11 +49,13 @@ impl AnchorTool {
}
}
/// A direction handle of a selected path under `p`: (id, subpath, anchor, is_out).
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
/// A direction handle of a selected path under `p`, among those shown (a path with anchors
/// direct-selected shows theirs only): (id, subpath, anchor, is_out).
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, ai, a) in pd.anchors() {
let shown = cx.selection.partial(*id);
for (si, ai, a) in pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))) {
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
+2 -1
View File
@@ -400,7 +400,8 @@ pub enum Cursor {
PenDelete,
PenClose,
PenContinue,
/// Over the last anchor of the path being drawn: a click retracts its outgoing handle.
/// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with
/// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture).
PenConvert,
Text,
Hand,
+114 -28
View File
@@ -5,6 +5,9 @@
//! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°.
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
//! Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as
//! the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes it a
//! corner and dragging an anchor pulls out new symmetric handles.
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
//! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
@@ -15,6 +18,7 @@ use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use crate::draw2::{AnchorTool, hit_handle};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
@@ -30,6 +34,8 @@ pub struct PenTool {
/// The last anchor of the path being drawn, while a click retracts its outgoing handle or a
/// drag pulls a new one out: (path, subpath, anchor, its position).
handle: Option<(NodeId, usize, usize, Point)>,
/// The Anchor Point tool, while an Alt gesture on a selected path's handle or anchor lasts.
convert: Option<AnchorTool>,
}
/// The open path the pen is extending: the single selected open path.
@@ -57,6 +63,21 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
Some((si, ai))
}
/// Alt held over a handle end or an anchor of a selected path: the Anchor Point tool's gesture.
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
let tol = cx.tol(4.0);
m.alt && (hit_handle(cx, p, tol).is_some() || crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some())
}
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
}
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
Action::Preview(
@@ -70,7 +91,7 @@ impl Tool for PenTool {
"pen"
}
fn busy(&self) -> bool {
self.drag.is_some() || self.handle.is_some()
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
@@ -103,10 +124,16 @@ impl Tool for PenTool {
self.handle = Some((id, si, ai, last));
return vec![Action::Begin("Convert Anchor Point".into()), set_out_handle(id, si, ai, last)];
}
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
self.drag = Some((p, false));
self.in_h = p;
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
if let Some(acts) = spine_click(cx, p, tol) {
return acts;
}
@@ -128,6 +155,9 @@ impl Tool for PenTool {
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
}
PointerKind::Drag => {
if let Some(t) = &mut self.convert {
return t.pointer(cx, ev);
}
if let Some((id, si, ai, a)) = self.handle {
let mut h = ev.pos;
if ev.mods.shift {
@@ -173,6 +203,9 @@ impl Tool for PenTool {
)]
}
PointerKind::Up => {
if let Some(mut t) = self.convert.take() {
return t.pointer(cx, ev);
}
if self.handle.take().is_some() {
return vec![Action::Commit];
}
@@ -185,22 +218,26 @@ impl Tool for PenTool {
PointerKind::DoubleClick => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Enter | ToolKey::Escape => {
self.drawing = false;
self.drag = None;
self.handle = None;
vec![]
// An Alt gesture under way ends as it stands.
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.drawing = false;
vec![]
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(t) = &self.convert {
return t.overlays(cx);
}
if !cx.pen_rubber_band || !self.drawing || self.drag.is_some() || self.handle.is_some() {
return vec![];
}
@@ -216,27 +253,32 @@ impl Tool for PenTool {
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0);
let active = active_path(cx);
if self.drawing
&& let Some((id, first, last, _)) = active
{
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if p.distance(first) <= tol && !one {
return Cursor::PenClose;
}
if p.distance(last) <= tol {
return Cursor::PenConvert;
}
}
if alt_converts(cx, p, m) {
return Cursor::PenConvert;
}
if !self.drawing {
match spine_click(cx, p, cx.tol(5.0)).as_deref() {
match spine_click(cx, p, tol).as_deref() {
Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete,
Some([_]) => return Cursor::PenAdd,
_ => {}
}
}
if let Some((id, first, last, _)) = active_path(cx) {
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if self.drawing && p.distance(first) <= cx.tol(5.0) && !one {
return Cursor::PenClose;
}
if self.drawing && p.distance(last) <= cx.tol(5.0) {
return Cursor::PenConvert;
}
if !self.drawing && (p.distance(last) <= cx.tol(5.0) || p.distance(first) <= cx.tol(5.0)) {
if active.is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
return Cursor::PenContinue;
}
}
if !self.drawing {
match auto_add_delete(cx, p, m, cx.tol(5.0)) {
match auto_add_delete(cx, p, m, tol) {
Some(Action::Exec(c, _)) if c == "path.removeAnchor" => return Cursor::PenDelete,
Some(_) => return Cursor::PenAdd,
None => {}
@@ -246,6 +288,19 @@ impl Tool for PenTool {
}
}
impl PenTool {
/// Hand an Alt press on a selected path's handle or anchor to the Anchor Point tool.
fn alt_convert(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
if !alt_converts(cx, ev.pos, ev.mods) {
return None;
}
let mut t = AnchorTool::new("anchorPoint");
let acts = t.pointer(cx, ev);
self.convert = Some(t);
Some(acts)
}
}
/// Auto Add/Delete: what a click at `p` does to a selected path. On one of its anchors it deletes
/// it; else, where one of its segments passes within `tol`, it adds an anchor. None when the
/// preference turns it off, Shift is held, or no selected path is there.
@@ -253,15 +308,8 @@ fn auto_add_delete(cx: &ToolContext, p: Point, m: Mods, tol: f64) -> Option<Acti
if !cx.auto_add_delete || m.shift {
return None;
}
let paths = || {
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
};
let anchor = crate::draw2::anchor_in(cx, paths(), p, tol).map(crate::draw2::remove_anchor);
anchor.or_else(|| crate::draw2::segment_in(cx, paths(), p, tol).map(crate::draw2::insert_anchor))
let anchor = crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).map(crate::draw2::remove_anchor);
anchor.or_else(|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).map(crate::draw2::insert_anchor))
}
/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it
@@ -501,4 +549,42 @@ mod tests {
let a = click(&mut t, &cx, 325.0, 351.0, shift);
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
}
#[test]
fn alt_over_a_shown_handle_or_an_anchor_converts() {
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(300.0, 200.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 100.0), Point::new(250.0, 100.0));
let (d, id, mut s) = drawing(sp);
let p = paint();
let alt = Mods { alt: true, ..Mods::default() };
let t = PenTool::default();
let cx0 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen);
assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen);
// A drag of the handle moves it alone, as one gesture.
let mut t = PenTool::default();
assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Down, 250.0, 101.0).with_mods(alt)), vec![]);
assert!(t.busy());
let a = t.pointer(&cx0, &PointerEvent::new(PointerKind::Drag, 260.0, 140.0).with_mods(alt));
assert_eq!(
a,
vec![
Action::Begin("Reshape".into()),
Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": 0, "anchor": 1, "which": "out", "x": 260.0, "y": 140.0, "independent": true})
)
]
);
// Alt let go mid-drag: the gesture goes on to its end.
assert_eq!(t.pointer(&cx0, &PointerEvent::new(PointerKind::Up, 260.0, 140.0)), vec![Action::Commit]);
assert!(!t.busy());
// With other anchors direct-selected the handles of this one are hidden, and not grabbed.
s.anchors.insert(id, std::collections::BTreeSet::from([(0, 0)]));
let cx1 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen);
}
}
+5 -2
View File
@@ -1612,8 +1612,11 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
if !st.selection.anchors.is_empty() {
// Direct-selected anchors take the place of a path's Offset Path and Simplify.
let anchors = !st.selection.anchors.is_empty();
if anchors {
items.push((tl!("Remove Anchor Points"), "dc-pen-delete", "path.removeAnchors"));
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors"));
}
if n > 1 {
items.push((tl!("Group"), "group", "object.group"));
@@ -1623,7 +1626,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
items.push((tl!("Isolate"), "square-dashed", "object.isolate"));
} else if is_text {
items.push((tl!("Create Outlines"), "type", "type.createOutlines"));
} else {
} else if !anchors {
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath"));
items.push((tl!("Simplify"), "spline", "object.path.simplify"));
}
+43 -2
View File
@@ -105,6 +105,47 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// An anchor button: icon, tooltip, command and the convert command's `to`.
type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>);
/// What the Control bar and the Properties panel offer for direct-selected anchors: "Convert:"
/// corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a row of its
/// own: inline in the Control bar, one under the other in a panel.
pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let mut run = None;
let groups: [(&str, &[AnchorButton]); 2] = [
(
tl!("Convert:"),
&[
("dc-anchor", tl!("Convert Selected Anchor Points to Corner"), "path.convertAnchors", Some("corner")),
("dc-anchor-smooth", tl!("Convert Selected Anchor Points to Smooth"), "path.convertAnchors", Some("smooth")),
],
),
(
tl!("Anchors:"),
&[
("dc-pen-delete", tl!("Remove Anchor Points"), "path.removeAnchors", None),
("dc-join", tl!("Connect Selected End Points"), "path.join", None),
("scissors", tl!("Cut Path at Selected Anchor Points"), "path.cutAtAnchors", None),
],
),
];
for (label, buttons) in groups {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).size(12.0).color(t.text));
for &(icon, tip, id, to) in buttons {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to }))));
}
}
});
}
if let Some((id, p)) = run {
crate::menus::invoke(app, id, p);
}
}
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -139,8 +180,8 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
if anchor_mode && widgets::icon_button(ui, "dc-pen-delete", "Remove Anchor Points", false, 24.0).clicked() {
crate::menus::invoke(app, "path.removeAnchors", json!({}));
if anchor_mode {
anchor_buttons(app, ui);
}
ui.add_space(6.0);
// An image or an Image Trace object shows its own controls in place of Fill and Stroke.
+1
View File
@@ -11,6 +11,7 @@
# names.
About VectorCraft O aplikaci VectorCraft
Align to Path Zarovnat k cestě
Cut Path at Selected Anchor Points Rozdělit cestu ve vybraných kotevních bodech
Join Our Discord Připojit se k našemu Discordu
Settings… Nastavení…
Language Jazyk
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -124,6 +124,7 @@
Anchor Points Puntos de ancla
Anchor Points, Handle, and Bounding Box Display Visualización de puntos de ancla, manejadores y cuadro delimitador
Anchor/Path Labels Etiquetas de ancla/trazado
Anchors: Anclas:
Angle Ángulo
Angle: Ángulo:
Angles Ángulos
@@ -427,6 +428,7 @@
Compression: Compresión:
Condition identifier: Identificador de condición:
Conflict {n} of {count} Conflicto {n} de {count}
Connect Selected End Points Conectar los puntos finales seleccionados
Consolidate All Windows Consolidar todas las ventanas
Constrain Angle Restringir ángulo
Constrain Width and Height Proportions Restringir proporciones de anchura y altura
@@ -444,6 +446,8 @@
Convert Color Convertir color
Convert Document Color Mode Convertir modo de color del documento
Convert Process to Global Convertir cuatricromía en global
Convert Selected Anchor Points to Corner Convertir los puntos de ancla seleccionados en esquina
Convert Selected Anchor Points to Smooth Convertir los puntos de ancla seleccionados en suaves
Convert text to outlines Convertir texto en contornos
Convert To Area Type Convertir en texto de área
Convert to Artboards Convertir en mesas de trabajo
@@ -454,6 +458,7 @@
Convert to RGB Convertir a RGB
Convert to Shape Convertir en forma
Convert to sRGB Convertir a sRGB
Convert: Convertir:
Copied the CSS of {rules} object(s) Se copió el CSS de {rules} objeto(s)
Copies: Copias:
Copy Copiar
@@ -476,6 +481,7 @@
Corners Esquinas
Chamfer Chaflán
Corners… Esquinas…
Cut Path Cortar trazado
Descender Descendente
Flip Voltear
Inverted Round Redondeada invertida
@@ -3047,6 +3053,7 @@ axis Both Ambos
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
@msg `to` must be "corner" or "smooth" `to` debe ser "corner" o "smooth"
@msg BMP files are RGB or grayscale: colorModel rgb or gray los archivos BMP son RGB o en escala de grises: colorModel rgb o gray
@msg Blend colors: select at least three objects with filled colours Fusionar colores: selecciona al menos tres objetos con relleno de color
@msg Combine Slices: the slices have no bounds Combinar sectores: los sectores no tienen límites
@@ -3064,6 +3071,7 @@ axis Both Ambos
@msg Create from Guides: the guides make more than {MAX_PIECES} slices Crear a partir de guías: las guías forman más de {MAX_PIECES} sectores
@msg Create from Selection: the selection has no area Crear a partir de la selección: la selección no tiene área
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Recuperación de datos no tiene dónde guardar copias: configura Preferencias → Manejo de archivos → Recuperación de datos → Carpeta (recoveryFolder)
@msg direct-select anchor points to cut at (not the end points of open paths) selecciona con Selección directa los puntos de ancla en los que cortar (no los puntos finales de trazados abiertos)
@msg Divide Objects Below: select a path Dividir objetos inferiores: selecciona un trazado
@msg Divide Objects Below: select exactly one path Dividir objetos inferiores: selecciona exactamente un trazado
@msg Downloaded {_1} file(s) Se descargaron {_1} archivo(s)
@@ -3098,6 +3106,7 @@ axis Both Ambos
@msg Release Guides: no guides Soltar guías: no hay guías
@msg Release: no layer to put the rectangle in Soltar: no hay ninguna capa donde poner el rectángulo
@msg Replace Spine: select a blend and a path Reemplazar eje: selecciona una fusión y un trazado
@msg select paths or anchor points selecciona trazados o puntos de ancla
@msg SVG parse error: {0} error al analizar el SVG: {0}
@msg Save for Web: the image is empty Guardar para web: la imagen está vacía
@msg Save writes {_1}, not {_2}: use document.export for other formats Guardar escribe {_1}, no {_2}: usa document.export para otros formatos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -10,12 +10,18 @@
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
About VectorCraft VectorCraft について
All Scripts すべての文字
Anchors: アンカー:
Any Kind すべての種類
Ascender アセンダ
Baseline ベースライン
Bottom-to-Bottom Leading 欧文ベースライン基準の行送り
Center 中央
Character Alignment 文字揃え
Connect Selected End Points 選択した端点を連結
Convert Selected Anchor Points to Corner 選択したアンカーポイントをコーナーポイントに変換
Convert Selected Anchor Points to Smooth 選択したアンカーポイントをスムーズポイントに変換
Convert: 変換:
Cut Path パスをカット
Decorative 装飾
Descender ディセンダ
Em Box Bottom/Left 仮想ボディの下 / 左
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -126,6 +126,7 @@
Anchor Points 锚点
Anchor Points, Handle, and Bounding Box Display 锚点、手柄和定界框显示
Anchor/Path Labels 锚点/路径标签
Anchors: 锚点:
Angle 角度
Angle: 角度:
Angles 角度
@@ -426,6 +427,7 @@
Compression: 压缩:
Condition identifier: 条件识别码:
Conflict {n} of {count} 冲突 {n}/{count}
Connect Selected End Points 连接所选端点
Consolidate All Windows 合并所有窗口
Constrain Angle 约束角度
Constrain Width and Height Proportions 约束宽高比例
@@ -443,6 +445,8 @@
Convert Color 转换颜色
Convert Document Color Mode 转换文档颜色模式
Convert Process to Global 将印刷色转换为全局色
Convert Selected Anchor Points to Corner 将所选锚点转换为尖角
Convert Selected Anchor Points to Smooth 将所选锚点转换为平滑
Convert text to outlines 将文字转换为轮廓
Convert To Area Type 转换为区域文字
Convert to Artboards 转换为画板
@@ -453,6 +457,7 @@
Convert to RGB 转换为 RGB
Convert to Shape 转换为形状
Convert to sRGB 转换为 sRGB
Convert: 转换:
Copied the CSS of {rules} object(s) 已复制 {rules} 个对象的 CSS
Copies: 份数:
Copy 复制
@@ -473,6 +478,7 @@
Corner Radius: 圆角半径:
Corner: 转角:
Corners 转角
Cut Path 切割路径
Descender 下缘
Flip 翻转
Round 圆角
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -135,6 +135,7 @@
Anchor Points 錨點
Anchor Points, Handle, and Bounding Box Display 錨點、控制把手與邊框顯示
Anchor/Path Labels 錨點/路徑標籤
Anchors: 錨點:
Angle 角度
Angle: 角度:
Angles 角度
@@ -440,6 +441,7 @@
Compression: 壓縮:
Condition identifier: 條件識別碼:
Conflict {n} of {count} 衝突 {n}/{count}
Connect Selected End Points 連接所選端點
Consolidate All Windows 合併所有視窗
Constrain Angle 強制角度
Constrain Width and Height Proportions 強制寬度與高度比例
@@ -457,6 +459,8 @@
Convert Color 轉換顏色
Convert Document Color Mode 轉換文件色彩模式
Convert Process to Global 將印刷色轉換為整體色
Convert Selected Anchor Points to Corner 將所選錨點轉換為尖角
Convert Selected Anchor Points to Smooth 將所選錨點轉換為平滑
Convert text to outlines 將文字轉換為外框
Convert To Area Type 轉換為區域文字
Convert to Artboards 轉換為工作區域
@@ -467,6 +471,7 @@
Convert to RGB 轉換為 RGB
Convert to Shape 轉換為形狀
Convert to sRGB 轉換為 sRGB
Convert: 轉換:
Copied the CSS of {rules} object(s) 已複製 {rules} 個物件的 CSS
Copies: 份數:
Copy 複製
@@ -487,6 +492,7 @@
Corner Radius: 圓角半徑:
Corner: 轉角:
Corners 轉角
Cut Path 切斷路徑
Descender 下緣
Flip 翻轉
Round 圓角
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -41,6 +41,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("dc-alignto-key", include_bytes!("../../../assets/icons/dc-alignto-key.svg")),
("dc-alignto-selection", include_bytes!("../../../assets/icons/dc-alignto-selection.svg")),
("dc-anchor", include_bytes!("../../../assets/icons/dc-anchor.svg")),
("dc-anchor-smooth", include_bytes!("../../../assets/icons/dc-anchor-smooth.svg")),
("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")),
("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")),
("dc-arrow-down", include_bytes!("../../../assets/icons/dc-arrow-down.svg")),
+1 -1
View File
@@ -2461,7 +2461,7 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
v.extend([c("Join", "path.join"), c("Average…", "path.average")]);
}
if !st.selection.anchors.is_empty() {
v.push(c("Remove Anchor Points", "path.removeAnchors"));
v.extend([c("Remove Anchor Points", "path.removeAnchors"), c("Cut Path at Selected Anchor Points", "path.cutAtAnchors")]);
}
if several {
v.push(c("Make Clipping Mask", "object.clippingMask.make"));
+5 -4
View File
@@ -85,6 +85,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
}
if anchor_mode {
divider(ui);
section_header(ui, tl!("Anchor Point"));
crate::chrome::anchor_buttons(app, ui);
}
divider(ui);
section_header(ui, tl!("Quick Actions"));
let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. }));
@@ -104,10 +109,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if multi_color(app, ui.ctx()) {
actions.push((tl!("Recolor"), "ui.recolorDialog"));
}
// One column: the label is wider than half of the narrowest dock.
if anchor_mode && widgets::flat_button(ui, "Remove Anchor Points", ui.available_width()).clicked() {
crate::menus::invoke(app, "path.removeAnchors", json!({}));
}
if is_image {
crate::panels::image_trace::trace_button(app, ui, ui.available_width());
actions.push((tl!("Crop Image"), "object.cropImage"));
+55 -9
View File
@@ -1,5 +1,6 @@
//! Object › Path › Remove Anchor Points: the menu, the Control bar, the contextual task bar, the
//! Properties panel and the context menu run it on direct-selected anchors.
//! Properties panel and the context menu run it on direct-selected anchors. The Control bar's and
//! the Properties panel's other anchor buttons: convert to corner or smooth, connect, cut.
use egui::{Event, PointerButton, Pos2, Rect, Shape, vec2};
use serde_json::json;
@@ -116,12 +117,10 @@ fn the_path_menu_and_bars_run_it_for_direct_selected_anchors() {
assert!(menus::enabled(&app, "path.removeAnchors"));
assert!(labels(&menus::context_items(&app)).contains(&LABEL));
// The Control bar's icon follows its "Anchor Point" label.
// The Control bar's icon follows its "Anchors:" label.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let bar = control_frame(&mut app, &ctx, vec![]);
let label = bar.iter().find(|(t, _)| t == "Anchor Point").map(|(_, r)| *r).expect("anchor label");
click_control(&mut app, &ctx, Pos2::new(label.max.x + ctx.global_style().spacing.item_spacing.x + 12.0, label.center().y));
click_button(&mut app, &ctx, "Anchors:", 0);
assert_eq!(anchor_count(&app, id), 3, "the Control bar button removes the anchor");
let st = app.session.active().unwrap();
assert_eq!(st.history.undo.last().unwrap().label, LABEL);
@@ -131,10 +130,12 @@ fn the_path_menu_and_bars_run_it_for_direct_selected_anchors() {
assert_eq!(anchor_count(&app, id), 4);
app.run("select.anchors", json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
// 230 pt is the dock's minimum width. The label has to sit inside that row.
// 230 pt is the dock's minimum width. Each row of anchor buttons has to fit in it.
let props = properties_frame(&mut app, 230.0);
let row = props.iter().find(|(t, _)| t == LABEL).expect("properties");
assert!(row.1.min.x >= -0.5 && row.1.max.x <= 230.5, "properties clips the label: {:?}", row.1);
for label in ["Convert:", "Anchors:"] {
let row = props.iter().find(|(t, _)| t == label).map(|(_, r)| *r).expect("properties");
assert!(row.min.x >= -0.5 && button(&props, &ctx, label, 2).x + 12.0 <= 230.5, "properties clips the {label} row: {row:?}");
}
// The task bar's area settles on the second frame.
let ctx = egui::Context::default();
@@ -157,5 +158,50 @@ fn the_control_bar_and_properties_hide_it_without_anchors() {
let mut app = app();
rect(&mut app);
let props = crate::tests_labels::painted_text(&mut app, crate::panels::properties::show);
assert!(!props.contains("Remove Anchor Points"), "{props}");
assert!(!props.contains("Anchors:"), "{props}");
}
/// The centre of the `i`-th 24 pt icon button after `label`.
fn button(texts: &[(String, Rect)], ctx: &egui::Context, label: &str, i: usize) -> Pos2 {
let r = texts.iter().find(|(t, _)| t == label).map(|(_, r)| *r).unwrap_or_else(|| panic!("no `{label}`"));
let gap = ctx.global_style().spacing.item_spacing.x;
Pos2::new(r.max.x + gap + 12.0 + i as f32 * (24.0 + gap), r.center().y)
}
/// Click the Control bar's `i`-th icon button after `label`.
fn click_button(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str, i: usize) {
let at = button(&control_frame(app, ctx, vec![]), ctx, label, i);
click_control(app, ctx, at);
}
#[test]
fn the_control_bar_converts_connects_and_cuts_selected_anchors() {
let mut app = app();
let id = rect(&mut app);
app.select_tool("directSelection");
app.run("select.anchors", json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let anchor = |app: &VectorcraftApp, id: NodeId, ai: usize| {
app.session.active().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai]
};
let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone();
// Convert: smooth, then corner again.
click_button(&mut app, &ctx, "Convert:", 1);
assert!(anchor(&app, id, 1).has_in() && anchor(&app, id, 1).has_out(), "smooth");
assert_eq!(last_undo(&app), "Convert Anchor Points");
click_button(&mut app, &ctx, "Convert:", 0);
assert!(!anchor(&app, id, 1).has_in() && !anchor(&app, id, 1).has_out(), "corner");
// Cut: the rectangle opens at the anchor, one of its two ends selected.
click_button(&mut app, &ctx, "Anchors:", 2);
assert_eq!(last_undo(&app), "Cut Path");
let st = app.session.active().unwrap();
let sp = &st.doc.node(id).unwrap().path_data().unwrap().subpaths[0];
assert!(!sp.closed && sp.anchors.len() == 5, "{sp:?}");
assert_eq!(st.selection.anchors[&id].iter().collect::<Vec<_>>(), [&(0, 0)]);
// Connect: closes it again.
click_button(&mut app, &ctx, "Anchors:", 1);
assert_eq!(last_undo(&app), "Join");
assert!(app.session.active().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].closed);
assert_eq!(anchor_count(&app, id), 4, "the coincident ends merge");
}
+18
View File
@@ -1029,6 +1029,24 @@ their facing handles are refitted so one cubic follows the two old segments; two
{"name":"run_command","arguments":{"command":"path.removeAnchors","params":{}}}
```
## Converting and cutting anchor points
The Control bar's and the Properties panel's anchor buttons, one undo step each, act on the direct-selected anchors
(all anchors of a path selected as a whole): `path.convertAnchors {to: "corner"|"smooth"}` retracts both handles,
or pulls handles out in line with the neighbouring anchors (a smooth anchor keeps its own); `path.cutAtAnchors {}`
(Cut Path at Selected Anchor Points) cuts there and answers `{ids}`: a closed path opens at the cut, its two ends
on top of each other, and an open path becomes one path per piece. Each cut leaves one of its two anchors selected,
so `path.moveAnchors {dx, dy}` (or a Direct Selection drag) pulls the path apart there; `path.join {}` (Connect
Selected End Points) joins the ends again. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt
held over a selected path's handle or anchor works as the Anchor Point tool.
```json
{"name":"run_command","arguments":{"command":"select.anchors","params":{"id":12,"anchors":[[0,2]]}}}
{"name":"run_command","arguments":{"command":"path.cutAtAnchors","params":{}}}
{"name":"run_command","arguments":{"command":"path.moveAnchors","params":{"dx":0,"dy":40}}}
```
## Registration and trim marks
Every document has the built-in `[Registration]` swatch (listed after None by `swatch.list`): a colour that prints on