From d5a060a00909ef58bb81a18eb3d3b4eacd258b1f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 02:20:32 +0700 Subject: [PATCH] =?UTF-8?q?Path:=20Remove=20Anchor=20Points=20is=20Object?= =?UTF-8?q?=20=E2=80=BA=20Path's=20one=20item=20and=20shares=20the=20Delet?= =?UTF-8?q?e=20Anchor=20Point=20refit=20(review=20of=20#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parity: Object › Path › Remove Anchor Points now runs the new command (`path.removeAnchors`, the menu's feature name) instead of `path.deleteAnchors`, which deleted the anchors with their segments and opened closed paths; that stays the Delete key's command ("Delete Anchor Points", off the menu). No second "Smart" item. - Geometry: the refit moves to `vectorcraft_pathops::remove_anchor` on the existing least-squares fitter (fixed end tangents, a golden-section search for where the curve passes the removed point) instead of a ~1,000-candidate grid search in geom (about 4 ms per anchor, now well under 0.1 ms). Two straight sides stay one straight side (a rectangle loses a corner and becomes a triangle), as Illustrator does; non-finite input leaves the handles alone. - The Delete Anchor Point tool (`path.removeAnchor`) uses the same function, replacing its heuristic handle stretch; the explicit id/anchor and `fit` params of the new command are dropped as duplicates of it. The command is enabled by direct-selected anchors (`has_anchors`), not a UI override. - The Control bar shows an icon button (the Delete Anchor Point icon, tooltip "Remove Anchor Points") instead of a 204 pt text button; the labels reuse the translated "Remove Anchor Points"; zh-hant gains "Delete Anchor Points". The Pen test that pinned Alt-click as plain Pen (Illustrator's Alt over an anchor is Convert Anchor Point) is dropped. - Tests for the refit, closed paths, straight sides and bad input; docs/mcp.md and ROADMAP. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/draw2.rs | 175 ++++---------- crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/cmd/path.rs | 8 +- crates/engine/src/tests_draw2.rs | 71 ++---- crates/geom/src/path.rs | 228 +----------------- crates/pathops/src/edit.rs | 73 +++++- crates/pathops/src/fit.rs | 7 +- crates/pathops/src/lib.rs | 3 +- crates/pathops/tests/ops.rs | 46 ++++ crates/tools/src/pen.rs | 22 -- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/chrome.rs | 7 +- crates/ui-egui/src/i18n/cs.tsv | 1 - crates/ui-egui/src/i18n/ja.tsv | 1 - crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/menus.rs | 10 +- crates/ui-egui/src/panels/properties.rs | 4 +- ..._smartremove.rs => tests_removeanchors.rs} | 56 ++--- docs/mcp.md | 14 ++ 21 files changed, 257 insertions(+), 485 deletions(-) rename crates/ui-egui/src/{tests_smartremove.rs => tests_removeanchors.rs} (72%) diff --git a/ROADMAP.md b/ROADMAP.md index 6b1b99ce..fd86c079 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, smart remove when anchors are selected, 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, Remove Anchor Points 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. 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. + - 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. - 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. diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 4e13d3a8..1e27c9dc 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -46,13 +46,13 @@ pub fn specs() -> Vec { remove_anchor ), cmd!( - "path.smartRemoveAnchor", - "Smart Remove Anchor Points", + "path.removeAnchors", + "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 + "{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}", + has_anchors, + remove_anchors ), cmd!( "path.convertAnchor", @@ -601,141 +601,60 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result { let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; let ai = usize_req(p, "anchor", C)?; let removed = s.edit("Delete Anchor Point", |d, sel| { - 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 { + let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + if ai >= sp.anchors.len() { return Err(EngineError::Other("no such anchor".into())); } - let interior = sp.closed || (ai > 0 && ai + 1 < n); - if interior && n >= 3 { - let (pi, ni) = ((ai + n - 1) % n, (ai + 1) % n); - let (prev, a, next) = (sp.anchors[pi], sp.anchors[ai], sp.anchors[ni]); - // Keep the outer tangents; stretch them to span both removed segments. - let l1 = prev.p.distance(a.p); - let l2 = a.p.distance(next.p); - let chord = prev.p.distance(next.p).max(1e-9); - let first_line = !prev.has_out() && !a.has_in(); - let second_line = !a.has_out() && !next.has_in(); - let (mut hout, mut hin) = (prev.h_out, next.h_in); - if !(first_line && second_line) { - // Curved: stretch the outer handles to span both segments; a straight side aims a - // third of the chord at the removed anchor. - hout = if prev.has_out() { - prev.p + (prev.h_out - prev.p) * ((l1 + l2) / l1.max(1e-9)).min(3.0) - } else { - prev.p + (a.p - prev.p) * (chord / 3.0 / l1.max(1e-9)) - }; - hin = if next.has_in() { - next.p + (next.h_in - next.p) * ((l1 + l2) / l2.max(1e-9)).min(3.0) - } else { - next.p + (a.p - next.p) * (chord / 3.0 / l2.max(1e-9)) - }; - } - sp.anchors[pi].h_out = hout; - sp.anchors[ni].h_in = hin; - } - sp.anchors.remove(ai); - if !sp.closed && !sp.anchors.is_empty() { - let (f, l) = (0, sp.anchors.len() - 1); - let a0 = sp.anchors[f].p; - sp.anchors[f].h_in = a0; - let al = sp.anchors[l].p; - sp.anchors[l].h_out = al; - } - if sp.anchors.len() < 3 { - sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out()); - } - let empty = sp.anchors.len() < 2; - if empty { - path.subpaths.remove(si); - } - if path.is_empty() { - d.remove(id)?; - sel.clear(); - return Ok(true); - } - sel.anchors.remove(&id); - Ok(false) + remove_anchors_of(d, sel, id, [(si, ai)]) })?; Ok(json!({ "removedObject": removed })) } -fn smart_remove_anchor(s: &mut Session, p: &Value) -> Result { - 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")); +fn remove_anchors(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st + .selection + .anchors + .iter() + .filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. }))) + .map(|(id, set)| (*id, set.iter().copied().collect())) + .collect(); + if jobs.is_empty() { + return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); + } + let removed = s.edit("Remove Anchor Points", |d, sel| { + let mut removed = 0; + for (id, anchors) in jobs { + removed += usize::from(remove_anchors_of(d, sel, id, anchors)?); } - v - }; - let label = if fit { "Smart Remove Anchor Point" } else { "Delete Anchor Point" }; - s.edit(label, |d, sel| { - let mut ids: Vec = 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 = jobs.iter().filter(|(i, ..)| *i == id).map(|(_, si, _)| *si).collect(); - subs.sort_unstable(); - subs.dedup(); - for si in subs { - let mut ais: Vec = 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(removed) })?; - ok() + Ok(json!({ "removedObjects": removed })) } -/// 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; +/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round +/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when +/// the path went. +fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator) -> Result { + let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); + // The last first, so the indexes still to come stay valid. + anchors.sort_unstable_by(|a, b| b.cmp(a)); + anchors.dedup(); + let path = path_mut(d, id)?; + for (si, ai) in anchors { + if let Some(sp) = path.subpaths.get_mut(si) { + po::remove_anchor(sp, ai); + } } - 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()); + path.subpaths.retain(|sp| sp.anchors.len() >= 2); + let gone = path.is_empty(); + if gone { + d.remove(id)?; + sel.remove(id); + } else { + sel.anchors.remove(&id); } + Ok(gone) } fn convert_anchor(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 623f757f..a1fd5484 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -144,6 +144,10 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } } +pub fn has_anchors(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.selection.anchors.values().all(|a| a.is_empty()) { Err("no anchor points selected".into()) } else { Ok(()) } +} pub fn has_multi(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 9486117b..77371ced 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -87,11 +87,11 @@ pub fn specs() -> Vec { ), cmd!( "path.deleteAnchors", - "Remove Anchor Points", - ["Object", "Path"], + "Delete Anchor Points", + [], None, - "{} delete direct-selected anchors", - has_selection, + "{} delete the direct-selected anchors and their segments, opening closed paths there (the Delete key)", + has_anchors, delete_anchors ), cmd!( diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 7c4f254e..cf89a4ba 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -1,6 +1,5 @@ //! 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}; @@ -376,8 +375,7 @@ 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.removeAnchors", json!({})), ("path.convertAnchor", json!({"id": 9999, "anchor": 0, "to": "corner"})), ("path.reshapeSegment", json!({"id": id.0, "segment": 42})), ("path.split", json!({"id": id.0})), @@ -485,7 +483,7 @@ fn shift_drag_keeps_a_smooth_anchor_smooth_and_alt_breaks_it() { } #[test] -fn smart_remove_refits_a_split_curve_and_undo_restores_it() { +fn removing_an_anchor_refits_a_split_curve_and_undo_restores_it() { let mut s = session(); let made = s .execute( @@ -500,7 +498,7 @@ fn smart_remove_refits_a_split_curve_and_undo_restores_it() { 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(); + s.execute("path.removeAnchor", &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); @@ -511,49 +509,28 @@ fn smart_remove_refits_a_split_curve_and_undo_restores_it() { } #[test] -fn smart_remove_without_fit_leaves_handles() { +fn remove_anchor_points_keeps_the_paths_closed() { + // Object › Path › Remove Anchor Points, unlike the Delete key: a rectangle loses a corner and + // stays closed, a straight-sided triangle; a curve keeps its shape round a removed point. 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})"); + let r = rect(&mut s, 0.0, 0.0, 100.0, 80.0); + let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 0, "out": [330, 80]}, {"x": 400, "y": 0, "in": [370, 80]}]})).unwrap(); + let c = NodeId(made["id"].as_u64().unwrap()); + let before = path(&s, c).subpaths[0].segment(0); + s.execute("path.insertAnchor", &json!({"id": c.0, "segment": 0, "t": 0.5})).unwrap(); + s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0); + let sp = &path(&s, r).subpaths[0]; + assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}"); + assert!(!sp.anchors.iter().any(|a| a.has_in() || a.has_out()), "straight sides stay straight: {sp:?}"); + let after = path(&s, c).subpaths[0].segment(0); + assert_eq!(path(&s, c).subpaths[0].anchors.len(), 2); + assert!(after.p1.distance(before.p1) < 0.5 && after.p2.distance(before.p2) < 0.5, "{after:?}"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove Anchor Points"); 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!(path(&s, r).subpaths[0].anchors.len(), 4); + assert_eq!(path(&s, c).subpaths[0].anchors.len(), 3); + let spec = find_command("path.removeAnchors").unwrap(); assert_eq!(spec.menu, &["Object", "Path"][..]); } diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 738faa81..67c53873 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -1,6 +1,6 @@ //! The anchor-based editable path model. -use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape, Vec2}; +use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape}; use serde::{Deserialize, Serialize}; use crate::EPS; @@ -171,147 +171,6 @@ 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 { - 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; @@ -409,32 +268,6 @@ 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(); @@ -723,63 +556,4 @@ 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); - } } diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 587a8247..7e3451dd 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -1,11 +1,11 @@ //! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average, //! Join, Split Into Grid. -use kurbo::{ParamCurveArclen, Point, Rect, Vec2}; +use kurbo::{CubicBez, ParamCurveArclen, Point, Rect, Vec2}; use vectorcraft_geom::{Anchor, PathData, SubPath}; use crate::boolean::{Seg, segs_to_subpath}; -use crate::fit::{end_tangent, fit_cubics, fit_single, is_straight, sample, start_tangent}; +use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent}; /// Options for [`simplify_with`]. #[derive(Clone, Copy, Debug, PartialEq)] @@ -226,6 +226,75 @@ fn merge_pair(a: &Seg, b: &Seg, tol: f64) -> Option { (err <= tol).then_some(Seg { c, line: false }) } +/// Remove anchor `index` without opening the path (the Delete Anchor Point tool and Object → Path +/// → Remove Anchor Points). Its neighbours keep their handle directions and their facing handles +/// are refitted so one cubic follows the two segments that met there; two straight segments +/// become one. Removing an end of an open path drops its segment. Returns false, leaving `sp` +/// alone, when `index` is out of range. +pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { + let n = sp.anchors.len(); + if index >= n { + return false; + } + if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 { + let (pi, ni) = ((index + n - 1) % n, (index + 1) % n); + if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) { + let (left, right) = (sp.segment(pi), sp.segment(index)); + const N: usize = 16; + let mut pts = Vec::with_capacity(2 * N + 1); + sample(&left, N, &mut pts, true); + sample(&right, N, &mut pts, false); + let (t0, t1) = (start_tangent(&left), end_tangent(&right)); + // The new curve passes the removed point at some parameter k: the left samples sit + // at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit. + let fit = |k: f64| { + let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect(); + fit_single_from(&pts, u, t0, t1) + }; + let (c, _, _) = golden_min(fit, 0.0, 1.0); + if c.p1.is_finite() && c.p2.is_finite() { + sp.anchors[pi].h_out = c.p1; + sp.anchors[ni].h_in = c.p2; + } + } + } + sp.anchors.remove(index); + if !sp.closed { + // A new end has nothing beyond it. + if let Some(a) = sp.anchors.first_mut() { + a.h_in = a.p; + } + if let Some(a) = sp.anchors.last_mut() { + a.h_out = a.p; + } + } + if sp.anchors.len() < 3 { + sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out()); + } + true +} + +/// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a +/// golden-section search (a bounded number of steps). +fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) { + const R: f64 = 0.618_033_988_749_895; + let (mut x1, mut x2) = (b - R * (b - a), a + R * (b - a)); + let (mut f1, mut f2) = (f(x1), f(x2)); + for _ in 0..40 { + // A NaN error never wins. + if f1.1 < f2.1 || f2.1.is_nan() { + (b, x2, f2) = (x2, x1, f1); + x1 = b - R * (b - a); + f1 = f(x1); + } else { + (a, x1, f1) = (x1, x2, f2); + x2 = a + R * (b - a); + f2 = f(x2); + } + } + if f1.1 <= f2.1 { f1 } else { f2 } +} + /// Object → Path → Add Anchor Points: one new anchor at the middle (t = 0.5) of every segment. pub fn add_anchor_points(path: &PathData) -> PathData { let mut out = path.clone(); diff --git a/crates/pathops/src/fit.rs b/crates/pathops/src/fit.rs index a9a91764..091164d2 100644 --- a/crates/pathops/src/fit.rs +++ b/crates/pathops/src/fit.rs @@ -69,7 +69,12 @@ fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec, /// Best single cubic for `pts` with the given end tangents. Returns (curve, max error, index of /// the worst point). pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) { - let mut u = chord_params(pts); + fit_single_from(pts, chord_params(pts), t0, t1) +} + +/// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the +/// chord-length ones. +pub(crate) fn fit_single_from(pts: &[Point], mut u: Vec, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) { let mut best = generate(pts, &u, t0, t1); let (mut best_err, mut best_split) = max_error(pts, &best, &u); for _ in 0..MAX_REPARAM { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 7d56bb95..9092d81f 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -15,7 +15,8 @@ mod planar; pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all}; pub use edit::{ - AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_redundant_points, simplify, simplify_with, smooth, split_into_grid, + AverageAxis, SimplifyOptions, add_anchor_points, average, join, remove_anchor, remove_redundant_points, simplify, simplify_with, smooth, + split_into_grid, }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; diff --git a/crates/pathops/tests/ops.rs b/crates/pathops/tests/ops.rs index cd088491..40bdfc2d 100644 --- a/crates/pathops/tests/ops.rs +++ b/crates/pathops/tests/ops.rs @@ -437,3 +437,49 @@ fn timing_unite_1000_circles() { println!("unite 1000 circles: {} ms, {} anchors", t.elapsed().as_millis(), r[0].path.anchor_count()); assert!(!r.is_empty()); } + +#[test] +fn remove_anchor_refits_the_curve_round_it() { + use vectorcraft_geom::Anchor; + let (p0, p3) = (Point::new(0.0, 0.0), Point::new(100.0, 0.0)); + let original = + SubPath::new(vec![Anchor::with_handles(p0, p0, Point::new(30.0, 80.0)), Anchor::with_handles(p3, Point::new(70.0, 80.0), p3)], false); + let mut sp = original.clone(); + let i = sp.insert_anchor(0, 0.4); + assert!(remove_anchor(&mut sp, i)); + 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); + // A circle stays closed and round with one anchor fewer. + let mut c = circle(0.0, 0.0, 50.0).subpaths.remove(0); + let n = c.anchors.len(); + assert!(remove_anchor(&mut c, 1)); + assert!(c.closed && c.anchors.len() == n - 1); + let a = ar(&PathData::single(c)); + assert!(close(a, PI * 2500.0, 0.03), "{a}"); +} + +#[test] +fn remove_anchor_between_straight_sides_draws_a_straight_one() { + let mut sp = rect(0.0, 0.0, 100.0, 80.0).subpaths.remove(0); + let n = sp.anchors.len(); + assert!(remove_anchor(&mut sp, 0)); + assert!(sp.closed && sp.anchors.len() == n - 1); + assert!(!sp.anchors.iter().any(|a| a.has_in() || a.has_out()), "{sp:?}"); + let mut line = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(40.0, 30.0), Point::new(100.0, 0.0)], false); + assert!(remove_anchor(&mut line, 1)); + assert!(line.anchors.len() == 2 && line.segment_is_line(0)); +} + +#[test] +fn remove_anchor_at_an_end_drops_its_segment_and_rejects_bad_input() { + 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!(!remove_anchor(&mut sp, 3)); + assert!(remove_anchor(&mut sp, 0)); + assert_eq!(sp.anchors[0].p, Point::new(10.0, 0.0)); + // Non-finite points never panic. + let mut bad = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(f64::NAN, 1.0), Point::new(f64::INFINITY, 0.0)], false); + bad.anchors[0].h_out = Point::new(5.0, f64::NAN); + assert!(remove_anchor(&mut bad, 1)); + assert_eq!(bad.anchors.len(), 2); +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 715337f3..7ab69e68 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -501,26 +501,4 @@ 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:?}"); - } - } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 33a62fb5..8624af90 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1425,7 +1425,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { 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")); + items.push((tl!("Remove Anchor Points"), "dc-pen-delete", "path.removeAnchors")); } if n > 1 { items.push((tl!("Group"), "group", "object.group")); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 31385547..cfb73d6a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -138,11 +138,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 { - ui.add_space(8.0); - if widgets::flat_button(ui, "Smart Remove Anchor Points", 204.0).clicked() { - crate::menus::invoke(app, "path.smartRemoveAnchor", json!({})); - } + if anchor_mode && widgets::icon_button(ui, "dc-pen-delete", "Remove Anchor Points", false, 24.0).clicked() { + crate::menus::invoke(app, "path.removeAnchors", json!({})); } ui.add_space(6.0); crate::place::control_bar_details(app, ui); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e88b87c0..e1148c8a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -158,7 +158,6 @@ 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… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d373ed70..c3d3f913 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -158,7 +158,6 @@ Simplify… 単純化… Add Anchor Points アンカーポイントの追加 Remove Anchor Points アンカーポイントの削除 - Smart Remove Anchor Points アンカーポイントのスマート削除 Divide Objects Below 背面のオブジェクトで型抜き Split Into Grid… グリッドに分割… Clean Up… 余分なポイントを削除… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f2f6301a..1fe8015e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -550,6 +550,7 @@ Delete All 全部刪除 Delete Anchor Point 刪除錨點 Delete Anchor Point Tool 刪除錨點工具 + Delete Anchor Points 刪除錨點 Delete Artboard 刪除工作區域 Delete Artboards 刪除工作區域 Delete Brush 刪除筆刷 @@ -2409,7 +2410,6 @@ Smart Guides (View → Smart Guides) 智慧型參考線 (檢視 → 智慧型參考線) Smart Guides (⌘U) 智慧型參考線 (⌘U) Smart Punctuation… 智慧型標點… - Smart Remove Anchor Points 智慧移除錨點 Smooth 平滑 Smooth Color 平滑顏色 Smooth Tool 平滑工具 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4a3044f4..eb298cf0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -96,14 +96,14 @@ mod tests_recolor; #[cfg(test)] mod tests_recovery; #[cfg(test)] +mod tests_removeanchors; +#[cfg(test)] mod tests_save; #[cfg(test)] mod tests_saveext; #[cfg(test)] mod tests_slices; #[cfg(test)] -mod tests_smartremove; -#[cfg(test)] mod tests_svg; #[cfg(test)] mod tests_svgsave; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4bfd8f80..559c8bf3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1530,11 +1530,6 @@ 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"); @@ -1818,8 +1813,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, 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("Remove Anchor Points", "path.removeAnchors"), c("Divide Objects Below", "object.path.divideObjectsBelow"), c("Split Into Grid…", "object.path.splitIntoGrid"), Sep, @@ -2321,7 +2315,7 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { 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")); + v.push(c("Remove Anchor Points", "path.removeAnchors")); } if several { v.push(c("Make Clipping Mask", "object.clippingMask.make")); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3b3499cd..3d7972a0 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -99,8 +99,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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!({})); + if anchor_mode && widgets::flat_button(ui, "Remove Anchor Points", ui.available_width()).clicked() { + crate::menus::invoke(app, "path.removeAnchors", json!({})); } actions.push((tl!("Offset Path"), "object.path.offsetPath")); actions.push((tl!("Simplify"), "object.path.simplify")); diff --git a/crates/ui-egui/src/tests_smartremove.rs b/crates/ui-egui/src/tests_removeanchors.rs similarity index 72% rename from crates/ui-egui/src/tests_smartremove.rs rename to crates/ui-egui/src/tests_removeanchors.rs index 67235b41..c09712a6 100644 --- a/crates/ui-egui/src/tests_smartremove.rs +++ b/crates/ui-egui/src/tests_removeanchors.rs @@ -1,4 +1,5 @@ -//! Smart Remove Anchor Points: a command for direct-selected anchors, not a Pen click. +//! 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. use egui::{Event, PointerButton, Pos2, Rect, Shape, vec2}; use serde_json::json; @@ -98,38 +99,41 @@ fn at(texts: &[(String, Rect)], label: &str) -> Pos2 { #[test] fn the_path_menu_and_bars_run_it_for_direct_selected_anchors() { + const LABEL: &str = "Remove Anchor Points"; 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")); + let entries: Vec<_> = menus::menu_entries(&app).into_iter().filter(|e| e.label == LABEL).collect(); + assert_eq!(entries.len(), 1, "one Remove Anchor Points item"); + assert_eq!(entries[0].command.as_deref(), Some("path.removeAnchors")); + assert_eq!(entries[0].path, ["Object", "Path"]); + assert!(!entries[0].enabled, "an object selection is not enough"); + assert!(!labels(&menus::context_items(&app)).contains(&LABEL)); + assert!(crate::palette::items().iter().any(|(_, cmd, _)| cmd == "path.removeAnchors")); 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")); + assert!(menus::enabled(&app, "path.removeAnchors")); + assert!(labels(&menus::context_items(&app)).contains(&LABEL)); + // The Control bar's icon follows its "Anchor Point" label. 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")); + 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)); 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"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.last().unwrap().label, LABEL); + let sp = &st.doc.node(id).unwrap().path_data().unwrap().subpaths[0]; + assert!(sp.closed, "the path stays closed"); 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"); + 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); // The task bar's area settles on the second frame. @@ -137,29 +141,21 @@ fn the_path_menu_and_bars_run_it_for_direct_selected_anchors() { 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:?}"); + assert!(has(&texts, LABEL), "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!(has(&texts, LABEL), "context menu: {texts:?}"); + let texts = click_canvas(&mut app, &ctx, at(&texts, LABEL), 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}"); + assert!(!has(&texts, LABEL), "the menu closes after the command"); } #[test] -fn the_control_bar_hides_the_button_without_anchors() { +fn the_control_bar_and_properties_hide_it_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}"); + assert!(!props.contains("Remove Anchor Points"), "{props}"); } diff --git a/docs/mcp.md b/docs/mcp.md index 59efb4c1..6d4abc5b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -936,6 +936,20 @@ paths and compound paths, and `path.reverse {reversed?}` makes subpaths run coun {"name":"run_command","arguments":{"command":"path.setFillRule","params":{"rule":"evenOdd"}}} ``` +## Removing anchor points + +`path.removeAnchors {}` (Object › Path › Remove Anchor Points) removes the direct-selected anchors (`select.anchors +{id, anchors: [[subpath, anchor]…], mode?}`) without opening their paths, in one undo step, and answers +`{removedObjects}` (paths left with no segment go). Each removed point's neighbours keep their handle directions and +their facing handles are refitted so one cubic follows the two old segments; two straight sides become one. +`path.removeAnchor {id, subpath?, anchor}` (the Delete Anchor Point tool) removes one anchor the same way, and +`path.deleteAnchors {}` (the Delete key) deletes the selected anchors with their segments, opening closed paths there. + +```json +{"name":"run_command","arguments":{"command":"select.anchors","params":{"id":12,"anchors":[[0,1],[0,3]]}}} +{"name":"run_command","arguments":{"command":"path.removeAnchors","params":{}}} +``` + ## Registration and trim marks Every document has the built-in `[Registration]` swatch (listed after None by `swatch.list`): a colour that prints on