Path: Smart Remove Anchor Points is a menu command

path.smartRemoveAnchor stays. Object › Path, the Control bar, the contextual task bar, Properties, and the canvas context menu run it on direct-selected anchors. The Pen no longer uses Alt/Option for this, so that key stays free for Convert Anchor Point.
This commit is contained in:
Elias Witt
2026-10-08 03:25:44 +07:00
committed by bflatastic
parent a1f2f23b72
commit f5c3ecb47c
14 changed files with 607 additions and 3 deletions
+2 -2
View File
@@ -98,7 +98,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, smart remove when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…).
@@ -121,7 +121,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, 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.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Smart Remove Anchor Points (`path.smartRemoveAnchor`) removes direct-selected anchors and lengthens or shortens the facing handles so one cubic stays close to the old segments. Object › Path lists it. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu show it when anchors are selected. The command palette and MCP run it too. The Pen does not bind Alt-click to it, so that key stays free for Convert Anchor Point.
- 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), 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. 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.
- **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 Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. 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; 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.
+86
View File
@@ -45,6 +45,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
remove_anchor
),
cmd!(
"path.smartRemoveAnchor",
"Smart Remove Anchor Points",
["Object", "Path"],
None,
"{id?, subpath?, anchor?, fit?: bool (true)} remove an anchor and lengthen or shorten the facing handles so the curve stays close. Without id: every direct-selected anchor. fit false deletes and leaves the handles",
has_selection,
smart_remove_anchor
),
cmd!(
"path.convertAnchor",
"Convert Anchor Point",
@@ -652,6 +661,83 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "removedObject": removed }))
}
fn smart_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smartRemoveAnchor";
let fit = bool_or(p, "fit", true);
let explicit = p.get("anchor").is_some();
let jobs: Vec<(NodeId, usize, usize)> = if explicit {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
vec![(id, si, usize_req(p, "anchor", C)?)]
} else {
let anchors = s.doc()?.selection.anchors.clone();
let v: Vec<(NodeId, usize, usize)> = anchors.iter().flat_map(|(id, set)| set.iter().map(|&(si, ai)| (*id, si, ai))).collect();
if v.is_empty() {
return Err(bad(C, "direct-select an anchor point"));
}
v
};
let label = if fit { "Smart Remove Anchor Point" } else { "Delete Anchor Point" };
s.edit(label, |d, sel| {
let mut ids: Vec<NodeId> = jobs.iter().map(|(id, ..)| *id).collect();
ids.sort();
ids.dedup();
for id in ids {
let gone = {
let path = path_mut(d, id)?;
let mut subs: Vec<usize> = jobs.iter().filter(|(i, ..)| *i == id).map(|(_, si, _)| *si).collect();
subs.sort_unstable();
subs.dedup();
for si in subs {
let mut ais: Vec<usize> = jobs.iter().filter(|(i, s, _)| *i == id && *s == si).map(|(_, _, ai)| *ai).collect();
// Higher indexes go first, so each later index in this subpath stays valid.
ais.sort_unstable_by(|a, b| b.cmp(a));
ais.dedup();
for ai in ais {
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if ai >= sp.anchors.len() {
if explicit {
return Err(EngineError::Other("no such anchor".into()));
}
continue;
}
finish_smart_remove(sp, ai, fit);
}
}
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
path.is_empty()
};
if gone {
d.remove(id)?;
sel.remove(id);
} else {
sel.anchors.remove(&id);
}
}
Ok(())
})?;
ok()
}
/// Remove one anchor. `fit` lengthens or shortens the facing handles, then retracts a new open
/// end's free handle. `fit` false deletes the point and leaves every other handle where it is.
fn finish_smart_remove(sp: &mut SubPath, ai: usize, fit: bool) {
if !sp.remove_anchor(ai, fit) {
return;
}
if fit && !sp.closed && !sp.anchors.is_empty() {
let n = sp.anchors.len();
let p0 = sp.anchors[0].p;
sp.anchors[0].h_in = p0;
let last = n - 1;
let pl = sp.anchors[last].p;
sp.anchors[last].h_out = pl;
}
if sp.anchors.len() < 3 {
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
}
}
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+77
View File
@@ -1,5 +1,6 @@
//! Drawing / path-editing tools driven through the session (tools → actions → `path.*` commands).
use kurbo::ParamCurveNearest;
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Point};
@@ -375,6 +376,8 @@ fn draw2_commands_reject_bad_params() {
("path.freehand", json!({"points": [[1, 2]]})),
("path.curvature", json!({"points": []})),
("path.removeAnchor", json!({"id": id.0, "anchor": 99})),
("path.smartRemoveAnchor", json!({})),
("path.smartRemoveAnchor", json!({"id": id.0, "anchor": 99})),
("path.convertAnchor", json!({"id": 9999, "anchor": 0, "to": "corner"})),
("path.reshapeSegment", json!({"id": id.0, "segment": 42})),
("path.split", json!({"id": id.0})),
@@ -480,3 +483,77 @@ fn shift_drag_keeps_a_smooth_anchor_smooth_and_alt_breaks_it() {
}
}
}
#[test]
fn smart_remove_refits_a_split_curve_and_undo_restores_it() {
let mut s = session();
let made = s
.execute(
"path.create",
&json!({"anchors": [
{"x": 0, "y": 0, "out": [30, 80]},
{"x": 100, "y": 0, "in": [70, 80]}
]}),
)
.unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
let before = path(&s, id);
let inserted = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.4})).unwrap();
let ai = inserted["anchor"].as_u64().unwrap();
s.execute("path.smartRemoveAnchor", &json!({"id": id.0, "anchor": ai})).unwrap();
let after = path(&s, id);
assert_eq!(after.subpaths[0].anchors.len(), 2);
let (got, orig) = (&after.subpaths[0].anchors, &before.subpaths[0].anchors);
assert!(got[0].h_out.distance(orig[0].h_out) < 0.5, "{:?}", got[0].h_out);
assert!(got[1].h_in.distance(orig[1].h_in) < 0.5, "{:?}", got[1].h_in);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
}
#[test]
fn smart_remove_without_fit_leaves_handles() {
let mut s = session();
let made = s
.execute(
"path.create",
&json!({"anchors": [
{"x": 0, "y": 0, "out": [10, 30]},
{"x": 50, "y": 10},
{"x": 100, "y": 0, "in": [80, 25]}
]}),
)
.unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
s.execute("path.smartRemoveAnchor", &json!({"id": id.0, "anchor": 1, "fit": false})).unwrap();
let pd = path(&s, id);
let sp = &pd.subpaths[0];
assert_eq!(sp.anchors.len(), 2);
assert_eq!(sp.anchors[0].h_out, Point::new(10.0, 30.0));
assert_eq!(sp.anchors[1].h_in, Point::new(80.0, 25.0));
}
#[test]
fn smart_remove_uses_the_direct_selected_anchors() {
let mut s = session();
let made = s
.execute(
"path.create",
&json!({"anchors": [
{"x": 0, "y": 0},
{"x": 50, "y": 40},
{"x": 100, "y": 0}
]}),
)
.unwrap();
let id = NodeId(made["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
s.execute("path.smartRemoveAnchor", &json!({})).unwrap();
let sp = &path(&s, id).subpaths[0];
assert_eq!(sp.anchors.len(), 2);
let d = sp.segment(0).nearest(Point::new(50.0, 40.0), 1e-4).distance_sq.sqrt();
assert!(d < 40.0, "the refit stays closer to the removed corner than the chord ({d})");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
let spec = find_command("path.smartRemoveAnchor").unwrap();
assert_eq!(spec.menu, &["Object", "Path"][..]);
}
+227 -1
View File
@@ -1,6 +1,6 @@
//! The anchor-based editable path model.
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape};
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape, Vec2};
use serde::{Deserialize, Serialize};
use crate::EPS;
@@ -171,6 +171,147 @@ impl Anchor {
}
}
fn anchor_finite(a: &Anchor) -> bool {
[a.p, a.h_in, a.h_out].into_iter().all(|p| p.x.is_finite() && p.y.is_finite())
}
fn unit(v: Vec2) -> Vec2 {
let l = v.hypot();
if l > 1e-9 { v / l } else { Vec2::ZERO }
}
/// Direction of the handle that leaves `prev` toward `mid`. A missing handle takes the
/// tangent of that segment, then the chord.
fn ray_out(prev: &Anchor, mid: &Anchor) -> Vec2 {
let v = prev.h_out - prev.p;
if v.hypot() > 1e-9 {
return unit(v);
}
let toward_in = mid.h_in - prev.p;
if toward_in.hypot() > 1e-9 {
return unit(toward_in);
}
unit(mid.p - prev.p)
}
/// Direction of the handle that arrives at `next` from `mid`.
fn ray_in(next: &Anchor, mid: &Anchor) -> Vec2 {
let v = next.h_in - next.p;
if v.hypot() > 1e-9 {
return unit(v);
}
let toward_out = mid.h_out - next.p;
if toward_out.hypot() > 1e-9 {
return unit(toward_out);
}
unit(mid.p - next.p)
}
/// Points along the two segments that meet at `index`, in order, including both ends.
fn sample_pair(sp: &SubPath, index: usize) -> Vec<Point> {
let n = sp.anchors.len();
let left = sp.segment((index + n - 1) % n);
let right = sp.segment(index);
const N: usize = 8;
let mut pts = Vec::with_capacity(N * 2 + 1);
for i in 0..=N {
pts.push(left.eval(i as f64 / N as f64));
}
for i in 1..=N {
pts.push(right.eval(i as f64 / N as f64));
}
pts
}
fn shape_error(p0: Point, p3: Point, d0: Vec2, d1: Vec2, s0: f64, s1: f64, samples: &[Point]) -> f64 {
if !s0.is_finite() || !s1.is_finite() {
return f64::MAX;
}
let c = CubicBez::new(p0, p0 + d0 * s0, p3 + d1 * s1, p3);
samples.iter().map(|p| c.nearest(*p, 1e-3).distance_sq).sum()
}
/// True when `(s0, s1)` matches the samples more closely, or matches them the same and is shorter.
/// Equal shapes keep the retracted handles: a straight line needs no handles.
fn prefer(err: f64, s0: f64, s1: f64, best_e: f64, best: (f64, f64)) -> bool {
err < best_e - 1e-6 || ((err - best_e).abs() <= 1e-6 && s0 + s1 < best.0 + best.1 - 1e-6)
}
struct ScaleFit {
cap: f64,
p0: Point,
p3: Point,
d0: Vec2,
d1: Vec2,
}
impl ScaleFit {
fn consider(&self, s0: f64, s1: f64, samples: &[Point], best: &mut (f64, f64), best_e: &mut f64) {
let s0 = s0.clamp(0.0, self.cap);
let s1 = s1.clamp(0.0, self.cap);
let err = shape_error(self.p0, self.p3, self.d0, self.d1, s0, s1, samples);
if prefer(err, s0, s1, *best_e, *best) {
*best_e = err;
*best = (s0, s1);
}
}
}
/// Lengths along `d0` and `d1`. A grid search on the nearest-point error, then a tighter grid
/// around the winner. The handles already on the path, and retracted handles, are candidates too.
fn best_scales(p0: Point, p3: Point, d0: Vec2, d1: Vec2, keep0: f64, keep1: f64, samples: &[Point]) -> (f64, f64) {
let fit = ScaleFit { cap: (p3 - p0).hypot().max(1.0) * 4.0, p0, p3, d0, d1 };
let mut best = (0.0_f64, 0.0_f64);
let mut best_e = f64::MAX;
fit.consider(keep0, keep1, samples, &mut best, &mut best_e);
fit.consider(0.0, 0.0, samples, &mut best, &mut best_e);
const N: usize = 16;
for i in 0..=N {
for j in 0..=N {
fit.consider(fit.cap * (i as f64 / N as f64), fit.cap * (j as f64 / N as f64), samples, &mut best, &mut best_e);
}
}
let mut window = fit.cap / N as f64;
for _ in 0..4 {
let step = window / 6.0;
if step < 1e-3 {
break;
}
let center = best;
for i in 0..=12 {
for j in 0..=12 {
fit.consider(center.0 + (i as f64 - 6.0) * step, center.1 + (j as f64 - 6.0) * step, samples, &mut best, &mut best_e);
}
}
window *= 0.5;
}
best
}
/// Facing handles of the anchors on either side of `index`, as absolute positions.
fn fit_facing_handles(sp: &SubPath, index: usize) -> Option<(Point, Point)> {
let n = sp.anchors.len();
let pi = (index + n - 1) % n;
let ni = (index + 1) % n;
let prev = sp.anchors.get(pi)?;
let mid = sp.anchors.get(index)?;
let next = sp.anchors.get(ni)?;
let d0 = ray_out(prev, mid);
let d1 = ray_in(next, mid);
let samples = sample_pair(sp, index);
if samples.len() < 3 {
return None;
}
let (p0, p3) = (prev.p, next.p);
let keep0 = (prev.h_out - p0).hypot();
let keep1 = (next.h_in - p3).hypot();
let (s0, s1) = best_scales(p0, p3, d0, d1, keep0, keep1, &samples);
let cap = (p3 - p0).hypot().max(1.0) * 8.0;
let s0 = if s0.is_finite() { s0.clamp(0.0, cap) } else { 0.0 };
let s1 = if s1.is_finite() { s1.clamp(0.0, cap) } else { 0.0 };
Some((p0 + d0 * s0, p3 + d1 * s1))
}
fn is_collinear(p: Point, a: Point, b: Point) -> bool {
let va = a - p;
let vb = b - p;
@@ -268,6 +409,32 @@ impl SubPath {
self.anchors.insert(seg + 1, mid);
seg + 1
}
/// Remove anchor `index`. When `fit` is set and the anchor has a neighbour on both sides, the
/// facing handles are lengthened or shortened along their current direction so one cubic stays
/// close to the two segments that met at the removed point. Positions of the other anchors do
/// not move. Returns false when `index` is out of range.
pub fn remove_anchor(&mut self, index: usize, fit: bool) -> bool {
let n = self.anchors.len();
if index >= n {
return false;
}
let interior = self.closed || (index > 0 && index + 1 < n);
if fit
&& interior
&& n >= 3
&& self.anchors.iter().all(anchor_finite)
&& let Some((hout, hin)) = fit_facing_handles(self, index)
{
let pi = (index + n - 1) % n;
let ni = (index + 1) % n;
let prev = self.anchors[pi];
let next = self.anchors[ni];
self.anchors[pi] = Anchor::with_handles(prev.p, prev.h_in, hout);
self.anchors[ni] = Anchor::with_handles(next.p, hin, next.h_out);
}
self.anchors.remove(index);
true
}
/// 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();
@@ -556,4 +723,63 @@ mod tests {
let back: PathData = serde_json::from_str(&s).unwrap();
assert_eq!(p, back);
}
#[test]
fn smart_remove_restores_a_split_cubic() {
let mut sp = SubPath::new(
vec![
Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(30.0, 80.0)),
Anchor::with_handles(Point::new(100.0, 0.0), Point::new(70.0, 80.0), Point::new(100.0, 0.0)),
],
false,
);
let original = sp.clone();
let idx = sp.insert_anchor(0, 0.4);
assert!(sp.remove_anchor(idx, true));
assert_eq!(sp.anchors.len(), 2);
assert!(sp.anchors[0].h_out.distance(original.anchors[0].h_out) < 0.5, "{:?}", sp.anchors[0].h_out);
assert!(sp.anchors[1].h_in.distance(original.anchors[1].h_in) < 0.5, "{:?}", sp.anchors[1].h_in);
}
#[test]
fn smart_remove_of_a_line_stays_a_line() {
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(100.0, 0.0)], false);
assert!(sp.remove_anchor(1, true));
assert_eq!(sp.anchors.len(), 2);
assert!(!sp.anchors[0].has_out());
assert!(!sp.anchors[1].has_in());
}
#[test]
fn smart_remove_of_a_corner_beats_the_straight_chord() {
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 40.0), Point::new(100.0, 0.0)], false);
assert!(sp.remove_anchor(1, true));
let d = sp.segment(0).nearest(Point::new(50.0, 40.0), 1e-4).distance_sq.sqrt();
assert!(d < 40.0, "curve stays closer to the removed corner than the chord does ({d})");
}
#[test]
fn remove_without_fit_keeps_handles() {
let mut sp = SubPath::new(
vec![
Anchor::with_handles(Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(10.0, 30.0)),
Anchor::corner(Point::new(50.0, 10.0)),
Anchor::with_handles(Point::new(100.0, 0.0), Point::new(80.0, 25.0), Point::new(100.0, 0.0)),
],
false,
);
let before = sp.clone();
assert!(sp.remove_anchor(1, false));
assert_eq!(sp.anchors[0].h_out, before.anchors[0].h_out);
assert_eq!(sp.anchors[1].h_in, before.anchors[2].h_in);
assert!(!sp.remove_anchor(9, true));
}
#[test]
fn remove_endpoint_drops_the_point() {
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(20.0, 5.0)], false);
assert!(sp.remove_anchor(0, true));
assert_eq!(sp.anchors[0].p, Point::new(10.0, 0.0));
assert_eq!(sp.anchors.len(), 2);
}
}
+22
View File
@@ -501,4 +501,26 @@ 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_click_on_an_anchor_does_not_remove_it() {
let (d, id) = doc_with_rect();
let mut sel = Selection::default();
sel.set([id]);
let p = paint();
let cx = cx(&d, &sel, &p);
let mut t = PenTool::default();
let mods = Mods { alt: true, ..Mods::default() };
// Alt over an anchor stays the Pen. Convert Anchor Point will use this key later.
assert_eq!(t.cursor(&cx, Point::new(200.0, 100.0), mods), Cursor::Pen);
let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 100.0).with_mods(mods));
let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 100.0).with_mods(mods));
for act in down.iter().chain(&up) {
let name = match act {
Action::Begin(c) | Action::Preview(c, _) | Action::Exec(c, _) | Action::Dialog(c, _) => c.as_str(),
_ => "",
};
assert_ne!(name, "path.smartRemoveAnchor", "{act:?}");
}
}
}
+3
View File
@@ -1424,6 +1424,9 @@ 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() {
items.push((tl!("Smart Remove Anchor Points"), "dc-anchor", "path.smartRemoveAnchor"));
}
if n > 1 {
items.push((tl!("Group"), "group", "object.group"));
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
+6
View File
@@ -138,6 +138,12 @@ 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 {
ui.add_space(8.0);
if widgets::flat_button(ui, "Smart Remove Anchor Points", 204.0).clicked() {
crate::menus::invoke(app, "path.smartRemoveAnchor", json!({}));
}
}
ui.add_space(6.0);
crate::place::control_bar_details(app, ui);
crate::toolbar::control_bar_options(app, ui);
+1
View File
@@ -158,6 +158,7 @@
Simplify… Zjednodušit…
Add Anchor Points Přidat kotevní body
Remove Anchor Points Odstranit kotevní body
Smart Remove Anchor Points Chytře odstranit kotevní body
Divide Objects Below Rozdělit objekty pod
Split Into Grid… Rozdělit do mřížky…
Clean Up… Vyčistit…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -158,6 +158,7 @@
Simplify… 単純化…
Add Anchor Points アンカーポイントの追加
Remove Anchor Points アンカーポイントの削除
Smart Remove Anchor Points アンカーポイントのスマート削除
Divide Objects Below 背面のオブジェクトで型抜き
Split Into Grid… グリッドに分割…
Clean Up… 余分なポイントを削除…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2409,6 +2409,7 @@
Smart Guides (View → Smart Guides) 智慧型參考線 (檢視 → 智慧型參考線)
Smart Guides (⌘U) 智慧型參考線 (⌘U)
Smart Punctuation… 智慧型標點…
Smart Remove Anchor Points 智慧移除錨點
Smooth 平滑
Smooth Color 平滑顏色
Smooth Tool 平滑工具
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -102,6 +102,8 @@ mod tests_saveext;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartremove;
#[cfg(test)]
mod tests_svg;
#[cfg(test)]
mod tests_svgsave;
+9
View File
@@ -1530,6 +1530,11 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
if waits_for_ime(app, id) {
return false;
}
// The menu, the palette and the context menu run this on direct-selected anchors. An explicit
// id still works over MCP: the engine only requires a selection.
if id == "path.smartRemoveAnchor" {
return app.session.active().is_some_and(|d| !d.selection.anchors.is_empty());
}
if let Some(c) = vectorcraft_engine::find_command(id) {
// The system clipboard's contents can be pasted with an empty internal clipboard.
return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste");
@@ -1814,6 +1819,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Simplify…", "object.path.simplify"),
c("Add Anchor Points", "object.path.addAnchorPoints"),
c("Remove Anchor Points", "path.deleteAnchors"),
c("Smart Remove Anchor Points", "path.smartRemoveAnchor"),
c("Divide Objects Below", "object.path.divideObjectsBelow"),
c("Split Into Grid…", "object.path.splitIntoGrid"),
Sep,
@@ -2314,6 +2320,9 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
if paths {
v.extend([c("Join", "path.join"), c("Average…", "path.average")]);
}
if !st.selection.anchors.is_empty() {
v.push(c("Smart Remove Anchor Points", "path.smartRemoveAnchor"));
}
if several {
v.push(c("Make Clipping Mask", "object.clippingMask.make"));
}
+5
View File
@@ -16,6 +16,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
};
let n_sel = st.selection.len();
let anchor_mode = !st.selection.anchors.is_empty();
let first = first_selected(app);
let label = match (&first, n_sel) {
(None, _) => tl!("Document").to_string(),
@@ -97,6 +98,10 @@ 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, "Smart Remove Anchor Points", ui.available_width()).clicked() {
crate::menus::invoke(app, "path.smartRemoveAnchor", json!({}));
}
actions.push((tl!("Offset Path"), "object.path.offsetPath"));
actions.push((tl!("Simplify"), "object.path.simplify"));
actions.push((tl!("Arrange: Bring to Front"), "object.arrange.bringToFront"));
+165
View File
@@ -0,0 +1,165 @@
//! Smart Remove Anchor Points: a command for direct-selected anchors, not a Pen click.
use egui::{Event, PointerButton, Pos2, Rect, Shape, vec2};
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use crate::canvas::Xf;
use crate::menus::{self, Item};
use crate::{VectorcraftApp, canvas, chrome};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app
}
fn rect(app: &mut VectorcraftApp) -> NodeId {
NodeId(app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap())
}
fn anchor_count(app: &VectorcraftApp, id: NodeId) -> usize {
app.session.active().and_then(|st| st.doc.node(id)).and_then(|n| n.path_data()).map(|p| p.anchor_count()).unwrap_or(0)
}
fn labels(items: &[Item]) -> Vec<&'static str> {
items
.iter()
.flat_map(|it| match it {
Item::Cmd(l, ..) => vec![*l],
Item::Sub(l, ch) => std::iter::once(*l).chain(labels(ch)).collect(),
_ => vec![],
})
.collect()
}
fn shapes_text(shapes: &[Shape]) -> Vec<(String, Rect)> {
fn walk(s: &Shape, v: &mut Vec<(String, Rect)>) {
match s {
Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
_ => {}
}
}
let mut v = vec![];
shapes.iter().for_each(|s| walk(s, &mut v));
v
}
fn control_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| chrome::control_bar(app, ui));
out.textures_delta.clear();
shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
fn click_control(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
control_frame(app, ctx, vec![Event::PointerMoved(at), press(true)]);
control_frame(app, ctx, vec![press(false)]);
control_frame(app, ctx, vec![]);
}
fn canvas_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| canvas::show(app, ui));
out.textures_delta.clear();
shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
fn click_canvas(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2, button: PointerButton) -> Vec<(String, Rect)> {
let press = |pressed| Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() };
canvas_frame(app, ctx, vec![Event::PointerMoved(at)]);
canvas_frame(app, ctx, vec![press(true)]);
canvas_frame(app, ctx, vec![press(false)]);
canvas_frame(app, ctx, vec![])
}
fn properties_frame(app: &mut VectorcraftApp, width: f32) -> Vec<(String, Rect)> {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
crate::panels::properties::show(app, ui);
});
out.textures_delta.clear();
shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::<Vec<_>>())
}
fn has(texts: &[(String, Rect)], label: &str) -> bool {
texts.iter().any(|(t, _)| t == label)
}
fn at(texts: &[(String, Rect)], label: &str) -> Pos2 {
texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}`"))
}
#[test]
fn the_path_menu_and_bars_run_it_for_direct_selected_anchors() {
let mut app = app();
let id = rect(&mut app);
assert_eq!(anchor_count(&app, id), 4);
let entry = menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("path.smartRemoveAnchor")).unwrap();
assert_eq!(entry.path, ["Object", "Path"]);
assert_eq!(entry.label, "Smart Remove Anchor Points");
assert!(!entry.enabled, "an object selection is not enough");
assert!(!labels(&menus::context_items(&app)).contains(&"Smart Remove Anchor Points"));
assert!(crate::palette::items().iter().any(|(_, cmd, _)| cmd == "path.smartRemoveAnchor"));
app.select_tool("directSelection");
app.run("select.anchors", json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
assert!(menus::enabled(&app, "path.smartRemoveAnchor"));
assert!(labels(&menus::context_items(&app)).contains(&"Smart Remove Anchor Points"));
assert!(menus::menu_strings().contains("Smart Remove Anchor Points"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let bar = control_frame(&mut app, &ctx, vec![]);
assert!(has(&bar, "Smart Remove Anchor Points"), "control bar: {bar:?}");
let label_w = bar.iter().find(|(t, _)| t == "Smart Remove Anchor Points").unwrap().1.width();
assert!(label_w <= 196.0, "the Control bar button clips the label ({label_w} pt)");
click_control(&mut app, &ctx, at(&bar, "Smart Remove Anchor Points"));
assert_eq!(anchor_count(&app, id), 3, "the Control bar button removes the anchor");
assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "Smart Remove Anchor Point");
app.run("edit.undo", json!({})).unwrap();
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.
let props = properties_frame(&mut app, 230.0);
let row = props.iter().find(|(t, _)| t == "Smart Remove Anchor Points").expect("properties");
assert!(row.1.min.x >= -0.5 && row.1.max.x <= 230.5, "properties clips the label: {:?}", row.1);
// The task bar's area settles on the second frame.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
canvas_frame(&mut app, &ctx, vec![]);
let texts = canvas_frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Smart Remove Anchor Points"), "task bar: {texts:?}");
// Right-click keeps the anchor selection (the object is already selected) and runs the item.
let p = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.0, 90.0));
let texts = click_canvas(&mut app, &ctx, p, PointerButton::Secondary);
assert!(has(&texts, "Smart Remove Anchor Points"), "context menu: {texts:?}");
let texts = click_canvas(&mut app, &ctx, at(&texts, "Smart Remove Anchor Points"), PointerButton::Primary);
assert_eq!(anchor_count(&app, id), 3);
assert!(!has(&texts, "Smart Remove Anchor Points"), "the menu closes after the command");
app.select_tool("pen");
let hint = crate::tests_labels::painted_text(&mut app, chrome::hint_bar);
assert!(!hint.contains("Smart Remove") && !hint.contains("keep the shape"), "pen hint: {hint}");
}
#[test]
fn the_control_bar_hides_the_button_without_anchors() {
let mut app = app();
rect(&mut app);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let bar = control_frame(&mut app, &ctx, vec![]);
assert!(!has(&bar, "Smart Remove Anchor Points"), "{bar:?}");
let props = crate::tests_labels::painted_text(&mut app, crate::panels::properties::show);
assert!(!props.contains("Smart Remove Anchor Points"), "{props}");
}