mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #241 from realwitt/smart-remove
Path: Smart Remove Anchor Points keeps the curve close
This commit is contained in:
17 files changed
+441
-65
No files matched your search
+2
-2
@@ -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, 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.
|
||||
- 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.
|
||||
|
||||
@@ -45,6 +45,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
cmd!(
|
||||
"path.removeAnchors",
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
|
||||
has_anchors,
|
||||
remove_anchors
|
||||
),
|
||||
cmd!(
|
||||
"path.convertAnchor",
|
||||
"Convert Anchor Point",
|
||||
@@ -592,66 +601,62 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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)?);
|
||||
}
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// 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<Item = (usize, usize)>) -> Result<bool> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
|
||||
@@ -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(()) }
|
||||
|
||||
@@ -87,11 +87,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
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!(
|
||||
|
||||
@@ -375,6 +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.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})),
|
||||
@@ -480,3 +481,56 @@ fn shift_drag_keeps_a_smooth_anchor_smooth_and_alt_breaks_it() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_anchor_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.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);
|
||||
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 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 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, 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"][..]);
|
||||
}
|
||||
@@ -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<Seg> {
|
||||
(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();
|
||||
|
||||
@@ -69,7 +69,12 @@ fn fit_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>,
|
||||
/// 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<f64>, 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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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!("Remove Anchor Points"), "dc-pen-delete", "path.removeAnchors"));
|
||||
}
|
||||
if n > 1 {
|
||||
items.push((tl!("Group"), "group", "object.group"));
|
||||
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
|
||||
|
||||
@@ -138,6 +138,9 @@ 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!({}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
crate::place::control_bar_details(app, ui);
|
||||
crate::toolbar::control_bar_options(app, ui);
|
||||
|
||||
@@ -550,6 +550,7 @@
|
||||
Delete All 全部刪除
|
||||
Delete Anchor Point 刪除錨點
|
||||
Delete Anchor Point Tool 刪除錨點工具
|
||||
Delete Anchor Points 刪除錨點
|
||||
Delete Artboard 刪除工作區域
|
||||
Delete Artboards 刪除工作區域
|
||||
Delete Brush 刪除筆刷
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -96,6 +96,8 @@ mod tests_recolor;
|
||||
#[cfg(test)]
|
||||
mod tests_recovery;
|
||||
#[cfg(test)]
|
||||
mod tests_removeanchors;
|
||||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_saveext;
|
||||
|
||||
@@ -1813,7 +1813,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
Sep,
|
||||
c("Simplify…", "object.path.simplify"),
|
||||
c("Add Anchor Points", "object.path.addAnchorPoints"),
|
||||
c("Remove Anchor Points", "path.deleteAnchors"),
|
||||
c("Remove Anchor Points", "path.removeAnchors"),
|
||||
c("Divide Objects Below", "object.path.divideObjectsBelow"),
|
||||
c("Split Into Grid…", "object.path.splitIntoGrid"),
|
||||
Sep,
|
||||
@@ -2314,6 +2314,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("Remove Anchor Points", "path.removeAnchors"));
|
||||
}
|
||||
if several {
|
||||
v.push(c("Make Clipping Mask", "object.clippingMask.make"));
|
||||
}
|
||||
|
||||
@@ -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, "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"));
|
||||
actions.push((tl!("Arrange: Bring to Front"), "object.arrange.bringToFront"));
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 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;
|
||||
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() {
|
||||
const LABEL: &str = "Remove Anchor Points";
|
||||
let mut app = app();
|
||||
let id = rect(&mut app);
|
||||
assert_eq!(anchor_count(&app, id), 4);
|
||||
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.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![]);
|
||||
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");
|
||||
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 == 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.
|
||||
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, 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, 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, LABEL), "the menu closes after the command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}");
|
||||
}
|
||||
+14
@@ -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
|
||||
|
||||
Reference in new issue
Block a user