mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Add-ons: Path Precision Tools — anchor eraser brush, extend path, measure, anchor inspector, crossings
A built-in add-on under Object › Plug-ins › Path Precision Tools: - Anchor Eraser Brush: painting over selected paths removes the anchors under the brush that the outline can do without within a tolerance, refitting the handles beside them (Alt ignores the tolerance, Cmd/Ctrl deletes without refitting, pen pressure can scale the brush); one undo step per stroke. - Extend Path tool: dragging an end of an open path carries it on straight, by stretching its last curve or along the circle it curves on, or shortens it back along itself. - Measure & Inspect tool: drag to measure distance and angle; hover a path to read segment and path length, area, curvature, tangent and anchor angles and handles, in the document's units. - Anchor Inspector panel: the selected anchor's position and handles as numbers, with Make Smooth (auto-smooth rule), Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points and three ways to close. - Add Points at Crossings and Split at Crossings. The tools stay out of both toolbars as shipped; the All Tools drawer lists them under the add-on's heading. Commands are addon.pathtools.*; the existing commands, tools and menus are unchanged.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
mod cleanup;
|
||||
mod color;
|
||||
mod pathtools;
|
||||
|
||||
use super::CommandSpec;
|
||||
|
||||
@@ -10,5 +11,6 @@ use super::CommandSpec;
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = cleanup::specs();
|
||||
v.extend(color::specs());
|
||||
v.extend(pathtools::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Simplify Anchors' tolerance by default, and the drift (pt) each step of it allows.
|
||||
const TOLERANCE: f64 = 20.0;
|
||||
const PT_PER_TOLERANCE: f64 = 0.02;
|
||||
pub(super) const PT_PER_TOLERANCE: f64 = 0.02;
|
||||
/// Rejoin Path Pieces' gap by default, and the widest (pt).
|
||||
const GAP: f64 = 0.1;
|
||||
const MAX_GAP: f64 = 6.0;
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
//! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke,
|
||||
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
|
||||
//! anchor edits and Split / Add Points at Crossings. Its tools, panel and submenu (Object ›
|
||||
//! Plug-ins › Path Precision Tools) are the tools crate's and the UI's.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||||
use vectorcraft_pathops::addons::{self as po, pathtools as pt};
|
||||
|
||||
use super::super::draw2::{replace_with_pieces, usize_opt};
|
||||
use super::super::pathops::Beside;
|
||||
use super::super::{
|
||||
CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param,
|
||||
};
|
||||
use super::cleanup::PT_PER_TOLERANCE;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The Anchor Eraser Brush's tolerance by default (the scale Simplify Anchors (Keep Shape) uses).
|
||||
const ERASE_TOLERANCE: f64 = 10.0;
|
||||
/// Most points one brush stroke carries.
|
||||
const MAX_STROKE_POINTS: usize = 100_000;
|
||||
/// Most anchors one command edits at once.
|
||||
const MAX_ANCHORS: usize = 2_000_000;
|
||||
/// The narrowest a brush gets at the lightest pen pressure (pt).
|
||||
const MIN_RADIUS: f64 = 0.05;
|
||||
/// The widest brush (pt).
|
||||
const MAX_DIAMETER: f64 = 2500.0;
|
||||
/// The farthest an end is extended at once (pt).
|
||||
const MAX_EXTEND: f64 = 1e6;
|
||||
/// Most anchors Add Points puts on each segment.
|
||||
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.pathtools.erase",
|
||||
"Anchor Eraser Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x, y] | [x, y, pressure 0..1]…] (the brush's path, at most 100000 points), diameter?: pt 0.1..2500 (20; a point's pressure scales it), tolerance?: 1..100 (10; the outline may move up to tolerance × 0.02 pt), ignoreTolerance?: bool (false: every anchor under the brush that can go goes, its neighbours refitted), plain?: bool (false: true deletes them without refitting the handles beside them), ids?: [id…] (default: the selection)} remove the anchors of the paths under the objects that the brush passes over and that can go while the outline stays within the tolerance, as one undo step; open ends stay → {ids, removed}",
|
||||
has_selection,
|
||||
erase
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.extend",
|
||||
"Extend Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step → {id, length (pt)}",
|
||||
has_doc,
|
||||
extend
|
||||
),
|
||||
cmd!(
|
||||
query "addon.pathtools.inspect",
|
||||
"Measure & Inspect",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, tolerance?: pt (3; how near an anchor counts as over it)} what the path reads at the point nearest (x, y): {subpath, closed, anchors, length, area (pt², an open subpath closed by a straight line), segment, t, point: [x, y], segmentLength, tangent (degrees counter-clockwise), radius (of curvature, null where straight), anchor?: {index, x, y, smooth, angle (at the anchor, null at an open end), in?: {length, angle}, out?: {length, angle}}}",
|
||||
has_doc,
|
||||
inspect
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.setAnchor",
|
||||
"Set Anchor",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set → {anchors}",
|
||||
has_selection,
|
||||
set_anchor
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.smooth",
|
||||
"Make Smooth",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{ratio?: 0.1..0.5 (0.3905: a square's corners make a circle)} make the selected anchors smooth: a lone handle is mirrored; without handles, both stand at right angles to the bisector of the angle at the anchor, ratio × the nearer neighbour's distance long; two handles are put in line; an open end without handles aims at its neighbour's handle. One undo step → {anchors (changed)}",
|
||||
has_selection,
|
||||
smooth
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.corner",
|
||||
"Make Corner",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{retract?: bool (true: the handles go; false: they stay where they are and move apart)} make the selected anchors corners, as one undo step → {anchors (changed)}",
|
||||
has_selection,
|
||||
corner
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.equalize",
|
||||
"Equalize Handles",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{} give each selected anchor's two handles their average length (a missing one is made opposite the other), as one undo step → {anchors (changed)}",
|
||||
has_selection,
|
||||
equalize
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.removeKeepShape",
|
||||
"Remove Points, Keep Shape",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors, refitting the handles beside each so the outline keeps its shape as best it can, as one undo step; open ends stay → {removed}",
|
||||
has_selection,
|
||||
remove_keep_shape
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.addPoints",
|
||||
"Add Points to Segments…",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{count?: 1..100 (1)} add count anchors to each selected segment (a segment between two selected anchors, or every segment of a path selected whole), splitting it into pieces of equal length; the shape doesn't change. One undo step → {added}",
|
||||
has_selection,
|
||||
add_points
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.close",
|
||||
"Close Path",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{mode?: \"handles\" (keep the ends' handles)|\"straight\" (a straight closing segment)|\"smooth\" (the ends made smooth), ratio?: 0.1..0.5 (0.3905, for smooth)} close the open subpaths of the selected paths, as one undo step → {closed}",
|
||||
has_selection,
|
||||
close
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.crossings",
|
||||
"Add Points at Crossings",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}",
|
||||
has_selection,
|
||||
crossings
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The editable paths (not guides) under `roots`, compound path members included.
|
||||
fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
let editable = d.editable();
|
||||
let mut out = vec![];
|
||||
for n in roots.iter().filter_map(|id| d.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: false, .. }) && editable(c.id) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Put `np` in path `id`'s place; its live shape goes (its anchors are edited).
|
||||
fn set_path(d: &mut Document, id: NodeId, np: PathData) {
|
||||
if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
*path = np;
|
||||
*live = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Error unless the paths together have at most [`MAX_ANCHORS`] anchors.
|
||||
fn check_size<'a>(paths: impl IntoIterator<Item = &'a PathData>, cmd: &str) -> Result<()> {
|
||||
if paths.into_iter().map(PathData::anchor_count).sum::<usize>() > MAX_ANCHORS {
|
||||
return Err(bad(cmd, "the paths have too many anchors to edit at once"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Anchor Eraser Brush ----------
|
||||
|
||||
/// The brush's points (with the radius each has), from `points`.
|
||||
fn stroke(p: &Value, radius: f64, cmd: &str) -> Result<Vec<(Point, f64)>> {
|
||||
let list = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points"))?;
|
||||
if list.len() > MAX_STROKE_POINTS {
|
||||
return Err(bad(cmd, "too many `points` (at most 100000)"));
|
||||
}
|
||||
list.iter()
|
||||
.map(|v| {
|
||||
let point = |[x, y]: [f64; 2], pressure: f64| (Point::new(x, y), (radius * pressure.clamp(0.0, 1.0)).max(MIN_RADIUS));
|
||||
finite_numbers::<2>(v)
|
||||
.map(|xy| point(xy, 1.0))
|
||||
.or_else(|| finite_numbers::<3>(v).map(|[x, y, f]| point([x, y], f)))
|
||||
.ok_or_else(|| bad(cmd, format!("bad point {v}: expected [x, y] or [x, y, pressure]")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Does the brush, sweeping through `pts`, cover `q`?
|
||||
fn under_brush(pts: &[(Point, f64)], q: Point) -> bool {
|
||||
let hit = |(a, ra): (Point, f64), (b, rb): (Point, f64)| {
|
||||
let ab = b - a;
|
||||
let len2 = ab.hypot2();
|
||||
let t = if len2 > 0.0 { ((q - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
q.distance(a + ab * t) <= ra + (rb - ra) * t
|
||||
};
|
||||
match pts {
|
||||
[only] => hit(*only, *only),
|
||||
_ => pts.windows(2).any(|w| matches!(w, [a, b] if hit(*a, *b))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchors of `sp` that go when the brush passes over those marked, removed without refitting
|
||||
/// the handles beside them: open ends stay, and the subpath keeps two anchors (three closed).
|
||||
fn delete_marked(sp: &SubPath, marked: &[bool]) -> SubPath {
|
||||
let n = sp.anchors.len();
|
||||
let min = if sp.closed { 3 } else { 2 };
|
||||
let mut left = n.saturating_sub(min);
|
||||
let mut out = sp.clone();
|
||||
let keep: Vec<bool> = (0..n)
|
||||
.map(|i| {
|
||||
let end = !sp.closed && (i == 0 || i + 1 == n);
|
||||
let go = !end && left > 0 && marked.get(i).copied().unwrap_or(false);
|
||||
left -= usize::from(go);
|
||||
!go
|
||||
})
|
||||
.collect();
|
||||
let mut it = keep.iter();
|
||||
out.anchors.retain(|_| it.next().copied().unwrap_or(true));
|
||||
out
|
||||
}
|
||||
|
||||
fn erase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.erase";
|
||||
let diameter = number_or(p, "diameter", 20.0, C)?;
|
||||
if !(0.1..=MAX_DIAMETER).contains(&diameter) {
|
||||
return Err(bad(C, "`diameter` must be from 0.1 to 2500 pt"));
|
||||
}
|
||||
let tolerance = number_or(p, "tolerance", ERASE_TOLERANCE, C)?;
|
||||
if !(1.0..=100.0).contains(&tolerance) {
|
||||
return Err(bad(C, "`tolerance` must be from 1 to 100"));
|
||||
}
|
||||
let (ignore, plain) = (switch_or(p, "ignoreTolerance", false, C)?, switch_or(p, "plain", false, C)?);
|
||||
let pts = stroke(p, diameter / 2.0, C)?;
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = paths_under(d, &roots);
|
||||
let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect();
|
||||
check_size(paths.iter().map(|(_, p)| *p), C)?;
|
||||
let tol = if ignore { f64::INFINITY } else { tolerance * PT_PER_TOLERANCE };
|
||||
let mut removed = 0;
|
||||
let mut changed = vec![];
|
||||
for (id, path) in paths {
|
||||
let subs: Vec<SubPath> = path
|
||||
.subpaths
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
let marked: Vec<bool> = sp.anchors.iter().map(|a| !pts.is_empty() && under_brush(&pts, a.p)).collect();
|
||||
match marked.contains(&true) {
|
||||
false => sp.clone(),
|
||||
true if plain => delete_marked(sp, &marked),
|
||||
true => po::simplify_anchors_keep_shape(sp, tol, &marked),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let np = PathData::new(subs);
|
||||
if np != *path {
|
||||
removed += path.anchor_count().saturating_sub(np.anchor_count());
|
||||
changed.push((id, np));
|
||||
}
|
||||
}
|
||||
edit_whole(s, "Anchor Eraser Brush", changed)?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "removed": removed }))
|
||||
}
|
||||
|
||||
/// Replace paths with the new data given, as one step (none when there is nothing to change); each
|
||||
/// is selected whole, as its anchors' indexes changed.
|
||||
fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) -> Result<()> {
|
||||
if changed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
s.edit(label, |d, sel| {
|
||||
for (id, np) in changed {
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
set_path(d, id, np);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Extend Path ----------
|
||||
|
||||
/// The open subpath an `extend` request names: (path id, subpath index, its data).
|
||||
fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
|
||||
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?;
|
||||
let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0);
|
||||
let d = &s.doc()?.doc;
|
||||
if !d.is_editable(id) {
|
||||
return Err(bad(cmd, "the path is locked or hidden"));
|
||||
}
|
||||
let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?;
|
||||
let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?;
|
||||
if sp.closed || sp.anchors.len() < 2 {
|
||||
return Err(bad(cmd, "the subpath has no ends to extend (it is closed or a single point)"));
|
||||
}
|
||||
Ok((id, si, sp.clone()))
|
||||
}
|
||||
|
||||
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.extend";
|
||||
let (id, si, sp) = open_subpath(s, p, C)?;
|
||||
let at_start = match text_param(p, "end", C)? {
|
||||
Some("start") => true,
|
||||
Some("end") => false,
|
||||
_ => return Err(bad(C, "`end` must be \"start\" or \"end\"")),
|
||||
};
|
||||
let mode = match text_param(p, "mode", C)? {
|
||||
None => pt::ExtendMode::Straight,
|
||||
Some(m) => pt::ExtendMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"straight\", \"bezier\" or \"arc\""))?,
|
||||
};
|
||||
let length = match (p.get("to").filter(|v| !v.is_null()), p.get("length").filter(|v| !v.is_null())) {
|
||||
(Some(to), None) => {
|
||||
let [x, y] = finite_numbers::<2>(to).ok_or_else(|| bad(C, "`to` must be [x, y]"))?;
|
||||
pt::extend_length_to(&sp, at_start, Point::new(x, y), mode).ok_or_else(|| bad(C, "the path can't be extended"))?
|
||||
}
|
||||
(None, Some(_)) => number_or(p, "length", 0.0, C)?,
|
||||
_ => return Err(bad(C, "give `length` or `to`")),
|
||||
};
|
||||
if !length.is_finite() || length.abs() > MAX_EXTEND {
|
||||
return Err(bad(C, "`length` must be a number of at most 1000000 pt either way"));
|
||||
}
|
||||
let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?;
|
||||
if new != sp {
|
||||
s.edit("Extend Path", |d, sel| {
|
||||
let n = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let mut np = n;
|
||||
if let Some(slot) = np.subpaths.get_mut(si) {
|
||||
*slot = new;
|
||||
}
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
set_path(d, id, np);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "id": id.0, "length": length }))
|
||||
}
|
||||
|
||||
// ---------- Measure & Inspect ----------
|
||||
|
||||
fn polar(h: Option<(f64, f64)>) -> Value {
|
||||
h.map_or(Value::Null, |(length, angle)| json!({ "length": length, "angle": angle }))
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.inspect";
|
||||
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
|
||||
let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?);
|
||||
let tolerance = number_or(p, "tolerance", 3.0, C)?;
|
||||
if !(x.is_finite() && y.is_finite()) || !(tolerance >= 0.0 && tolerance.is_finite()) {
|
||||
return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)"));
|
||||
}
|
||||
let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?;
|
||||
let i = pt::inspect(path, Point::new(x, y), tolerance).ok_or_else(|| bad(C, "the path has no segments to measure"))?;
|
||||
let anchor = i.anchor.map(|a| {
|
||||
json!({ "index": a.index, "x": a.p.x, "y": a.p.y, "smooth": a.smooth, "angle": a.angle, "in": polar(a.handle_in), "out": polar(a.handle_out) })
|
||||
});
|
||||
Ok(json!({
|
||||
"subpath": i.subpath, "closed": i.closed, "anchors": i.anchors, "length": i.length, "area": i.area,
|
||||
"segment": i.segment, "t": i.t, "point": [i.point.x, i.point.y], "segmentLength": i.segment_length,
|
||||
"tangent": i.tangent, "radius": i.radius, "anchor": anchor,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Anchor Inspector ----------
|
||||
|
||||
/// The anchors the Anchor Inspector acts on, per path: each path's direct-selected anchors, or
|
||||
/// every anchor of a path selected whole (with `whole`), keyed by subpath.
|
||||
type Picked = Vec<(NodeId, BTreeMap<usize, BTreeSet<usize>>)>;
|
||||
|
||||
/// The selected anchors ([`Picked`]); `whole` counts the anchors of paths selected whole.
|
||||
fn picked_anchors(s: &Session, whole: bool) -> Result<Picked> {
|
||||
let st = s.doc()?;
|
||||
let (d, sel) = (&st.doc, &st.selection);
|
||||
let roots = super::super::edit::selected_roots(s)?;
|
||||
let mut out = vec![];
|
||||
for id in paths_under(d, &roots) {
|
||||
let Some(path) = d.node(id).and_then(|n| n.path_data()) else { continue };
|
||||
let refs: Vec<AnchorRef> = match sel.anchors.get(&id).filter(|a| !a.is_empty()) {
|
||||
Some(set) => set.iter().copied().collect(),
|
||||
None if whole => path.anchors().map(|(si, ai, _)| (si, ai)).collect(),
|
||||
None => continue,
|
||||
};
|
||||
let mut by_sub: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
|
||||
for (si, ai) in refs.into_iter().filter(|(si, ai)| path.subpaths.get(*si).is_some_and(|sp| *ai < sp.anchors.len())) {
|
||||
by_sub.entry(si).or_default().insert(ai);
|
||||
}
|
||||
if !by_sub.is_empty() {
|
||||
out.push((id, by_sub));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Edit each selected anchor ([`picked_anchors`]) in place with `f` (true when it changed one), as
|
||||
/// one step labelled `label`; the selection stays. → how many anchors changed.
|
||||
fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath, usize) -> bool) -> Result<usize> {
|
||||
let picked = picked_anchors(s, true)?;
|
||||
if picked.is_empty() {
|
||||
return Err(bad(cmd, "select paths or anchor points"));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), cmd)?;
|
||||
let mut count = 0;
|
||||
let mut changed = vec![];
|
||||
for (id, by_sub) in &picked {
|
||||
let Some(mut np) = d.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
|
||||
let before = count;
|
||||
for (si, anchors) in by_sub {
|
||||
let Some(sp) = np.subpaths.get_mut(*si) else { continue };
|
||||
count += anchors.iter().filter(|&&i| f(sp, i)).count();
|
||||
}
|
||||
if count > before {
|
||||
changed.push((*id, np));
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
s.edit(label, |d, _| {
|
||||
for (id, np) in changed {
|
||||
set_path(d, id, np);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// A smoothing `ratio` param (default [`pt::SMOOTH_RATIO`]).
|
||||
fn ratio(p: &Value, cmd: &str) -> Result<f64> {
|
||||
let r = number_or(p, "ratio", pt::SMOOTH_RATIO, cmd)?;
|
||||
let (lo, hi) = pt::SMOOTH_RATIO_RANGE;
|
||||
if !(lo..=hi).contains(&r) {
|
||||
return Err(bad(cmd, "`ratio` must be from 0.1 to 0.5"));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.smooth";
|
||||
let r = ratio(p, C)?;
|
||||
let n = edit_anchors(s, "Make Smooth", C, |sp, i| pt::auto_smooth(sp, i, r))?;
|
||||
Ok(json!({ "anchors": n }))
|
||||
}
|
||||
|
||||
fn corner(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.corner";
|
||||
let retract = switch_or(p, "retract", true, C)?;
|
||||
let n = edit_anchors(s, "Make Corner", C, |sp, i| pt::make_corner(sp, i, retract))?;
|
||||
Ok(json!({ "anchors": n }))
|
||||
}
|
||||
|
||||
fn equalize(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.equalize";
|
||||
let n = edit_anchors(s, "Equalize Handles", C, pt::equalize_handles)?;
|
||||
Ok(json!({ "anchors": n }))
|
||||
}
|
||||
|
||||
/// A number param that is given, finite, and (with `range`) inside it.
|
||||
fn given(p: &Value, key: &str, range: Option<(f64, f64)>, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?;
|
||||
match range {
|
||||
Some((lo, hi)) if !(lo..=hi).contains(&x) => Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))),
|
||||
_ => Ok(Some(x)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.setAnchor";
|
||||
const LIMIT: Option<(f64, f64)> = Some((-1e7, 1e7));
|
||||
let (x, y) = (given(p, "x", LIMIT, C)?, given(p, "y", LIMIT, C)?);
|
||||
let lengths = [given(p, "inLength", Some((0.0, 1e7)), C)?, given(p, "outLength", Some((0.0, 1e7)), C)?];
|
||||
let angles = [given(p, "inAngle", LIMIT, C)?, given(p, "outAngle", LIMIT, C)?];
|
||||
if [x, y].iter().chain(&lengths).chain(&angles).all(Option::is_none) {
|
||||
return Err(bad(C, "give x, y, or a handle's length or angle"));
|
||||
}
|
||||
let n = edit_anchors(s, "Set Anchor", C, |sp, i| {
|
||||
let Some(a) = sp.anchors.get_mut(i) else { return false };
|
||||
let before = *a;
|
||||
let to = Point::new(x.unwrap_or(a.p.x), y.unwrap_or(a.p.y));
|
||||
a.translate(to - a.p);
|
||||
for (out, (len, ang)) in [false, true].into_iter().zip(lengths.into_iter().zip(angles)) {
|
||||
if len.is_none() && ang.is_none() {
|
||||
continue;
|
||||
}
|
||||
let h = if out { a.h_out } else { a.h_in };
|
||||
let (l0, a0) = pt::handle_polar(a.p, h).unwrap_or((0.0, if out { 0.0 } else { 180.0 }));
|
||||
let (l, deg) = (len.unwrap_or(l0), ang.unwrap_or(a0));
|
||||
// A length alone keeps a smooth anchor's other handle where it is; an angle turns it too.
|
||||
let other = if out { a.h_in } else { a.h_out };
|
||||
a.set_handle(out, pt::handle_at(a.p, l, deg), false);
|
||||
if ang.is_none() {
|
||||
if out { a.h_in = other } else { a.h_out = other }
|
||||
}
|
||||
}
|
||||
*a != before
|
||||
})?;
|
||||
Ok(json!({ "anchors": n }))
|
||||
}
|
||||
|
||||
fn remove_keep_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.removeKeepShape";
|
||||
let picked = picked_anchors(s, false)?;
|
||||
if picked.is_empty() {
|
||||
return Err(bad(C, "direct-select the anchor points to remove"));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?;
|
||||
let mut removed = 0;
|
||||
let mut changed = vec![];
|
||||
for (id, by_sub) in &picked {
|
||||
let Some(mut np) = d.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
|
||||
for (si, anchors) in by_sub {
|
||||
let Some(sp) = np.subpaths.get_mut(*si) else { continue };
|
||||
// From the last back, so the indexes left stay right.
|
||||
for &i in anchors.iter().rev() {
|
||||
if let Some((out, _)) = po::remove_anchor_keep_shape(sp, i) {
|
||||
*sp = out;
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if np != *d.node(*id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(*id))? {
|
||||
changed.push((*id, np));
|
||||
}
|
||||
}
|
||||
edit_whole(s, "Remove Points, Keep Shape", changed)?;
|
||||
Ok(json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
fn add_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.addPoints";
|
||||
let count = number_or(p, "count", 1.0, C)?;
|
||||
if !(1.0..=MAX_ADD as f64).contains(&count) || count.fract() != 0.0 {
|
||||
return Err(bad(C, "`count` must be a whole number from 1 to 100"));
|
||||
}
|
||||
let picked = picked_anchors(s, true)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?;
|
||||
let mut added = 0;
|
||||
let mut changed = vec![];
|
||||
for (id, by_sub) in &picked {
|
||||
let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
let picked_segments = st.selection.segments.get(id);
|
||||
let mut np = path.clone();
|
||||
for (si, sp) in np.subpaths.iter_mut().enumerate() {
|
||||
let Some(anchors) = by_sub.get(&si) else { continue };
|
||||
let n = sp.anchors.len();
|
||||
// Picked segments, else those between two selected anchors.
|
||||
let segs: BTreeSet<usize> = match picked_segments {
|
||||
Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(),
|
||||
None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n.max(1)))).collect(),
|
||||
};
|
||||
let (out, marks) = pt::add_points(sp, Some(&segs), count as usize);
|
||||
added += marks.len();
|
||||
*sp = out;
|
||||
}
|
||||
if np != *path {
|
||||
changed.push((*id, np));
|
||||
}
|
||||
}
|
||||
if added == 0 {
|
||||
return Err(bad(C, "select segments, or paths, to add points to"));
|
||||
}
|
||||
edit_whole(s, "Add Points to Segments", changed)?;
|
||||
Ok(json!({ "added": added }))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.close";
|
||||
let mode = match text_param(p, "mode", C)? {
|
||||
None => pt::CloseMode::Handles,
|
||||
Some(m) => pt::CloseMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"handles\", \"straight\" or \"smooth\""))?,
|
||||
};
|
||||
let r = ratio(p, C)?;
|
||||
let roots = super::super::edit::selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut closed = 0;
|
||||
let mut changed = vec![];
|
||||
for id in paths_under(d, &roots) {
|
||||
let Some(path) = d.node(id).and_then(|n| n.path_data()) else { continue };
|
||||
let mut np = path.clone();
|
||||
let n = np.subpaths.iter_mut().map(|sp| pt::close(sp, mode, r)).filter(|c| *c).count();
|
||||
if n > 0 {
|
||||
closed += n;
|
||||
changed.push((id, np));
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(bad(C, "select open paths to close"));
|
||||
}
|
||||
s.edit("Close Path", |d, _| {
|
||||
for (id, np) in changed {
|
||||
set_path(d, id, np);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "closed": closed }))
|
||||
}
|
||||
|
||||
// ---------- Split / Add Points at Crossings ----------
|
||||
|
||||
fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.crossings";
|
||||
let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?);
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = paths_under(d, &roots);
|
||||
let paths: Vec<&PathData> = ids.iter().filter_map(|id| d.node(*id)?.path_data()).collect();
|
||||
if paths.len() != ids.len() {
|
||||
return Err(EngineError::Other("Add Points at Crossings: a path went missing".into()));
|
||||
}
|
||||
let places = pt::crossings(&paths, self_crossings)
|
||||
.ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross (at most 10000 segments)"))?;
|
||||
let mut count = 0;
|
||||
// Each path that is crossed: its new subpaths, and the anchors at the crossings on each.
|
||||
let mut changed: Vec<(NodeId, Vec<SubPath>, Vec<BTreeSet<usize>>)> = vec![];
|
||||
for ((id, path), on) in ids.iter().zip(&paths).zip(places) {
|
||||
if on.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut subs = vec![];
|
||||
let mut marks = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
|
||||
let (out, m) = pt::insert_at(sp, &here);
|
||||
count += m.len();
|
||||
subs.push(out);
|
||||
marks.push(m);
|
||||
}
|
||||
changed.push((*id, subs, marks));
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Ok(json!({ "ids": [], "crossings": 0 }));
|
||||
}
|
||||
let label = if cut { "Split at Crossings" } else { "Add Points at Crossings" };
|
||||
let out = s.edit(label, |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut beside = Beside::replacing(d, &changed.iter().map(|(id, ..)| *id).collect::<Vec<_>>());
|
||||
for (id, subs, marks) in changed {
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
if !cut {
|
||||
set_path(d, id, PathData::new(subs));
|
||||
all.push(id);
|
||||
continue;
|
||||
}
|
||||
let pieces: Vec<SubPath> = subs.iter().zip(&marks).flat_map(|(sp, m)| sp.cut_at(m)).collect();
|
||||
// A path of one subpath becomes a path per piece; a compound shape's pieces stay its subpaths.
|
||||
if subs.len() == 1 {
|
||||
let made = replace_with_pieces(d, &mut beside, id, pieces.into_iter().map(PathData::single).collect())?;
|
||||
for n in &made {
|
||||
if !sel.contains(*n) {
|
||||
sel.add(*n);
|
||||
}
|
||||
}
|
||||
all.extend(made);
|
||||
} else {
|
||||
set_path(d, id, PathData::new(pieces));
|
||||
all.push(id);
|
||||
}
|
||||
}
|
||||
beside.finish(d)?;
|
||||
Ok(all)
|
||||
})?;
|
||||
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>(), "crossings": count }))
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Path Precision Tools' commands: each one undo step, bad params refused, and the tools' gestures
|
||||
//! through the session.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::{Session, ViewInfo};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
|
||||
}
|
||||
|
||||
/// A circle of 16 anchors about (100, 100), radius 50, selected.
|
||||
fn circle(s: &mut Session) -> NodeId {
|
||||
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
||||
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn path(s: &Session, id: NodeId) -> PathData {
|
||||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
|
||||
}
|
||||
|
||||
/// Direct-select anchors `anchors` (of subpath 0) of `id`.
|
||||
fn select_anchors(s: &mut Session, id: NodeId, anchors: &[usize]) {
|
||||
s.select(|_, sel| {
|
||||
sel.set([id]);
|
||||
sel.anchors.insert(id, anchors.iter().map(|a| (0, *a)).collect::<BTreeSet<_>>());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Each of `bad` is refused and leaves the document as it was.
|
||||
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
|
||||
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
|
||||
for p in bad {
|
||||
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
|
||||
}
|
||||
assert_eq!(steps(s), n, "{cmd}");
|
||||
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_brush_removes_anchors_under_it_keeping_the_shape() {
|
||||
let mut s = session();
|
||||
let e = circle(&mut s);
|
||||
assert_eq!(path(&s, e).anchor_count(), 16);
|
||||
let bounds = path(&s, e).bounds().unwrap();
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "tolerance": 100})).unwrap();
|
||||
let left = path(&s, e).anchor_count();
|
||||
assert!(left < 16 && left > 8, "{left}");
|
||||
assert_eq!(r["removed"], json!(16 - left));
|
||||
assert_eq!(steps(&s), n + 1, "one undo step");
|
||||
let b = path(&s, e).bounds().unwrap();
|
||||
assert!((b.x1 - bounds.x1).abs() < 2.5 && (b.y0 - bounds.y0).abs() < 0.01, "{b:?} vs {bounds:?}");
|
||||
// The anchors away from the brush stay: those on the left of the circle.
|
||||
assert!(path(&s, e).anchors().filter(|(_, _, a)| a.p.x < 100.0).count() >= 7);
|
||||
// A plain delete takes every anchor under it, whatever the shape.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "plain": true})).unwrap();
|
||||
assert!(path(&s, e).anchors().all(|(_, _, a)| a.p.x < 121.0));
|
||||
// Nothing under the brush: no step.
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.erase", &json!({"points": [[390, 290]], "diameter": 5})).unwrap();
|
||||
assert_eq!(steps(&s), n);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.erase",
|
||||
&[
|
||||
json!({}),
|
||||
json!({"points": "x"}),
|
||||
json!({"points": [[1, "y"]]}),
|
||||
json!({"points": [[100, 100]], "diameter": 0}),
|
||||
json!({"points": [[100, 100]], "diameter": 3000}),
|
||||
json!({"points": [[100, 100]], "tolerance": 0}),
|
||||
json!({"points": [[100, 100]], "plain": "yes"}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_brush_tool_is_one_undo_step_per_stroke() {
|
||||
let mut s = session();
|
||||
let e = circle(&mut s);
|
||||
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("anchorEraserBrush", view).unwrap();
|
||||
assert_eq!(s.tool_id(), "anchorEraserBrush");
|
||||
s.set_tool_option("size", &json!(60));
|
||||
s.set_tool_option("tolerance", &json!(100));
|
||||
let n = steps(&s);
|
||||
for (k, y) in
|
||||
[(PointerKind::Down, 40.0), (PointerKind::Drag, 80.0), (PointerKind::Drag, 120.0), (PointerKind::Drag, 160.0), (PointerKind::Up, 160.0)]
|
||||
{
|
||||
s.pointer(&PointerEvent::new(k, 150.0, y), view).unwrap();
|
||||
}
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert!(path(&s, e).anchor_count() < 16);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, e).anchor_count(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_lengthens_and_shortens_an_end() {
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(0.0, 100.0), (100.0, 100.0)], false);
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": 50})).unwrap();
|
||||
assert_eq!(path(&s, l).subpaths[0].anchors.last().unwrap().p, Point::new(150.0, 100.0));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "start", "to": [-30, 120], "mode": "bezier"})).unwrap();
|
||||
assert!((path(&s, l).subpaths[0].anchors[0].p.x + 30.0).abs() < 1e-6);
|
||||
let r = s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": -60})).unwrap();
|
||||
assert_eq!(r["length"], json!(-60.0));
|
||||
assert!((path(&s, l).length() - 120.0).abs() < 1e-3, "{}", path(&s, l).length());
|
||||
assert_eq!(steps(&s), n + 3);
|
||||
let sq = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.extend",
|
||||
&[
|
||||
json!({"id": l.0, "end": "end"}),
|
||||
json!({"id": l.0, "end": "middle", "length": 5}),
|
||||
json!({"id": l.0, "end": "end", "length": 5, "to": [1, 1]}),
|
||||
json!({"id": l.0, "end": "end", "length": 5, "mode": "spiral"}),
|
||||
json!({"id": l.0, "end": "end", "length": -1000}),
|
||||
json!({"id": l.0, "end": "end", "length": 1e9}),
|
||||
json!({"id": l.0, "end": "end", "to": [1]}),
|
||||
json!({"id": l.0, "subpath": 3, "end": "end", "length": 5}),
|
||||
json!({"id": sq.0, "end": "end", "length": 5}),
|
||||
json!({"id": 99999, "end": "end", "length": 5}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reads_the_path_under_a_point() {
|
||||
let mut s = session();
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap());
|
||||
let v = s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 99, "y": 1})).unwrap();
|
||||
assert_eq!((v["closed"].clone(), v["anchors"].clone()), (json!(true), json!(4)));
|
||||
assert!((v["length"].as_f64().unwrap() - 300.0).abs() < 1e-6 && (v["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6);
|
||||
assert!((v["anchor"]["angle"].as_f64().unwrap() - 90.0).abs() < 1e-9);
|
||||
assert!(v["radius"].is_null());
|
||||
assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1})).is_err());
|
||||
assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1, "y": 1, "tolerance": -1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_corner_and_equalize_edit_the_selected_anchors_in_one_step_each() {
|
||||
let mut s = session();
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
select(&mut s, &[r]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.smooth", &json!({})).unwrap()["anchors"], json!(4));
|
||||
assert!(path(&s, r).anchors().all(|(_, _, a)| a.kind == AnchorKind::Smooth && (a.h_out - a.p).hypot() > 39.0));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// Only the direct-selected anchor.
|
||||
select_anchors(&mut s, r, &[0]);
|
||||
s.execute("addon.pathtools.corner", &json!({})).unwrap();
|
||||
let p = path(&s, r);
|
||||
assert!(!p.subpaths[0].anchors[0].has_out() && p.subpaths[0].anchors[1].has_out());
|
||||
assert_eq!(steps(&s), n + 2);
|
||||
// Equalise: one handle made to match the other.
|
||||
select_anchors(&mut s, r, &[1]);
|
||||
s.execute("addon.pathtools.setAnchor", &json!({"outLength": 10})).unwrap();
|
||||
let a = path(&s, r).subpaths[0].anchors[1];
|
||||
assert!(((a.h_out - a.p).hypot() - 10.0).abs() < 1e-9 && (a.h_in - a.p).hypot() > 39.0);
|
||||
s.execute("addon.pathtools.equalize", &json!({})).unwrap();
|
||||
let a = path(&s, r).subpaths[0].anchors[1];
|
||||
assert!(((a.h_out - a.p).hypot() - (a.h_in - a.p).hypot()).abs() < 1e-9);
|
||||
// A selection with nothing to change makes no step.
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.equalize", &json!({})).unwrap()["anchors"], json!(0));
|
||||
assert_eq!(steps(&s), n);
|
||||
refuses(&mut s, "addon.pathtools.smooth", &[json!({"ratio": 0.9}), json!({"ratio": "x"})]);
|
||||
refuses(&mut s, "addon.pathtools.corner", &[json!({"retract": 1})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_moves_anchors_and_sets_handles() {
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(0.0, 0.0), (50.0, 0.0), (100.0, 0.0)], false);
|
||||
select_anchors(&mut s, l, &[1]);
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.setAnchor", &json!({"x": 60, "y": 20, "outLength": 30, "outAngle": 90})).unwrap();
|
||||
let a = path(&s, l).subpaths[0].anchors[1];
|
||||
assert_eq!(a.p, Point::new(60.0, 20.0));
|
||||
assert!(a.h_out.distance(Point::new(60.0, -10.0)) < 1e-9, "{a:?}");
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
refuses(&mut s, "addon.pathtools.setAnchor", &[json!({}), json!({"x": "1"}), json!({"inLength": -2}), json!({"y": f64::MAX})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_keep_shape_takes_the_direct_selected_anchors() {
|
||||
let mut s = session();
|
||||
let e = circle(&mut s);
|
||||
// Selected whole: nothing to say which anchors go.
|
||||
refuses(&mut s, "addon.pathtools.removeKeepShape", &[json!({})]);
|
||||
select_anchors(&mut s, e, &[1, 5]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.removeKeepShape", &json!({})).unwrap()["removed"], json!(2));
|
||||
assert_eq!(path(&s, e).anchor_count(), 14);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_points_splits_selected_segments_evenly() {
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
|
||||
select(&mut s, &[l]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({"count": 3})).unwrap()["added"], json!(3));
|
||||
let xs: Vec<f64> = path(&s, l).anchors().map(|(_, _, a)| a.p.x).collect();
|
||||
assert!(xs.iter().zip([0.0, 25.0, 50.0, 75.0, 100.0]).all(|(x, w)| (x - w).abs() < 1e-3), "{xs:?}");
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
refuses(&mut s, "addon.pathtools.addPoints", &[json!({"count": 0}), json!({"count": 101}), json!({"count": 1.5})]);
|
||||
// An anchor alone selects no segment.
|
||||
select_anchors(&mut s, l, &[1]);
|
||||
refuses(&mut s, "addon.pathtools.addPoints", &[json!({})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_joins_open_ends() {
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (50.0, 80.0)], false);
|
||||
select(&mut s, &[l]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.close", &json!({"mode": "smooth"})).unwrap()["closed"], json!(1));
|
||||
assert!(path(&s, l).subpaths[0].closed);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
refuses(&mut s, "addon.pathtools.close", &[json!({}), json!({"mode": "round"})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_add_anchors_or_cut_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, &[(0.0, 0.0), (100.0, 100.0)], false);
|
||||
let b = line(&mut s, &[(0.0, 100.0), (100.0, 0.0)], false);
|
||||
select(&mut s, &[a, b]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(2));
|
||||
assert_eq!((path(&s, a).anchor_count(), path(&s, b).anchor_count()), (3, 3));
|
||||
assert!(path(&s, a).subpaths[0].anchors[1].p.distance(Point::new(50.0, 50.0)) < 1e-6);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let r = s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 4, "{r}");
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
|
||||
}
|
||||
@@ -4,11 +4,14 @@
|
||||
//! its shape within a tolerance (shared by every add-on that takes anchors away).
|
||||
//! - [`cleanup`]: Artwork Cleanup's path repairs (collinear, duplicate and near anchors, tiny
|
||||
//! handles, nearly closed paths) and Rejoin Path Pieces.
|
||||
//! - [`pathtools`]: Path Precision Tools' anchor edits, path readouts, extending open paths and
|
||||
//! finding where paths cross.
|
||||
|
||||
pub mod cleanup;
|
||||
pub mod keep_shape;
|
||||
pub mod pathtools;
|
||||
|
||||
pub use cleanup::{
|
||||
Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles,
|
||||
};
|
||||
pub use keep_shape::{SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_keep_shape};
|
||||
pub use keep_shape::{SHARP_CORNER_DEG, remove_anchor_keep_shape, simplify_anchors_keep_shape, simplify_keep_shape};
|
||||
|
||||
@@ -113,6 +113,18 @@ pub fn simplify_keep_shape(path: &PathData, tolerance: f64, protect_corners: boo
|
||||
PathData::new(path.subpaths.iter().map(|sp| Reducer::new(sp, protect_corners).run(tolerance)).collect())
|
||||
}
|
||||
|
||||
/// [`simplify_keep_shape`] on subpath `sp` where only the anchors `may_go` marks (one flag per
|
||||
/// anchor, by index) may go: what the Anchor Eraser Brush passes over. Open ends always stay. An
|
||||
/// infinite tolerance removes every marked anchor that can go.
|
||||
pub fn simplify_anchors_keep_shape(sp: &SubPath, tolerance: f64, may_go: &[bool]) -> SubPath {
|
||||
if tolerance.is_nan() || tolerance <= 0.0 {
|
||||
return sp.clone();
|
||||
}
|
||||
let mut r = Reducer::new(sp, false);
|
||||
r.kept = (0..sp.anchors.len()).map(|i| !may_go.get(i).copied().unwrap_or(false)).collect();
|
||||
r.run(tolerance)
|
||||
}
|
||||
|
||||
/// One subpath being reduced: what is left of it, and where its anchors were in the original.
|
||||
struct Reducer<'a> {
|
||||
original: &'a SubPath,
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Path Precision Tools' geometry: anchor edits for the Anchor Inspector (smooth by the auto-smooth
|
||||
//! rule, equalise handles, add evenly spaced anchors, close), the readouts the Measure & Inspect
|
||||
//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]) and the places
|
||||
//! where paths cross ([`crossings`]).
|
||||
|
||||
mod crossings;
|
||||
mod extend;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Vec2};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, SubPath};
|
||||
|
||||
pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at};
|
||||
pub use extend::{ExtendMode, extend_length_to, extend_open};
|
||||
|
||||
use crate::fit::{end_tangent, start_tangent};
|
||||
|
||||
/// The auto-smooth rule's handle length as a share of the nearer neighbour's distance: the share
|
||||
/// that makes a square's four corners a circle.
|
||||
pub const SMOOTH_RATIO: f64 = 0.3905;
|
||||
/// The smallest and largest smoothing ratio.
|
||||
pub const SMOOTH_RATIO_RANGE: (f64, f64) = (0.1, 0.5);
|
||||
/// Most anchors [`add_points`] puts on one segment.
|
||||
pub const MAX_ADDED_PER_SEGMENT: usize = 100;
|
||||
|
||||
/// Arc-length accuracy (pt) of the measurements here.
|
||||
const ACCURACY: f64 = 1e-6;
|
||||
/// Shorter than this (pt) is no handle and no distance.
|
||||
const TINY: f64 = 1e-9;
|
||||
|
||||
fn unit(v: Vec2) -> Option<Vec2> {
|
||||
let l = v.hypot();
|
||||
(l > TINY && l.is_finite()).then(|| v / l)
|
||||
}
|
||||
|
||||
/// The indices of anchor `i`'s neighbours in `sp` (`None` past an open end).
|
||||
fn neighbours(sp: &SubPath, i: usize) -> (Option<usize>, Option<usize>) {
|
||||
let n = sp.anchors.len();
|
||||
let prev = if i > 0 { Some(i - 1) } else { (sp.closed && n > 1).then(|| n - 1) };
|
||||
let next = if i + 1 < n { Some(i + 1) } else { (sp.closed && n > 1).then_some(0) };
|
||||
(prev.filter(|&j| j != i), next.filter(|&j| j != i))
|
||||
}
|
||||
|
||||
/// Make anchor `i` smooth by the auto-smooth rule: a lone handle is mirrored at its length; with
|
||||
/// none, both handles stand at right angles to the bisector of the angle its neighbours make at
|
||||
/// it, each `ratio` × the nearer neighbour's distance long; two handles are brought into line,
|
||||
/// keeping their lengths. An open end without handles gets one aimed at its neighbour's facing
|
||||
/// handle. False when nothing changes or there is no direction to take.
|
||||
pub fn auto_smooth(sp: &mut SubPath, i: usize, ratio: f64) -> bool {
|
||||
let (prev, next) = neighbours(sp, i);
|
||||
let at = |j: Option<usize>| j.and_then(|j| sp.anchors.get(j)).copied();
|
||||
let (prev, next) = (at(prev), at(next));
|
||||
let Some(a) = sp.anchors.get_mut(i) else { return false };
|
||||
let before = *a;
|
||||
let p = a.p;
|
||||
match (a.has_in(), a.has_out()) {
|
||||
(true, true) => {
|
||||
let (Some(o), Some(n)) = (unit(a.h_out - p), unit(p - a.h_in)) else { return false };
|
||||
// Already in line: only the kind may change.
|
||||
if o.cross(n).abs() <= 1e-12 && o.dot(n) > 0.0 {
|
||||
a.kind = AnchorKind::Smooth;
|
||||
return *a != before;
|
||||
}
|
||||
let Some(d) = unit(o + n) else { return false };
|
||||
a.h_out = p + d * (a.h_out - p).hypot();
|
||||
a.h_in = p - d * (a.h_in - p).hypot();
|
||||
}
|
||||
(true, false) if next.is_some() => a.h_out = p - (a.h_in - p),
|
||||
(false, true) if prev.is_some() => a.h_in = p - (a.h_out - p),
|
||||
(false, false) => match (prev, next) {
|
||||
(Some(pr), Some(nx)) => {
|
||||
let (dp, dn) = (pr.p.distance(p), nx.p.distance(p));
|
||||
let (Some(up), Some(un)) = (unit(pr.p - p), unit(nx.p - p)) else { return false };
|
||||
let Some(dir) = unit(un - up) else { return false };
|
||||
let len = dp.min(dn) * ratio;
|
||||
a.h_out = p + dir * len;
|
||||
a.h_in = p - dir * len;
|
||||
}
|
||||
// An open end: aim at the neighbour's facing handle (or the neighbour itself).
|
||||
(None, Some(nx)) => {
|
||||
let Some(dir) = unit(nx.h_in - p) else { return false };
|
||||
a.h_out = p + dir * (nx.p.distance(p) * ratio);
|
||||
}
|
||||
(Some(pr), None) => {
|
||||
let Some(dir) = unit(pr.h_out - p) else { return false };
|
||||
a.h_in = p + dir * (pr.p.distance(p) * ratio);
|
||||
}
|
||||
(None, None) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
a.kind = if a.has_in() && a.has_out() { AnchorKind::Smooth } else { AnchorKind::Corner };
|
||||
*a != before
|
||||
}
|
||||
|
||||
/// Make anchor `i` a corner: its handles retracted with `retract`, else left where they are.
|
||||
/// False when nothing changes.
|
||||
pub fn make_corner(sp: &mut SubPath, i: usize, retract: bool) -> bool {
|
||||
let Some(a) = sp.anchors.get_mut(i) else { return false };
|
||||
let before = *a;
|
||||
if retract {
|
||||
a.retract();
|
||||
}
|
||||
a.kind = AnchorKind::Corner;
|
||||
*a != before
|
||||
}
|
||||
|
||||
/// Give anchor `i`'s two handles the same length, their average, each keeping its direction; a
|
||||
/// missing one (where there is a segment for it) is made opposite the other at its length. False
|
||||
/// when there is nothing to equalise.
|
||||
pub fn equalize_handles(sp: &mut SubPath, i: usize) -> bool {
|
||||
let (prev, next) = neighbours(sp, i);
|
||||
let Some(a) = sp.anchors.get_mut(i) else { return false };
|
||||
let before = *a;
|
||||
let p = a.p;
|
||||
match (a.has_in(), a.has_out()) {
|
||||
(true, true) => {
|
||||
let len = ((a.h_in - p).hypot() + (a.h_out - p).hypot()) / 2.0;
|
||||
let (Some(di), Some(dout)) = (unit(a.h_in - p), unit(a.h_out - p)) else { return false };
|
||||
a.h_in = p + di * len;
|
||||
a.h_out = p + dout * len;
|
||||
}
|
||||
(true, false) if next.is_some() => a.h_out = p - (a.h_in - p),
|
||||
(false, true) if prev.is_some() => a.h_in = p - (a.h_out - p),
|
||||
_ => return false,
|
||||
}
|
||||
*a != before
|
||||
}
|
||||
|
||||
/// `sp` with `count` anchors added on each of `segments` (all of them when `None`), splitting each
|
||||
/// into `count + 1` pieces of equal length; the shape doesn't change. → (the subpath, the indexes of
|
||||
/// the anchors added).
|
||||
pub fn add_points(sp: &SubPath, segments: Option<&BTreeSet<usize>>, count: usize) -> (SubPath, BTreeSet<usize>) {
|
||||
let count = count.min(MAX_ADDED_PER_SEGMENT);
|
||||
let mut hits = vec![];
|
||||
for seg in (0..sp.segment_count()).filter(|s| segments.is_none_or(|set| set.contains(s))) {
|
||||
let c = sp.segment(seg);
|
||||
let len = c.arclen(ACCURACY);
|
||||
if !(len > TINY && len.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
hits.extend((1..=count).map(|k| (seg, c.inv_arclen(len * k as f64 / (count + 1) as f64, ACCURACY))));
|
||||
}
|
||||
insert_at(sp, &hits)
|
||||
}
|
||||
|
||||
/// How [`close`] joins an open subpath's ends.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CloseMode {
|
||||
/// With the handles the ends already have.
|
||||
Handles,
|
||||
/// With a straight segment.
|
||||
Straight,
|
||||
/// With a segment that leaves both ends smoothly ([`auto_smooth`]).
|
||||
Smooth,
|
||||
}
|
||||
|
||||
impl CloseMode {
|
||||
pub const ALL: [Self; 3] = [Self::Handles, Self::Straight, Self::Smooth];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Handles => "handles",
|
||||
Self::Straight => "straight",
|
||||
Self::Smooth => "smooth",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.id() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Close open subpath `sp` (of two anchors or more) the way `mode` says. False when it is closed
|
||||
/// already or too short.
|
||||
pub fn close(sp: &mut SubPath, mode: CloseMode, ratio: f64) -> bool {
|
||||
if sp.closed || sp.anchors.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
if mode == CloseMode::Straight {
|
||||
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;
|
||||
}
|
||||
}
|
||||
sp.closed = true;
|
||||
if mode == CloseMode::Smooth {
|
||||
let last = sp.anchors.len() - 1;
|
||||
auto_smooth(sp, 0, ratio);
|
||||
auto_smooth(sp, last, ratio);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A handle's length (pt) and direction (degrees counter-clockwise on the page from the x axis);
|
||||
/// `None` for no handle.
|
||||
pub fn handle_polar(p: Point, h: Point) -> Option<(f64, f64)> {
|
||||
let v = h - p;
|
||||
let len = v.hypot();
|
||||
(len > TINY).then(|| (len, angle_of(v)))
|
||||
}
|
||||
|
||||
/// Direction of `v` in degrees counter-clockwise on the page (y points down), in (-180, 180].
|
||||
pub fn angle_of(v: Vec2) -> f64 {
|
||||
(-v.y).atan2(v.x).to_degrees() + 0.0
|
||||
}
|
||||
|
||||
/// Where a handle `len` long at `deg` degrees (counter-clockwise on the page) from `p` ends.
|
||||
pub fn handle_at(p: Point, len: f64, deg: f64) -> Point {
|
||||
let (s, c) = deg.to_radians().sin_cos();
|
||||
p + Vec2::new(c, -s) * len
|
||||
}
|
||||
|
||||
/// What the Measure & Inspect tool reads off a path at a point near it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Inspection {
|
||||
pub subpath: usize,
|
||||
pub closed: bool,
|
||||
pub anchors: usize,
|
||||
/// The subpath's length (pt).
|
||||
pub length: f64,
|
||||
/// The area the subpath encloses (pt², an open one closed by a straight line).
|
||||
pub area: f64,
|
||||
pub segment: usize,
|
||||
pub t: f64,
|
||||
/// The point on the path nearest the pointer.
|
||||
pub point: Point,
|
||||
pub segment_length: f64,
|
||||
/// The direction the path runs there, in degrees counter-clockwise on the page.
|
||||
pub tangent: f64,
|
||||
/// The radius of curvature there (pt); `None` where the path runs straight.
|
||||
pub radius: Option<f64>,
|
||||
/// The anchor near the pointer, when one is.
|
||||
pub anchor: Option<AnchorInspection>,
|
||||
}
|
||||
|
||||
/// An anchor's readouts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnchorInspection {
|
||||
pub index: usize,
|
||||
pub p: Point,
|
||||
pub smooth: bool,
|
||||
/// The angle the path makes at the anchor (degrees: 90 at a square's corner, 180 where it runs
|
||||
/// straight on); `None` at an open end.
|
||||
pub angle: Option<f64>,
|
||||
/// Each handle's (length, direction): `None` for no handle.
|
||||
pub handle_in: Option<(f64, f64)>,
|
||||
pub handle_out: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Radius of curvature of `c` at `t`: `None` where it runs straight or has no direction.
|
||||
fn radius_at(c: &CubicBez, t: f64) -> Option<f64> {
|
||||
use kurbo::ParamCurveDeriv;
|
||||
let d1 = c.deriv();
|
||||
let d = d1.eval(t).to_vec2();
|
||||
let dd = d1.deriv().eval(t).to_vec2();
|
||||
let speed = d.hypot();
|
||||
let k = d.cross(dd) / (speed * speed * speed);
|
||||
(speed > TINY && k.is_finite() && k.abs() > 1e-9).then(|| 1.0 / k.abs())
|
||||
}
|
||||
|
||||
/// The direction `c` runs at `t` (unit), falling back to its chord's ends where the derivative
|
||||
/// vanishes (a retracted handle).
|
||||
fn tangent_at(c: &CubicBez, t: f64) -> Vec2 {
|
||||
use kurbo::ParamCurveDeriv;
|
||||
unit(c.deriv().eval(t).to_vec2()).unwrap_or_else(|| if t < 0.5 { start_tangent(c) } else { end_tangent(c) })
|
||||
}
|
||||
|
||||
/// The readouts of anchor `i` of `sp`.
|
||||
pub fn inspect_anchor(sp: &SubPath, i: usize) -> Option<AnchorInspection> {
|
||||
let a = sp.anchors.get(i)?;
|
||||
let (prev, next) = neighbours(sp, i);
|
||||
let angle = match (prev, next) {
|
||||
(Some(pr), Some(_)) if sp.segment_count() > 0 => {
|
||||
let (into, out) = (end_tangent(&sp.segment(pr)), start_tangent(&sp.segment(i)));
|
||||
Some((-into).dot(out).clamp(-1.0, 1.0).acos().to_degrees())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(AnchorInspection {
|
||||
index: i,
|
||||
p: a.p,
|
||||
smooth: a.kind == AnchorKind::Smooth,
|
||||
angle,
|
||||
handle_in: handle_polar(a.p, a.h_in),
|
||||
handle_out: handle_polar(a.p, a.h_out),
|
||||
})
|
||||
}
|
||||
|
||||
/// What `path` reads at `p`: its point nearest `p` and the readouts there, and the anchor within
|
||||
/// `tol` of `p` if one is. `None` for a path with no segments or one with a non-finite coordinate.
|
||||
pub fn inspect(path: &PathData, p: Point, tol: f64) -> Option<Inspection> {
|
||||
let finite = |q: Point| q.x.is_finite() && q.y.is_finite();
|
||||
if !finite(p) || !path.anchors().all(|(_, _, a)| finite(a.p) && finite(a.h_in) && finite(a.h_out)) {
|
||||
return None;
|
||||
}
|
||||
let (si, seg, t, point, _) = path.nearest(p)?;
|
||||
let sp = path.subpaths.get(si)?;
|
||||
let c = sp.segment(seg);
|
||||
let anchor = [(seg, c.p0), ((seg + 1) % sp.anchors.len(), c.p3)]
|
||||
.into_iter()
|
||||
.filter(|(_, q)| q.distance(p) <= tol)
|
||||
.min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p)))
|
||||
.and_then(|(i, _)| inspect_anchor(sp, i));
|
||||
Some(Inspection {
|
||||
subpath: si,
|
||||
closed: sp.closed,
|
||||
anchors: sp.anchors.len(),
|
||||
length: (0..sp.segment_count()).map(|s| sp.segment(s).arclen(ACCURACY)).sum(),
|
||||
area: sp.signed_area().abs(),
|
||||
segment: seg,
|
||||
t,
|
||||
point,
|
||||
segment_length: c.arclen(ACCURACY),
|
||||
tangent: angle_of(tangent_at(&c, t)),
|
||||
radius: radius_at(&c, t),
|
||||
anchor,
|
||||
})
|
||||
}
|
||||
|
||||
/// The point of `sp`'s segments nearest `p`: (segment, t, distance).
|
||||
pub(crate) fn nearest_on(sp: &SubPath, p: Point) -> Option<(usize, f64, f64)> {
|
||||
(0..sp.segment_count())
|
||||
.map(|s| {
|
||||
let n = sp.segment(s).nearest(p, ACCURACY);
|
||||
(s, n.t, n.distance_sq.sqrt())
|
||||
})
|
||||
.min_by(|a, b| a.2.total_cmp(&b.2))
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Where paths cross: [`crossings`] finds the places, [`insert_at`] puts anchors there (which
|
||||
//! Split / Add Points at Crossings then keeps or cuts at).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
/// Most segments [`crossings`] compares at once.
|
||||
pub const MAX_CROSSING_SEGMENTS: usize = 10_000;
|
||||
/// Most subdivision steps one [`crossings`] call takes.
|
||||
const MAX_STEPS: usize = 4_000_000;
|
||||
/// A piece of curve whose inner control points lie this close (pt) to its chord counts as straight.
|
||||
const FLAT: f64 = 1e-4;
|
||||
/// Deepest a curve is halved.
|
||||
const MAX_DEPTH: u32 = 96;
|
||||
/// Crossings closer than this (pt) are one.
|
||||
const SAME: f64 = 1e-3;
|
||||
/// A parameter this close to a segment's end is its anchor.
|
||||
const END: f64 = 1e-6;
|
||||
|
||||
/// A place on a path: (subpath, segment, t).
|
||||
pub type Place = (usize, usize, f64);
|
||||
|
||||
fn control_box(c: &CubicBez) -> Rect {
|
||||
Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3)
|
||||
}
|
||||
|
||||
fn flat(c: &CubicBez) -> bool {
|
||||
let chord = c.p3 - c.p0;
|
||||
let len = chord.hypot();
|
||||
let off = |p: Point| if len > 1e-12 { chord.cross(p - c.p0).abs() / len } else { p.distance(c.p0) };
|
||||
off(c.p1) <= FLAT && off(c.p2) <= FLAT
|
||||
}
|
||||
|
||||
/// Where the chords of `a` and `b` cross: (s, u) along each, or `None` when they don't (parallel
|
||||
/// ones never do).
|
||||
fn chords_cross(a: &CubicBez, b: &CubicBez) -> Option<(f64, f64)> {
|
||||
let (da, db) = (a.p3 - a.p0, b.p3 - b.p0);
|
||||
let den = da.cross(db);
|
||||
if den.abs() <= 1e-12 * da.hypot() * db.hypot() || den == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let w = b.p0 - a.p0;
|
||||
let (s, u) = (w.cross(db) / den, w.cross(da) / den);
|
||||
let within = |v: f64| (-1e-9..=1.0 + 1e-9).contains(&v);
|
||||
(within(s) && within(u)).then(|| (s.clamp(0.0, 1.0), u.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// Where segments `a` and `b` cross: (t on `a`, t on `b`) pairs, halving both while their control
|
||||
/// boxes meet until each piece is straight. `None` once `budget` steps are spent.
|
||||
fn segment_crossings(a: &CubicBez, b: &CubicBez, budget: &mut usize) -> Option<Vec<(f64, f64)>> {
|
||||
let mut stack = vec![(0.0, 1.0, 0.0, 1.0, 0u32)];
|
||||
let mut found: Vec<(f64, f64)> = vec![];
|
||||
while let Some((a0, a1, b0, b1, depth)) = stack.pop() {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
let (ca, cb) = (a.subsegment(a0..a1), b.subsegment(b0..b1));
|
||||
if !control_box(&ca).inflate(SAME, SAME).overlaps(control_box(&cb)) {
|
||||
continue;
|
||||
}
|
||||
if (flat(&ca) && flat(&cb)) || depth >= MAX_DEPTH {
|
||||
if let Some((s, u)) = chords_cross(&ca, &cb) {
|
||||
// The pieces are straight but their parameters needn't run evenly along them.
|
||||
let x = ca.p0.lerp(ca.p3, s);
|
||||
let (ta, tb) = (ca.nearest(x, 1e-9).t, cb.nearest(cb.p0.lerp(cb.p3, u), 1e-9).t);
|
||||
found.push((a0 + ta * (a1 - a0), b0 + tb * (b1 - b0)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Halve the larger of the pieces still curved: two curves lying along each other then
|
||||
// split along their length, not into every pair of pieces.
|
||||
let size = |c: &CubicBez| if flat(c) { -1.0 } else { control_box(c).size().to_vec2().hypot() };
|
||||
let d = depth + 1;
|
||||
if size(&ca) >= size(&cb) {
|
||||
let am = (a0 + a1) / 2.0;
|
||||
stack.extend([(a0, am, b0, b1, d), (am, a1, b0, b1, d)]);
|
||||
} else {
|
||||
let bm = (b0 + b1) / 2.0;
|
||||
stack.extend([(a0, a1, b0, bm, d), (a0, a1, bm, b1, d)]);
|
||||
}
|
||||
}
|
||||
found.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
// The same crossing found by neighbouring pieces counts once.
|
||||
found.dedup_by(|x, y| a.eval(x.0).distance(a.eval(y.0)) < SAME && b.eval(x.1).distance(b.eval(y.1)) < SAME);
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// Do segments `i` and `j` of `sp` meet at an anchor they share?
|
||||
fn adjacent(sp: &SubPath, i: usize, j: usize) -> bool {
|
||||
let n = sp.segment_count();
|
||||
i.abs_diff(j) == 1 || (sp.closed && n > 1 && i.abs_diff(j) == n - 1)
|
||||
}
|
||||
|
||||
/// Where `paths` cross one another (and, with `self_crossings`, where each crosses itself): for
|
||||
/// each path, the places on it, in order. Segments that meet at an anchor they share don't count as
|
||||
/// crossing there. `None` past [`MAX_CROSSING_SEGMENTS`] segments or when the curves are too tangled
|
||||
/// to sort out, or when a coordinate isn't finite.
|
||||
pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option<Vec<Vec<Place>>> {
|
||||
let mut segs: Vec<(usize, usize, usize, CubicBez, Rect)> = vec![];
|
||||
for (k, path) in paths.iter().enumerate() {
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
for s in 0..sp.segment_count() {
|
||||
let c = sp.segment(s);
|
||||
if ![c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
segs.push((k, si, s, c, control_box(&c)));
|
||||
if segs.len() > MAX_CROSSING_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Vec<Place>> = vec![vec![]; paths.len()];
|
||||
let mut budget = MAX_STEPS;
|
||||
for (i, (ka, sa, ia, ca, ra)) in segs.iter().enumerate() {
|
||||
for (kb, sb, ib, cb, rb) in segs.iter().skip(i + 1) {
|
||||
if ka == kb
|
||||
&& (!self_crossings || (sa == sb && paths.get(*ka).and_then(|p| p.subpaths.get(*sa)).is_some_and(|sp| adjacent(sp, *ia, *ib))))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !ra.inflate(SAME, SAME).overlaps(*rb) {
|
||||
continue;
|
||||
}
|
||||
for (ta, tb) in segment_crossings(ca, cb, &mut budget)? {
|
||||
out.get_mut(*ka)?.push((*sa, *ia, ta));
|
||||
out.get_mut(*kb)?.push((*sb, *ib, tb));
|
||||
}
|
||||
}
|
||||
}
|
||||
for places in &mut out {
|
||||
places.sort_by(|x, y| (x.0, x.1).cmp(&(y.0, y.1)).then(x.2.total_cmp(&y.2)));
|
||||
places.dedup_by(|x, y| (x.0, x.1) == (y.0, y.1) && (x.2 - y.2).abs() < END);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `sp` with an anchor at each of `places` (segment, t), the shape unchanged → (the subpath, the
|
||||
/// indexes of the anchors at those places: the new ones, and existing ones where a place falls on
|
||||
/// an anchor). Places off the subpath are left out.
|
||||
pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet<usize>) {
|
||||
let (n, segs) = (sp.anchors.len(), sp.segment_count());
|
||||
let mut inner: BTreeMap<usize, Vec<f64>> = BTreeMap::new();
|
||||
let mut at_anchor = vec![];
|
||||
for &(seg, t) in places.iter().filter(|(seg, t)| *seg < segs && t.is_finite()) {
|
||||
match t {
|
||||
_ if t <= END => at_anchor.push(seg),
|
||||
_ if t >= 1.0 - END => at_anchor.push((seg + 1) % n),
|
||||
_ => inner.entry(seg).or_default().push(t),
|
||||
}
|
||||
}
|
||||
for ts in inner.values_mut() {
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|x, y| (*x - *y).abs() < END);
|
||||
}
|
||||
// Each split segment's pieces, cut from the original curve so their parameters stay true.
|
||||
let mut anchors = sp.anchors.clone();
|
||||
let mut added: BTreeMap<usize, Vec<Anchor>> = BTreeMap::new();
|
||||
for (&seg, ts) in &inner {
|
||||
let c = sp.segment(seg);
|
||||
let bounds: Vec<f64> = std::iter::once(0.0).chain(ts.iter().copied()).chain([1.0]).collect();
|
||||
let pieces: Vec<CubicBez> = bounds.windows(2).filter_map(|w| Some(c.subsegment(*w.first()?..*w.get(1)?))).collect();
|
||||
if sp.segment_is_line(seg) {
|
||||
added.insert(seg, pieces.iter().skip(1).map(|q| Anchor::corner(q.p0)).collect());
|
||||
continue;
|
||||
}
|
||||
let made = pieces.windows(2).filter_map(|w| match w {
|
||||
[a, b] => Some(Anchor { p: b.p0, h_in: a.p2, h_out: b.p1, kind: AnchorKind::Smooth }),
|
||||
_ => None,
|
||||
});
|
||||
added.insert(seg, made.collect());
|
||||
if let (Some(first), Some(a)) = (pieces.first(), anchors.get_mut(seg)) {
|
||||
a.h_out = first.p1;
|
||||
}
|
||||
if let (Some(last), Some(a)) = (pieces.last(), anchors.get_mut((seg + 1) % n)) {
|
||||
a.h_in = last.p2;
|
||||
}
|
||||
}
|
||||
let mut out = SubPath::new(Vec::with_capacity(n + places.len()), sp.closed);
|
||||
for (i, a) in anchors.into_iter().enumerate() {
|
||||
out.anchors.push(a);
|
||||
out.anchors.extend(added.remove(&i).unwrap_or_default());
|
||||
}
|
||||
let added_before = |j: usize| inner.range(..j).map(|(_, v)| v.len()).sum::<usize>();
|
||||
let mut marks: BTreeSet<usize> = at_anchor.into_iter().map(|j| j + added_before(j)).collect();
|
||||
for (&seg, ts) in &inner {
|
||||
let base = seg + added_before(seg);
|
||||
marks.extend((1..=ts.len()).map(|m| base + m));
|
||||
}
|
||||
(out, marks)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Lengthen an open path at one end, carrying on the way it runs there, or shorten it back along
|
||||
//! itself: [`extend_open`], and [`extend_length_to`] for the length that brings the end nearest a
|
||||
//! point (what a drag asks for).
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
|
||||
|
||||
use super::{ACCURACY, TINY, nearest_on};
|
||||
use crate::fit::end_tangent;
|
||||
|
||||
/// How an end carries on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExtendMode {
|
||||
/// In a straight line along the end's direction.
|
||||
Straight,
|
||||
/// By stretching the last segment's curve on past its end (no anchor added).
|
||||
Bezier,
|
||||
/// Along the circle that matches the end's curvature, at most half way round.
|
||||
Arc,
|
||||
}
|
||||
|
||||
impl ExtendMode {
|
||||
pub const ALL: [Self; 3] = [Self::Straight, Self::Bezier, Self::Arc];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Straight => "straight",
|
||||
Self::Bezier => "bezier",
|
||||
Self::Arc => "arc",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.id() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The least length (pt) shortening leaves of a path.
|
||||
const MIN_LEFT: f64 = 1e-3;
|
||||
/// An end straighter than this radius (pt) carries on in a straight line in [`ExtendMode::Arc`].
|
||||
const MAX_RADIUS: f64 = 1e6;
|
||||
/// The farthest (in its parameter) [`ExtendMode::Bezier`] stretches a segment.
|
||||
const MAX_STRETCH: f64 = 1024.0;
|
||||
|
||||
fn finite(sp: &SubPath) -> bool {
|
||||
let ok = |p: Point| p.x.is_finite() && p.y.is_finite();
|
||||
sp.anchors.iter().all(|a| ok(a.p) && ok(a.h_in) && ok(a.h_out))
|
||||
}
|
||||
|
||||
fn length(sp: &SubPath) -> f64 {
|
||||
(0..sp.segment_count()).map(|s| sp.segment(s).arclen(ACCURACY)).sum()
|
||||
}
|
||||
|
||||
/// The last segment of an open subpath of two anchors or more.
|
||||
fn last_segment(sp: &SubPath) -> Option<CubicBez> {
|
||||
let n = sp.segment_count();
|
||||
(n > 0).then(|| sp.segment(n - 1))
|
||||
}
|
||||
|
||||
/// Open subpath `sp` (two anchors or more) lengthened by `length` pt at its first (`at_start`) or
|
||||
/// last anchor the way `mode` says, or shortened along itself by a negative `length`. `None` when it
|
||||
/// is closed, too short, not finite, or shortening would leave nothing of it.
|
||||
pub fn extend_open(sp: &SubPath, at_start: bool, length: f64, mode: ExtendMode) -> Option<SubPath> {
|
||||
if sp.closed || sp.anchors.len() < 2 || !length.is_finite() || !finite(sp) {
|
||||
return None;
|
||||
}
|
||||
let mut s = sp.clone();
|
||||
if at_start {
|
||||
s.reverse();
|
||||
}
|
||||
let mut out = match mode {
|
||||
_ if length < 0.0 => trim_end(&s, -length)?,
|
||||
_ if length == 0.0 => s,
|
||||
ExtendMode::Straight => straight_end(s, length)?,
|
||||
ExtendMode::Bezier => bezier_end(s, length)?,
|
||||
ExtendMode::Arc => arc_end(s, length)?,
|
||||
};
|
||||
if at_start {
|
||||
out.reverse();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The length (pt) [`extend_open`] takes to bring the end of `sp` nearest `to`: positive along the
|
||||
/// way `mode` carries the end on, negative back along the path (never all of it). `None` when there
|
||||
/// is no such end.
|
||||
pub fn extend_length_to(sp: &SubPath, at_start: bool, to: Point, mode: ExtendMode) -> Option<f64> {
|
||||
if !(to.x.is_finite() && to.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut s = sp.clone();
|
||||
if at_start {
|
||||
s.reverse();
|
||||
}
|
||||
let total = length(&s);
|
||||
let end = s.anchors.last()?.p;
|
||||
// Long enough to reach past the pointer; the original stays a prefix of the result.
|
||||
let probe = (to.distance(end) * 2.0).max(1.0);
|
||||
let ext = extend_open(&s, false, probe, mode)?;
|
||||
let (seg, t, _) = nearest_on(&ext, to)?;
|
||||
let before: f64 = (0..seg).map(|k| ext.segment(k).arclen(ACCURACY)).sum();
|
||||
let along = before + ext.segment(seg).subsegment(0.0..t).arclen(ACCURACY);
|
||||
Some((along - total).max(MIN_LEFT - total))
|
||||
}
|
||||
|
||||
/// `sp` with `cut` pt taken off its end.
|
||||
fn trim_end(sp: &SubPath, cut: f64) -> Option<SubPath> {
|
||||
if cut >= length(sp) - MIN_LEFT {
|
||||
return None;
|
||||
}
|
||||
let mut s = sp.clone();
|
||||
let mut left = cut;
|
||||
while left > 0.0 {
|
||||
let c = last_segment(&s)?;
|
||||
let len = c.arclen(ACCURACY);
|
||||
let line = s.segment_is_line(s.segment_count() - 1);
|
||||
if left >= len {
|
||||
s.anchors.pop();
|
||||
left -= len;
|
||||
if let Some(a) = s.anchors.last_mut() {
|
||||
a.h_out = a.p;
|
||||
a.kind = AnchorKind::Corner;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let part = c.subsegment(0.0..c.inv_arclen(len - left, ACCURACY));
|
||||
let k = s.anchors.len();
|
||||
if let Some(a) = s.anchors.get_mut(k - 2).filter(|_| !line) {
|
||||
a.h_out = part.p1;
|
||||
}
|
||||
if let Some(a) = s.anchors.last_mut() {
|
||||
*a = if line { Anchor::corner(part.p3) } else { Anchor { p: part.p3, h_in: part.p2, h_out: part.p3, kind: AnchorKind::Corner } };
|
||||
}
|
||||
break;
|
||||
}
|
||||
(s.anchors.len() >= 2).then_some(s)
|
||||
}
|
||||
|
||||
/// `sp` with a straight segment `len` pt long added at its end, along its end's direction.
|
||||
fn straight_end(mut s: SubPath, len: f64) -> Option<SubPath> {
|
||||
let dir = end_tangent(&last_segment(&s)?);
|
||||
let a = s.anchors.last_mut()?;
|
||||
a.h_out = a.p;
|
||||
let p = a.p + dir * len;
|
||||
s.anchors.push(Anchor::corner(p));
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// `sp` with its last segment's curve stretched on by `len` pt (its end anchor moved, no anchor
|
||||
/// added); a straight last segment just grows.
|
||||
fn bezier_end(mut s: SubPath, len: f64) -> Option<SubPath> {
|
||||
let c = last_segment(&s)?;
|
||||
let k = s.anchors.len();
|
||||
if s.segment_is_line(k - 2) {
|
||||
let p = c.p3 + end_tangent(&c) * len;
|
||||
*s.anchors.last_mut()? = Anchor::corner(p);
|
||||
return Some(s);
|
||||
}
|
||||
let target = c.arclen(ACCURACY) + len;
|
||||
let reach = |t: f64| c.subsegment(0.0..t).arclen(ACCURACY);
|
||||
let mut hi = 2.0;
|
||||
while reach(hi) < target && hi < MAX_STRETCH {
|
||||
hi *= 2.0;
|
||||
}
|
||||
let mut lo = 1.0;
|
||||
for _ in 0..60 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if reach(mid) < target { lo = mid } else { hi = mid }
|
||||
}
|
||||
let ext = c.subsegment(0.0..hi);
|
||||
s.anchors.get_mut(k - 2)?.h_out = ext.p1;
|
||||
*s.anchors.last_mut()? = Anchor { p: ext.p3, h_in: ext.p2, h_out: ext.p3, kind: AnchorKind::Corner };
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// The signed radius of the circle through three points (positive when the turn from `a` through
|
||||
/// `b` to `c` is counter-clockwise in the x→y sense); `None` when they lie in a line.
|
||||
fn signed_radius(a: Point, b: Point, c: Point) -> Option<f64> {
|
||||
let cross = (b - a).cross(c - b);
|
||||
let r = (b - a).hypot() * (c - b).hypot() * (c - a).hypot() / (2.0 * cross.abs());
|
||||
(cross.abs() > TINY && r.is_finite()).then(|| r.copysign(cross))
|
||||
}
|
||||
|
||||
/// `v` turned by `a` radians (x toward y).
|
||||
fn rot(v: Vec2, a: f64) -> Vec2 {
|
||||
let (s, c) = a.sin_cos();
|
||||
Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
|
||||
}
|
||||
|
||||
/// `sp` carried on by an arc `len` pt long (at most half a circle) of the circle that leaves its
|
||||
/// end in its direction with the curvature it ends with; straight where it ends straight.
|
||||
fn arc_end(mut s: SubPath, len: f64) -> Option<SubPath> {
|
||||
let c = last_segment(&s)?;
|
||||
let dir = end_tangent(&c);
|
||||
let Some(r) = signed_radius(c.eval(0.96), c.eval(0.98), c.p3).filter(|r| r.abs() <= MAX_RADIUS) else { return straight_end(s, len) };
|
||||
let radius = r.abs();
|
||||
let turn = (len / radius).min(PI).copysign(r);
|
||||
let normal: Vec2 = Vec2::new(-dir.y, dir.x) * r.signum();
|
||||
let p = c.p3;
|
||||
let centre = p + normal * radius;
|
||||
let pieces = (turn.abs() / FRAC_PI_2).ceil().max(1.0) as usize;
|
||||
let step = turn / pieces as f64;
|
||||
let h = 4.0 / 3.0 * (step.abs() / 4.0).tan() * radius;
|
||||
let at = |phi: f64| (centre + rot(p - centre, phi), rot(dir, phi));
|
||||
let first = at(0.0);
|
||||
let end = s.anchors.last_mut()?;
|
||||
end.h_out = first.0 + first.1 * h;
|
||||
if end.has_in() {
|
||||
end.kind = AnchorKind::Smooth;
|
||||
}
|
||||
for j in 1..=pieces {
|
||||
let (q, t) = at(step * j as f64);
|
||||
let h_out = if j < pieces { q + t * h } else { q };
|
||||
let kind = if j < pieces { AnchorKind::Smooth } else { AnchorKind::Corner };
|
||||
s.anchors.push(Anchor { p: q, h_in: q - t * h, h_out, kind });
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveArclen, Point, Vec2};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
use super::*;
|
||||
use crate::addons::simplify_anchors_keep_shape;
|
||||
|
||||
fn pt(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
fn line(pts: &[(f64, f64)], closed: bool) -> SubPath {
|
||||
SubPath::polyline(&pts.iter().map(|&(x, y)| pt(x, y)).collect::<Vec<_>>(), closed)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
fn length(sp: &SubPath) -> f64 {
|
||||
(0..sp.segment_count()).map(|i| sp.segment(i).arclen(1e-6)).sum()
|
||||
}
|
||||
|
||||
/// The farthest any sampled point of `sp` lies off the circle about `c` of radius `r`.
|
||||
fn off_circle(sp: &SubPath, c: Point, r: f64) -> f64 {
|
||||
(0..sp.segment_count())
|
||||
.flat_map(|i| (0..=16).map(move |k| (i, k as f64 / 16.0)))
|
||||
.map(|(i, t)| (sp.segment(i).eval(t).distance(c) - r).abs())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_smooth_turns_a_square_into_a_circle() {
|
||||
let mut sq = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
for i in 0..4 {
|
||||
assert!(auto_smooth(&mut sq, i, SMOOTH_RATIO));
|
||||
}
|
||||
let a = sq.anchors[1];
|
||||
assert_eq!(a.kind, AnchorKind::Smooth);
|
||||
// Each handle is the ratio × the side long, at right angles to the corner's bisector.
|
||||
assert!(near((a.h_out - a.p).hypot(), 39.05, 1e-9) && near((a.h_in - a.p).hypot(), 39.05, 1e-9));
|
||||
assert!(near((a.h_out - a.p).dot(Vec2::new(-1.0, 1.0)), 0.0, 1e-9));
|
||||
// The four corners make a circle through them, to within its cubic approximation.
|
||||
assert!(off_circle(&sq, pt(50.0, 50.0), 50f64.hypot(50.0)) < 0.2, "{}", off_circle(&sq, pt(50.0, 50.0), 50f64.hypot(50.0)));
|
||||
// A smooth anchor stays as it is.
|
||||
assert!(!auto_smooth(&mut sq, 1, SMOOTH_RATIO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_smooth_mirrors_a_lone_handle_and_aims_open_ends() {
|
||||
let mut sp = SubPath::new(
|
||||
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(40.0, -10.0), pt(50.0, 0.0)), Anchor::corner(pt(100.0, 0.0))],
|
||||
false,
|
||||
);
|
||||
assert!(auto_smooth(&mut sp, 1, SMOOTH_RATIO));
|
||||
assert_eq!(sp.anchors[1].h_out, pt(60.0, 10.0));
|
||||
// An open end with no handle aims at its neighbour's facing handle.
|
||||
assert!(auto_smooth(&mut sp, 0, SMOOTH_RATIO));
|
||||
let h = sp.anchors[0].h_out;
|
||||
assert!(near(h.distance(pt(0.0, 0.0)), 50.0 * SMOOTH_RATIO, 1e-9));
|
||||
assert!(near((h - pt(0.0, 0.0)).cross(pt(40.0, -10.0) - pt(0.0, 0.0)), 0.0, 1e-9), "{h:?}");
|
||||
// Two handles out of line are brought into line, keeping their lengths.
|
||||
let mut bent = SubPath::new(
|
||||
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(40.0, 0.0), pt(50.0, 20.0)), Anchor::corner(pt(100.0, 0.0))],
|
||||
false,
|
||||
);
|
||||
assert!(auto_smooth(&mut bent, 1, SMOOTH_RATIO));
|
||||
let a = bent.anchors[1];
|
||||
assert!(
|
||||
near((a.h_in - a.p).cross(a.h_out - a.p), 0.0, 1e-9) && near((a.h_out - a.p).hypot(), 20.0, 1e-9) && near((a.h_in - a.p).hypot(), 10.0, 1e-9)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equalise_averages_lengths_and_makes_a_missing_handle() {
|
||||
let mut sp = SubPath::new(
|
||||
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(40.0, 0.0), pt(80.0, 0.0)), Anchor::corner(pt(100.0, 0.0))],
|
||||
false,
|
||||
);
|
||||
assert!(equalize_handles(&mut sp, 1));
|
||||
assert_eq!((sp.anchors[1].h_in, sp.anchors[1].h_out), (pt(30.0, 0.0), pt(70.0, 0.0)));
|
||||
assert!(!equalize_handles(&mut sp, 1), "already equal");
|
||||
let mut one = SubPath::new(
|
||||
vec![Anchor::corner(pt(0.0, 0.0)), Anchor::with_handles(pt(50.0, 0.0), pt(50.0, 0.0), pt(50.0, 20.0)), Anchor::corner(pt(100.0, 0.0))],
|
||||
false,
|
||||
);
|
||||
assert!(equalize_handles(&mut one, 1));
|
||||
assert_eq!(one.anchors[1].h_in, pt(50.0, -20.0));
|
||||
assert!(!equalize_handles(&mut one, 0), "no handles to equalise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_corner_retracts_or_keeps_handles() {
|
||||
let a = Anchor::smooth(pt(50.0, 0.0), pt(60.0, 0.0));
|
||||
let mut sp = SubPath::new(vec![Anchor::corner(pt(0.0, 0.0)), a, Anchor::corner(pt(100.0, 0.0))], false);
|
||||
assert!(make_corner(&mut sp, 1, false));
|
||||
assert_eq!((sp.anchors[1].kind, sp.anchors[1].h_out), (AnchorKind::Corner, pt(60.0, 0.0)));
|
||||
assert!(make_corner(&mut sp, 1, true));
|
||||
assert!(!sp.anchors[1].has_in() && !sp.anchors[1].has_out());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_points_splits_segments_into_equal_lengths() {
|
||||
let sp = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 40.0)], false);
|
||||
let (out, added) = add_points(&sp, Some(&BTreeSet::from([0])), 3);
|
||||
let xs: Vec<f64> = out.anchors.iter().map(|a| a.p.x).collect();
|
||||
for (x, want) in xs.iter().zip([0.0, 25.0, 50.0, 75.0, 100.0, 100.0]) {
|
||||
assert!(near(*x, want, 1e-4), "{xs:?}");
|
||||
}
|
||||
assert_eq!((xs.len(), added), (6, BTreeSet::from([1, 2, 3])));
|
||||
// On a curve the pieces have the same length and the shape stays.
|
||||
let mut sq = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
for i in 0..4 {
|
||||
auto_smooth(&mut sq, i, SMOOTH_RATIO);
|
||||
}
|
||||
let (out, added) = add_points(&sq, None, 2);
|
||||
assert_eq!((out.anchors.len(), added.len()), (12, 8));
|
||||
let lens: Vec<f64> = (0..3).map(|i| out.segment(i).arclen(1e-6)).collect();
|
||||
assert!(near(lens[0], lens[1], 1e-3) && near(lens[1], lens[2], 1e-3), "{lens:?}");
|
||||
assert!(near(length(&out), length(&sq), 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_joins_ends_three_ways() {
|
||||
let open = SubPath::new(
|
||||
vec![Anchor::with_handles(pt(0.0, 0.0), pt(-10.0, 10.0), pt(10.0, -10.0)), Anchor::corner(pt(100.0, 0.0)), Anchor::corner(pt(50.0, 80.0))],
|
||||
false,
|
||||
);
|
||||
let mut kept = open.clone();
|
||||
assert!(close(&mut kept, CloseMode::Handles, SMOOTH_RATIO));
|
||||
assert!(kept.closed && kept.anchors[0].h_in == pt(-10.0, 10.0));
|
||||
let mut straight = open.clone();
|
||||
assert!(close(&mut straight, CloseMode::Straight, SMOOTH_RATIO));
|
||||
assert!(straight.closed && straight.segment_is_line(2));
|
||||
let mut smooth = open.clone();
|
||||
assert!(close(&mut smooth, CloseMode::Smooth, SMOOTH_RATIO));
|
||||
assert!(smooth.anchors[2].has_in() && smooth.anchors[2].has_out(), "the last anchor is made smooth");
|
||||
assert!(!close(&mut smooth, CloseMode::Smooth, SMOOTH_RATIO), "closed already");
|
||||
assert_eq!(CloseMode::parse("straight"), Some(CloseMode::Straight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_straight_lengthens_and_shortens_either_end() {
|
||||
let sp = line(&[(0.0, 0.0), (100.0, 0.0)], false);
|
||||
let end = extend_open(&sp, false, 50.0, ExtendMode::Straight).unwrap();
|
||||
assert_eq!(end.anchors.last().unwrap().p, pt(150.0, 0.0));
|
||||
let start = extend_open(&sp, true, 20.0, ExtendMode::Straight).unwrap();
|
||||
assert_eq!(start.anchors.first().unwrap().p, pt(-20.0, 0.0));
|
||||
let short = extend_open(&sp, false, -30.0, ExtendMode::Straight).unwrap();
|
||||
assert_eq!(short.anchors.len(), 2);
|
||||
assert!(near(short.anchors[1].p.x, 70.0, 1e-6));
|
||||
// Shortening past an anchor drops it.
|
||||
let bent = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 10.0)], false);
|
||||
let cut = extend_open(&bent, false, -40.0, ExtendMode::Straight).unwrap();
|
||||
assert_eq!(cut.anchors.len(), 2);
|
||||
assert!(near(cut.anchors[1].p.x, 70.0, 1e-6));
|
||||
// Nothing is left of a path shortened by its whole length; closed paths have no ends.
|
||||
assert!(extend_open(&sp, false, -100.0, ExtendMode::Straight).is_none());
|
||||
assert!(extend_open(&line(&[(0.0, 0.0), (10.0, 0.0), (0.0, 10.0)], true), false, 5.0, ExtendMode::Straight).is_none());
|
||||
assert!(extend_open(&sp, false, f64::NAN, ExtendMode::Arc).is_none());
|
||||
}
|
||||
|
||||
/// A quarter of the circle of radius 100 about the origin, from (100, 0) to (0, 100).
|
||||
fn quarter() -> SubPath {
|
||||
let k = 4.0 / 3.0 * (std::f64::consts::FRAC_PI_8).tan() * 100.0;
|
||||
SubPath::new(
|
||||
vec![Anchor::with_handles(pt(100.0, 0.0), pt(100.0, 0.0), pt(100.0, k)), Anchor::with_handles(pt(0.0, 100.0), pt(k, 100.0), pt(0.0, 100.0))],
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_arc_follows_the_circle_the_end_curves_along() {
|
||||
let sp = quarter();
|
||||
let len = std::f64::consts::FRAC_PI_2 * 100.0;
|
||||
let out = extend_open(&sp, false, len, ExtendMode::Arc).unwrap();
|
||||
// The circle matches the curvature the (cubic) quarter ends with, within a percent or so of
|
||||
// the true circle's.
|
||||
assert!(off_circle(&out, Point::ORIGIN, 100.0) < 3.0, "{}", off_circle(&out, Point::ORIGIN, 100.0));
|
||||
let end = out.anchors.last().unwrap().p;
|
||||
assert!(end.distance(pt(-100.0, 0.0)) < 5.0, "{end:?}");
|
||||
assert!(near(length(&out), 2.0 * len, 0.2));
|
||||
// At most half way round.
|
||||
let long = extend_open(&sp, false, 10.0 * len, ExtendMode::Arc).unwrap();
|
||||
assert!(near(length(&long) - len, std::f64::consts::PI * 100.0, 6.0), "{}", length(&long) - len);
|
||||
// A straight end carries on straight.
|
||||
let straight = extend_open(&line(&[(0.0, 0.0), (10.0, 0.0)], false), false, 5.0, ExtendMode::Arc).unwrap();
|
||||
assert_eq!(straight.anchors.last().unwrap().p, pt(15.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_bezier_stretches_the_last_curve_without_new_anchors() {
|
||||
let sp = quarter();
|
||||
let before = length(&sp);
|
||||
let out = extend_open(&sp, false, 30.0, ExtendMode::Bezier).unwrap();
|
||||
assert_eq!(out.anchors.len(), 2);
|
||||
assert!(near(length(&out), before + 30.0, 1e-3), "{}", length(&out));
|
||||
let p = out.segment(0).eval(0.5);
|
||||
assert!((p.to_vec2().hypot() - 100.0).abs() < 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_length_a_drag_asks_for() {
|
||||
let sp = line(&[(0.0, 0.0), (100.0, 0.0)], false);
|
||||
assert!(near(extend_length_to(&sp, false, pt(130.0, 10.0), ExtendMode::Straight).unwrap(), 30.0, 1e-6));
|
||||
assert!(near(extend_length_to(&sp, false, pt(40.0, 5.0), ExtendMode::Straight).unwrap(), -60.0, 1e-6));
|
||||
assert!(near(extend_length_to(&sp, true, pt(-25.0, 3.0), ExtendMode::Straight).unwrap(), 25.0, 1e-6));
|
||||
// Never the whole path.
|
||||
assert!(extend_length_to(&sp, false, pt(-50.0, 0.0), ExtendMode::Straight).unwrap() > -100.0);
|
||||
let arc = extend_length_to(&quarter(), false, pt(-100.0, 0.0), ExtendMode::Arc).unwrap();
|
||||
assert!(near(arc, std::f64::consts::FRAC_PI_2 * 100.0, 3.0), "{arc}");
|
||||
assert!(extend_length_to(&sp, false, pt(f64::NAN, 0.0), ExtendMode::Straight).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_are_found_on_both_paths() {
|
||||
let a = PathData::single(line(&[(0.0, 0.0), (100.0, 100.0)], false));
|
||||
let b = PathData::single(line(&[(0.0, 100.0), (100.0, 0.0)], false));
|
||||
let hits = crossings(&[&a, &b], false).unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(hits[0].len(), 1);
|
||||
assert!(near(hits[0][0].2, 0.5, 1e-6) && near(hits[1][0].2, 0.5, 1e-6));
|
||||
// A circle crossed by a line: twice.
|
||||
let mut circle = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
for i in 0..4 {
|
||||
auto_smooth(&mut circle, i, SMOOTH_RATIO);
|
||||
}
|
||||
let across = PathData::single(line(&[(-40.0, 50.0), (140.0, 50.0)], false));
|
||||
let circle = PathData::single(circle);
|
||||
let hits = crossings(&[&circle, &across], false).unwrap();
|
||||
assert_eq!((hits[0].len(), hits[1].len()), (2, 2), "{hits:?}");
|
||||
// A path doesn't cross itself where its segments meet, and only crosses itself when asked.
|
||||
let bow = PathData::single(line(&[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false));
|
||||
assert!(crossings(&[&bow], false).unwrap()[0].is_empty());
|
||||
assert_eq!(crossings(&[&bow], true).unwrap()[0].len(), 2, "one crossing, on two of its segments");
|
||||
// Two identical paths lying on each other don't tangle the search.
|
||||
let sq = PathData::single(circle.subpaths[0].clone());
|
||||
assert!(crossings(&[&sq, &circle], false).is_some());
|
||||
// A path ending on another meets it there: at its end anchor, and part way along the other.
|
||||
let t = PathData::single(line(&[(50.0, 50.0), (50.0, 150.0)], false));
|
||||
let hits = crossings(&[&b, &t], false).unwrap();
|
||||
assert_eq!((hits[0].len(), hits[1].len()), (1, 1), "{hits:?}");
|
||||
assert!(near(hits[1][0].2, 0.0, 1e-6) && near(b.subpaths[0].segment(0).eval(hits[0][0].2).x, 50.0, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_at_keeps_the_shape_and_names_the_new_anchors() {
|
||||
let sp = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
|
||||
let (out, marks) = insert_at(&sp, &[(1, 0.5), (0, 0.25), (0, 0.75), (1, 1.0), (7, 0.5)]);
|
||||
let pts: Vec<Point> = out.anchors.iter().map(|a| a.p).collect();
|
||||
let c = sp.segment(0);
|
||||
let want = [pt(0.0, 0.0), c.eval(0.25), c.eval(0.75), pt(100.0, 0.0), pt(100.0, 50.0), pt(100.0, 100.0)];
|
||||
assert_eq!(pts.len(), want.len());
|
||||
assert!(pts.iter().zip(want).all(|(p, w)| p.distance(w) < 1e-9), "{pts:?}");
|
||||
assert_eq!(marks, BTreeSet::from([1, 2, 4, 5]));
|
||||
// The closing segment of a closed path.
|
||||
let tri = line(&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true);
|
||||
let (out, marks) = insert_at(&tri, &[(2, 0.5)]);
|
||||
assert_eq!((out.anchors.len(), out.anchors[3].p, marks), (4, pt(0.0, 50.0), BTreeSet::from([3])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reads_lengths_area_angles_and_curvature() {
|
||||
let rect = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)], true));
|
||||
let i = inspect(&rect, pt(50.0, 1.0), 3.0).unwrap();
|
||||
assert_eq!((i.closed, i.anchors, i.segment), (true, 4, 0));
|
||||
assert!(near(i.length, 300.0, 1e-6) && near(i.area, 5000.0, 1e-6) && near(i.segment_length, 100.0, 1e-6));
|
||||
assert_eq!((i.radius, i.anchor.is_none()), (None, true));
|
||||
let corner = inspect(&rect, pt(99.0, 1.0), 3.0).unwrap().anchor.unwrap();
|
||||
assert_eq!(corner.index, 1);
|
||||
assert!(near(corner.angle.unwrap(), 90.0, 1e-9));
|
||||
let r = inspect(&PathData::single(quarter()), pt(71.0, 71.0), 1.0).unwrap();
|
||||
assert!(near(r.radius.unwrap(), 100.0, 1.0), "{:?}", r.radius);
|
||||
assert!(near(r.tangent, -135.0, 1.0), "{}", r.tangent);
|
||||
let open = inspect(&PathData::single(quarter()), pt(100.0, 0.0), 1.0).unwrap().anchor.unwrap();
|
||||
assert_eq!(open.angle, None, "an open end has no angle");
|
||||
assert!(inspect(&PathData::default(), pt(0.0, 0.0), 1.0).is_none());
|
||||
// Handle readouts: length and direction counter-clockwise on the page.
|
||||
assert_eq!(handle_polar(pt(0.0, 0.0), pt(0.0, -10.0)), Some((10.0, 90.0)));
|
||||
assert!(handle_at(pt(0.0, 0.0), 10.0, 90.0).distance(pt(0.0, -10.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_brush_kernel_removes_only_marked_anchors() {
|
||||
let pts: Vec<(f64, f64)> = (0..=10).map(|i| (i as f64 * 10.0, 0.0)).collect();
|
||||
let sp = line(&pts, false);
|
||||
let marked: Vec<bool> = (0..=10).map(|i| (3..=5).contains(&i)).collect();
|
||||
let out = simplify_anchors_keep_shape(&sp, 0.1, &marked);
|
||||
let xs: Vec<f64> = out.anchors.iter().map(|a| a.p.x).collect();
|
||||
assert_eq!(xs, [0.0, 10.0, 20.0, 60.0, 70.0, 80.0, 90.0, 100.0]);
|
||||
// Off the line, a removal within tolerance only.
|
||||
let bump = line(&[(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (30.0, 5.0), (40.0, 0.0)], false);
|
||||
assert_eq!(simplify_anchors_keep_shape(&bump, 0.1, &[true; 5]).anchors.len(), 4, "only the anchor on the straight run goes");
|
||||
assert_eq!(simplify_anchors_keep_shape(&bump, f64::INFINITY, &[true; 5]).anchors.len(), 2, "unless tolerance is ignored");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! The tools of VectorCraft's built-in add-ons. They aren't in the toolbars as they ship: the All
|
||||
//! Tools drawer lists them (under their add-on's heading) so they can be added, and each add-on's
|
||||
//! submenu selects them. Like every tool they only emit commands (`addon.<name>.*`).
|
||||
|
||||
pub mod anchor_eraser;
|
||||
pub mod extend_path;
|
||||
pub mod measure_inspect;
|
||||
|
||||
use crate::{Tool, ToolInfo};
|
||||
|
||||
/// Path Precision Tools' tools, one flyout group.
|
||||
pub const PATH_TOOLS: &[ToolInfo] = &[
|
||||
ToolInfo { id: anchor_eraser::ID, label: "Anchor Eraser Brush", shortcut: None, icon: "dc-remove-brush" },
|
||||
ToolInfo { id: extend_path::ID, label: "Extend Path Tool", shortcut: None, icon: "dc-arrow-extend" },
|
||||
ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" },
|
||||
];
|
||||
|
||||
/// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]).
|
||||
pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS];
|
||||
|
||||
/// The heading the All Tools drawer lists tool `id` under: its add-on's name.
|
||||
pub fn heading(id: &str) -> Option<&'static str> {
|
||||
PATH_TOOLS.iter().any(|t| t.id == id).then_some("Path Precision Tools")
|
||||
}
|
||||
|
||||
/// Create an add-on tool by id (None = not one of them).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()),
|
||||
extend_path::ID => Box::new(extend_path::ExtendPathTool::default()),
|
||||
measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_addon_tool_is_made_and_catalogued_but_not_in_the_toolbar() {
|
||||
for t in GROUPS.iter().flat_map(|g| g.iter()) {
|
||||
assert_eq!(create(t.id).map(|tool| tool.id()), Some(t.id));
|
||||
assert_eq!(crate::tool_info(t.id).map(|i| i.label), Some(t.label));
|
||||
assert!(crate::create(t.id).id() == t.id);
|
||||
assert!(t.shortcut.is_none(), "no default shortcut");
|
||||
assert!(!crate::TOOL_GROUPS.iter().any(|g| g.iter().any(|x| x.id == t.id)), "{} is not in the toolbar's groups", t.id);
|
||||
assert_eq!(heading(t.id), Some("Path Precision Tools"));
|
||||
}
|
||||
assert!(crate::catalog::all_tools().any(|t| t.id == extend_path::ID));
|
||||
assert!(create("pen").is_none() && heading("pen").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Anchor Eraser Brush (Path Precision Tools): paint over selected paths and the anchors the round
|
||||
//! brush passes over go, their neighbours' handles refitted, wherever the outline can do without
|
||||
//! them within the tolerance (`addon.pathtools.erase`, previewed while painting, one undo step per
|
||||
//! stroke). Alt removes every anchor under the brush that can go whatever the tolerance; Cmd/Ctrl
|
||||
//! deletes them without refitting. `[` and `]` make the brush smaller and larger. With Pressure on,
|
||||
//! a pen's pressure scales the brush.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Action, Cursor, MAX_STROKE_SAMPLES, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, previews_at};
|
||||
|
||||
/// The tool's id.
|
||||
pub const ID: &str = "anchorEraserBrush";
|
||||
/// The brush's diameter on screen (px) by default, and its range.
|
||||
pub const SIZE: f64 = 20.0;
|
||||
pub const SIZE_RANGE: (f64, f64) = (1.0, 2500.0);
|
||||
/// The tolerance by default and its range.
|
||||
pub const TOLERANCE: f64 = 10.0;
|
||||
pub const TOLERANCE_RANGE: (f64, f64) = (1.0, 100.0);
|
||||
/// The undo step's name.
|
||||
const LABEL: &str = "Anchor Eraser Brush";
|
||||
/// The brush's outline colour.
|
||||
const RED: [u8; 3] = [0xe0, 0x30, 0x30];
|
||||
|
||||
pub struct AnchorEraserTool {
|
||||
/// Diameter on screen (px).
|
||||
size: f64,
|
||||
tolerance: f64,
|
||||
pressure: bool,
|
||||
/// The stroke under way: its points and their pen pressure.
|
||||
points: Vec<(Point, f32)>,
|
||||
/// The modifiers the stroke started with: Alt ignores the tolerance, Cmd/Ctrl skips refitting.
|
||||
mods: Mods,
|
||||
painting: bool,
|
||||
/// Where the brush is.
|
||||
at: Option<Point>,
|
||||
}
|
||||
|
||||
impl Default for AnchorEraserTool {
|
||||
fn default() -> Self {
|
||||
Self { size: SIZE, tolerance: TOLERANCE, pressure: false, points: vec![], mods: Mods::default(), painting: false, at: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl AnchorEraserTool {
|
||||
/// `addon.pathtools.erase`'s params for the stroke so far.
|
||||
fn params(&self, cx: &ToolContext) -> Value {
|
||||
let points: Vec<Value> = self
|
||||
.points
|
||||
.iter()
|
||||
.map(|(p, f)| if self.pressure && *f < 1.0 { json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0]) } else { json!([p.x, p.y]) })
|
||||
.collect();
|
||||
json!({
|
||||
"points": points,
|
||||
"diameter": cx.tol(self.size).clamp(0.1, SIZE_RANGE.1),
|
||||
"tolerance": self.tolerance,
|
||||
"ignoreTolerance": self.mods.alt,
|
||||
"plain": self.mods.cmd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for AnchorEraserTool {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.painting
|
||||
}
|
||||
|
||||
fn wants_samples(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
self.at = Some(ev.pos);
|
||||
match ev.kind {
|
||||
// Nothing selected: nothing to erase from.
|
||||
PointerKind::Down if !cx.selection.objects.is_empty() => {
|
||||
self.points = vec![(ev.pos, ev.pressure)];
|
||||
self.mods = ev.mods;
|
||||
self.painting = true;
|
||||
vec![Action::Begin(LABEL.into()), Action::Preview("addon.pathtools.erase".into(), self.params(cx))]
|
||||
}
|
||||
PointerKind::Drag if self.painting => {
|
||||
if self.points.len() >= MAX_STROKE_SAMPLES {
|
||||
return vec![];
|
||||
}
|
||||
self.points.push((ev.pos, ev.pressure));
|
||||
if previews_at(self.points.len()) { vec![Action::Preview("addon.pathtools.erase".into(), self.params(cx))] } else { vec![] }
|
||||
}
|
||||
PointerKind::Up if self.painting => {
|
||||
if self.points.len() < MAX_STROKE_SAMPLES && self.points.last().is_none_or(|(p, _)| *p != ev.pos) {
|
||||
self.points.push((ev.pos, ev.pressure));
|
||||
}
|
||||
self.painting = false;
|
||||
let out = vec![Action::Preview("addon.pathtools.erase".into(), self.params(cx)), Action::Commit];
|
||||
self.points.clear();
|
||||
out
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
match key {
|
||||
ToolKey::Escape if self.painting => {
|
||||
self.painting = false;
|
||||
self.points.clear();
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
ToolKey::BracketLeft | ToolKey::BracketRight => {
|
||||
let step = if key == ToolKey::BracketLeft { 1.0 / 1.25 } else { 1.25 };
|
||||
self.size = (self.size * step).round().clamp(SIZE_RANGE.0, SIZE_RANGE.1);
|
||||
vec![]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
matches!(key, ToolKey::BracketLeft | ToolKey::BracketRight) || (self.painting && key == ToolKey::Escape)
|
||||
}
|
||||
|
||||
fn hover_ended(&mut self, _cx: &ToolContext) {
|
||||
self.at = None;
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(at) = self.at else { return vec![] };
|
||||
let r = cx.tol(self.size / 2.0);
|
||||
let circle = kurbo::Shape::to_path(&kurbo::Circle::new(at, r), 0.1 * r.max(1e-6));
|
||||
vec![Overlay::Path { path: circle, color: RED, width: 1.0, dashed: false }]
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::AnchorEraser
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
json!({ "size": self.size, "tolerance": self.tolerance, "pressure": self.pressure })
|
||||
}
|
||||
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
let num = |v: &Value, (lo, hi): (f64, f64)| v.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(lo, hi));
|
||||
match key {
|
||||
"size" => self.size = num(v, SIZE_RANGE).unwrap_or(self.size),
|
||||
"tolerance" => self.tolerance = num(v, TOLERANCE_RANGE).unwrap_or(self.tolerance),
|
||||
"pressure" => self.pressure = v.as_bool().unwrap_or(self.pressure),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_previews_the_erase_and_commits_once() {
|
||||
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 = AnchorEraserTool::default();
|
||||
let down = t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
|
||||
assert_eq!(down.first(), Some(&Action::Begin(LABEL.into())));
|
||||
assert!(t.busy());
|
||||
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0));
|
||||
let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") };
|
||||
assert_eq!(cmd, "addon.pathtools.erase");
|
||||
assert_eq!(params["points"], json!([[100.0, 100.0], [150.0, 100.0]]));
|
||||
assert_eq!((params["diameter"].clone(), params["tolerance"].clone()), (json!(20.0), json!(10.0)));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 200.0, 100.0));
|
||||
assert!(matches!(up.as_slice(), [Action::Preview(..), Action::Commit]), "{up:?}");
|
||||
assert!(!t.busy());
|
||||
// Alt ignores the tolerance; Cmd deletes without refitting; Escape cancels.
|
||||
let alt = Mods { alt: true, cmd: true, ..Mods::default() };
|
||||
let down = t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt));
|
||||
let Some(Action::Preview(_, params)) = down.get(1) else { panic!("{down:?}") };
|
||||
assert_eq!((params["ignoreTolerance"].clone(), params["plain"].clone()), (json!(true), json!(true)));
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 100.0, 100.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_selected_paints_nothing() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = AnchorEraserTool::default();
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)).is_empty());
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 100.0)).is_empty());
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_size_the_brush_and_pressure_scales_it() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut sel = Selection::default();
|
||||
sel.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &sel, &p);
|
||||
cx.zoom = 2.0;
|
||||
let mut t = AnchorEraserTool::default();
|
||||
t.set_option("size", &json!(40));
|
||||
t.set_option("tolerance", &json!(500));
|
||||
t.set_option("pressure", &json!(true));
|
||||
assert_eq!(t.options(), json!({"size": 40.0, "tolerance": 100.0, "pressure": true}));
|
||||
let mut down = ev(PointerKind::Down, 10.0, 10.0);
|
||||
down.pressure = 0.5;
|
||||
let a = t.pointer(&cx, &down);
|
||||
let Some(Action::Preview(_, params)) = a.get(1) else { panic!("{a:?}") };
|
||||
assert_eq!(params["diameter"], json!(20.0), "40 px at 200%");
|
||||
assert_eq!(params["points"], json!([[10.0, 10.0, 0.5]]));
|
||||
// The brush shows round the pointer, and `]` makes it larger.
|
||||
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Path { .. }]));
|
||||
t.key(&cx, ToolKey::BracketRight, Mods::default());
|
||||
assert_eq!(t.options()["size"], json!(50.0));
|
||||
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::AnchorEraser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Extend Path tool (Path Precision Tools): drag an end of an open path (selected or not) to carry
|
||||
//! the path on the way it runs there, or back along itself to shorten it
|
||||
//! (`addon.pathtools.extend`, previewed while dragging, one undo step). Over an end, a dashed line
|
||||
//! shows how it would go on. E cycles the mode (straight, the last curve stretched, an arc of the
|
||||
//! circle the end curves along); Shift steps the length by the keyboard increment; back over the
|
||||
//! end, the length snaps to none.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, SubPath};
|
||||
use vectorcraft_pathops::addons::pathtools::{ExtendMode, extend_length_to, extend_open};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The tool's id.
|
||||
pub const ID: &str = "extendPath";
|
||||
/// The undo step's name.
|
||||
const LABEL: &str = "Extend Path";
|
||||
/// How far ahead (px) the hover preview shows an end going on.
|
||||
const PREVIEW_PX: f64 = 60.0;
|
||||
const GREY: [u8; 3] = [0x80, 0x80, 0x80];
|
||||
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||||
|
||||
/// An end of an open subpath.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct End {
|
||||
id: NodeId,
|
||||
subpath: usize,
|
||||
start: bool,
|
||||
p: Point,
|
||||
/// The subpath as it was.
|
||||
sp: SubPath,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExtendPathTool {
|
||||
mode: Mode,
|
||||
hover: Option<End>,
|
||||
/// The end being dragged and the length the drag asks for.
|
||||
drag: Option<(End, f64)>,
|
||||
}
|
||||
|
||||
/// [`ExtendMode`], with the default the tool starts with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Mode(ExtendMode);
|
||||
|
||||
impl Default for Mode {
|
||||
fn default() -> Self {
|
||||
Mode(ExtendMode::Straight)
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of an editable open path nearest `p` within the point tolerance.
|
||||
fn end_at(cx: &ToolContext, p: Point) -> Option<End> {
|
||||
let tol = cx.point_tol();
|
||||
let mut best: Option<(f64, NodeId, usize, bool, Point)> = None;
|
||||
cx.doc.walk(|n| {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
|
||||
for (si, sp) in path.subpaths.iter().enumerate().filter(|(_, sp)| !sp.closed && sp.anchors.len() >= 2) {
|
||||
for (start, a) in [(true, sp.anchors.first()), (false, sp.anchors.last())] {
|
||||
let Some(a) = a else { continue };
|
||||
let d = a.p.distance(p);
|
||||
if d <= tol && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, n.id, si, start, a.p));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (_, id, subpath, start, at) = best.filter(|b| cx.doc.is_editable(b.1))?;
|
||||
let sp = cx.doc.node(id)?.path_data()?.subpaths.get(subpath)?.clone();
|
||||
Some(End { id, subpath, start, p: at, sp })
|
||||
}
|
||||
|
||||
impl ExtendPathTool {
|
||||
/// The length a drag to `p` asks for: back over the end none, with Shift in steps of the
|
||||
/// keyboard increment.
|
||||
fn length_at(&self, cx: &ToolContext, end: &End, p: Point, mods: Mods) -> f64 {
|
||||
if p.distance(end.p) <= cx.point_tol() {
|
||||
return 0.0;
|
||||
}
|
||||
let len = extend_length_to(&end.sp, end.start, p, self.mode.0).unwrap_or(0.0);
|
||||
let step = cx.keyboard_increment;
|
||||
if mods.shift && step > 0.0 && step.is_finite() { (len / step).round() * step } else { len }
|
||||
}
|
||||
|
||||
fn params(&self, end: &End, length: f64) -> Value {
|
||||
json!({
|
||||
"id": end.id.0,
|
||||
"subpath": end.subpath,
|
||||
"end": if end.start { "start" } else { "end" },
|
||||
"length": length,
|
||||
"mode": self.mode.0.id(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The length of `sp` (pt).
|
||||
fn length_of(sp: &SubPath) -> f64 {
|
||||
(0..sp.segment_count()).map(|i| kurbo::ParamCurveArclen::arclen(&sp.segment(i), 1e-6)).sum()
|
||||
}
|
||||
|
||||
impl Tool for ExtendPathTool {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = end_at(cx, ev.pos);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
let Some(end) = end_at(cx, ev.pos) else { return vec![] };
|
||||
self.hover = None;
|
||||
self.drag = Some((end, 0.0));
|
||||
vec![Action::Begin(LABEL.into())]
|
||||
}
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let Some((end, _)) = self.drag.take() else { return vec![] };
|
||||
let length = self.length_at(cx, &end, ev.pos, ev.mods);
|
||||
let preview = Action::Preview("addon.pathtools.extend".into(), self.params(&end, length));
|
||||
if ev.kind == PointerKind::Drag {
|
||||
self.drag = Some((end, length));
|
||||
return vec![preview];
|
||||
}
|
||||
// Back where it started: nothing to keep.
|
||||
if length == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] }
|
||||
}
|
||||
PointerKind::DoubleClick => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
match key {
|
||||
ToolKey::Escape if self.drag.is_some() => {
|
||||
self.drag = None;
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
ToolKey::Letter('e') => {
|
||||
let all = ExtendMode::ALL;
|
||||
let i = all.iter().position(|m| *m == self.mode.0).unwrap_or(0);
|
||||
self.mode = Mode(all.get((i + 1) % all.len()).copied().unwrap_or(ExtendMode::Straight));
|
||||
vec![]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Letter('e') || (self.drag.is_some() && key == ToolKey::Escape)
|
||||
}
|
||||
|
||||
fn hover_ended(&mut self, _cx: &ToolContext) {
|
||||
self.hover = None;
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let size = 7.0;
|
||||
if let Some((end, length)) = &self.drag {
|
||||
let total = length_of(&end.sp) + length;
|
||||
return vec![
|
||||
Overlay::Anchor { p: end.p, color: BLUE, filled: false, size },
|
||||
Overlay::Measure {
|
||||
p: end.p + kurbo::Vec2::new(cx.tol(12.0), cx.tol(12.0)),
|
||||
text: format!("Length: {}\nΔ: {}", cx.len(total), cx.len(*length)),
|
||||
},
|
||||
];
|
||||
}
|
||||
let Some(end) = &self.hover else { return vec![] };
|
||||
let mut out = vec![Overlay::Anchor { p: end.p, color: BLUE, filled: true, size }];
|
||||
// How the end would go on.
|
||||
if let Some(ext) = extend_open(&end.sp, end.start, cx.tol(PREVIEW_PX), self.mode.0) {
|
||||
let (k, n) = (end.sp.anchors.len(), ext.anchors.len());
|
||||
// From the old end on; a stretched last curve (no anchor added) shows whole.
|
||||
let from = if n > k { k - 1 } else { n.saturating_sub(2) };
|
||||
let tail = if end.start { ext.anchors.get(..n - from) } else { ext.anchors.get(from..) };
|
||||
if let Some(tail) = tail {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
SubPath::new(tail.to_vec(), false).to_bezpath_into(&mut bp);
|
||||
out.push(Overlay::Path { path: bp, color: GREY, width: 1.0, dashed: true });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::ExtendPath
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
json!({ "mode": self.mode.0.id() })
|
||||
}
|
||||
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
if key == "mode"
|
||||
&& let Some(m) = v.as_str().and_then(ExtendMode::parse)
|
||||
{
|
||||
self.mode = Mode(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::{Appearance, Document, Node, Selection};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
/// A document with an open line from (100, 100) to (200, 100).
|
||||
fn doc_with_line() -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let sp = SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0)], false);
|
||||
d.insert(Some(l), 0, Node::path(id, PathData::single(sp), Appearance::default_art())).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_an_end_previews_the_extension_and_commits() {
|
||||
let (d, id) = doc_with_line();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = ExtendPathTool::default();
|
||||
// Hovering the end shows it and how it would go on.
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Move, 201.0, 101.0)).is_empty());
|
||||
let ov = t.overlays(&cx);
|
||||
assert!(matches!(ov.as_slice(), [Overlay::Anchor { filled: true, .. }, Overlay::Path { dashed: true, .. }]), "{ov:?}");
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 201.0, 101.0)), vec![Action::Begin(LABEL.into())]);
|
||||
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 240.0, 105.0));
|
||||
let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") };
|
||||
assert_eq!(cmd, "addon.pathtools.extend");
|
||||
assert_eq!((params["id"].clone(), params["end"].clone(), params["mode"].clone()), (json!(id.0), json!("end"), json!("straight")));
|
||||
assert!((params["length"].as_f64().unwrap() - 40.0).abs() < 1e-6);
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Length: 140"))));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 100.0));
|
||||
assert!(matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if (p["length"].as_f64().unwrap() + 40.0).abs() < 1e-6), "{up:?}");
|
||||
// The start end too, and Shift steps the length.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
|
||||
let mut shifted = ev(PointerKind::Drag, 87.4, 100.0);
|
||||
shifted.mods.shift = true;
|
||||
let drag = t.pointer(&cx, &shifted);
|
||||
let Some(Action::Preview(_, params)) = drag.first() else { panic!("{drag:?}") };
|
||||
assert_eq!((params["end"].clone(), params["length"].clone()), (json!("start"), json!(13.0)));
|
||||
// Back on the end: nothing to keep.
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 100.0, 100.0)), vec![Action::Cancel]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn away_from_ends_nothing_happens_and_e_cycles_the_mode() {
|
||||
let (d, _) = doc_with_line();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = ExtendPathTool::default();
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0)).is_empty(), "the middle of the path");
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 100.0)).is_empty());
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
assert!(t.claims_key(&cx, ToolKey::Letter('e')));
|
||||
for want in ["bezier", "arc", "straight"] {
|
||||
t.key(&cx, ToolKey::Letter('e'), Mods::default());
|
||||
assert_eq!(t.options()["mode"], json!(want));
|
||||
}
|
||||
t.set_option("mode", &json!("arc"));
|
||||
t.set_option("mode", &json!("spiral"));
|
||||
assert_eq!(t.options()["mode"], json!("arc"));
|
||||
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::ExtendPath);
|
||||
// A closed path has no ends.
|
||||
let (d, _) = doc_with_rect();
|
||||
let cx = crate::testutil::cx(&d, &sel, &p);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Measure & Inspect tool (Path Precision Tools): drag between two points to read their distance,
|
||||
//! angle and offsets (Shift: 45° steps), which stay shown until the next drag; hover a path to
|
||||
//! read it there: open or closed and its anchor count, the segment's and the path's length, the
|
||||
//! area it encloses, the radius of curvature and the direction it runs at the pointer, and over an
|
||||
//! anchor its angle and handles. Readouts are in the document's units; nothing is drawn into the
|
||||
//! document (`addon.pathtools.inspect` gives agents the same readouts).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitOptions, hit_test};
|
||||
use vectorcraft_doc::{NodeId, Unit};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
use vectorcraft_pathops::addons::pathtools::{Inspection, inspect};
|
||||
|
||||
use crate::xform::measure;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The tool's id.
|
||||
pub const ID: &str = "measureInspect";
|
||||
const CYAN: [u8; 3] = [0x00, 0xa8, 0xff];
|
||||
const ORANGE: [u8; 3] = [0xff, 0x8a, 0x00];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MeasureInspectTool {
|
||||
line: Option<(Point, Point)>,
|
||||
dragging: bool,
|
||||
/// The line measured before the drag under way, which Escape puts back.
|
||||
before: Option<(Point, Point)>,
|
||||
/// The path under the pointer and what it reads there.
|
||||
hover: Option<(NodeId, Inspection)>,
|
||||
}
|
||||
|
||||
/// An area of `pt2` square points in `unit` (`12.5 mm²`).
|
||||
fn area_label(unit: Unit, pt2: f64) -> String {
|
||||
let side = unit.from_pt(1.0);
|
||||
format!("{:.2} {}²", pt2 * side * side, unit.suffix())
|
||||
}
|
||||
|
||||
/// The readouts of `i`, a line each.
|
||||
fn readout(cx: &ToolContext, i: &Inspection) -> String {
|
||||
let mut lines = vec![
|
||||
format!("{} · {} anchors", if i.closed { "Closed path" } else { "Open path" }, i.anchors),
|
||||
format!("Segment: {}", cx.len(i.segment_length)),
|
||||
format!("Path: {}", cx.len(i.length)),
|
||||
format!("Area: {}", area_label(cx.unit, i.area)),
|
||||
format!("Tangent: {:.1}°", i.tangent),
|
||||
match i.radius {
|
||||
Some(r) => format!("Radius: {}", cx.len(r)),
|
||||
None => "Radius: straight".into(),
|
||||
},
|
||||
];
|
||||
if let Some(a) = &i.anchor {
|
||||
let kind = if a.smooth { "smooth" } else { "corner" };
|
||||
lines.push(match a.angle {
|
||||
Some(deg) => format!("Anchor {} ({kind}) · Angle: {deg:.1}°", a.index + 1),
|
||||
None => format!("Anchor {} ({kind}) · open end", a.index + 1),
|
||||
});
|
||||
for (name, h) in [("In", a.handle_in), ("Out", a.handle_out)] {
|
||||
if let Some((len, deg)) = h {
|
||||
lines.push(format!("{name}: {} at {deg:.1}°", cx.len(len)));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
impl MeasureInspectTool {
|
||||
/// The path whose outline is under `p`, and its readouts there.
|
||||
fn inspect_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Inspection)> {
|
||||
// Outlines only: a fill under the pointer is not a path to read.
|
||||
let hit = hit_test(cx.doc, p, HitOptions { outline: true, ..cx.hit_options() })?;
|
||||
let path = cx.doc.node(hit.leaf)?.path_data()?;
|
||||
let i = inspect(path, p, cx.point_tol())?;
|
||||
(i.point.distance(p) <= cx.pick_tol() * 2.0).then_some((hit.leaf, i))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for MeasureInspectTool {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.dragging
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Move => self.hover = Self::inspect_at(cx, ev.pos),
|
||||
PointerKind::Down => {
|
||||
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
|
||||
self.before = self.line;
|
||||
self.line = Some((p, p));
|
||||
self.dragging = true;
|
||||
self.hover = None;
|
||||
}
|
||||
PointerKind::Drag | PointerKind::Up if self.dragging => {
|
||||
if let Some((a, _)) = self.line {
|
||||
let b = if ev.mods.shift {
|
||||
a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle)
|
||||
} else {
|
||||
crate::guides::snap_draw(cx, ev.pos, &[]).0
|
||||
};
|
||||
self.line = Some((a, b));
|
||||
}
|
||||
self.dragging = ev.kind == PointerKind::Drag;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.dragging {
|
||||
self.dragging = false;
|
||||
self.line = self.before;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn hover_ended(&mut self, _cx: &ToolContext) {
|
||||
self.hover = None;
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
let off = Vec2::new(cx.tol(14.0), cx.tol(14.0));
|
||||
if let Some((a, b)) = self.line.filter(|(a, b)| a != b) {
|
||||
let (dist, ang, dx, dy) = measure(a, b);
|
||||
out.push(Overlay::Line { a, b, color: CYAN, dashed: false });
|
||||
out.push(Overlay::Measure { p: b + off, text: format!("D: {}\nAngle: {ang:.1}°\n{}", cx.len(dist), cx.offset_label(dx, dy)) });
|
||||
}
|
||||
if let Some((id, i)) = self.hover.as_ref().filter(|_| !self.dragging) {
|
||||
if let Some(sp) =
|
||||
cx.doc.node(*id).and_then(|n| n.path_data()).and_then(|p| p.subpaths.get(i.subpath)).filter(|sp| i.segment < sp.segment_count())
|
||||
{
|
||||
let c = sp.segment(i.segment);
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to(c.p0);
|
||||
bp.curve_to(c.p1, c.p2, c.p3);
|
||||
out.push(Overlay::Path { path: bp, color: ORANGE, width: 2.0, dashed: false });
|
||||
}
|
||||
let at = i.anchor.as_ref().map_or(i.point, |a| a.p);
|
||||
out.push(Overlay::Anchor { p: at, color: ORANGE, filled: i.anchor.is_some(), size: 6.0 });
|
||||
out.push(Overlay::Measure { p: at + off, text: readout(cx, i) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::MeasureInspect
|
||||
}
|
||||
|
||||
/// The last measurement, as the Measure tool reports it.
|
||||
fn options(&self) -> Value {
|
||||
match self.line {
|
||||
Some((a, b)) => {
|
||||
let (d, ang, dx, dy) = measure(a, b);
|
||||
json!({ "x": a.x, "y": a.y, "distance": d, "angle": ang, "dx": dx, "dy": dy })
|
||||
}
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
#[test]
|
||||
fn a_drag_measures_and_hovering_a_path_reads_it() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let mut t = MeasureInspectTool::default();
|
||||
for (k, x, y) in [(PointerKind::Down, 10.0, 10.0), (PointerKind::Drag, 40.0, -30.0), (PointerKind::Up, 40.0, -30.0)] {
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(k, x, y)).is_empty(), "nothing goes into the document");
|
||||
}
|
||||
assert!((t.options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("D: 50"))));
|
||||
// Hover the rectangle's top edge (100, 100)–(200, 100), then its corner.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 150.0, 101.0));
|
||||
let text = t
|
||||
.overlays(&cx)
|
||||
.into_iter()
|
||||
.filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text) } else { None })
|
||||
.find(|t| t.contains("Segment"))
|
||||
.expect("the readout");
|
||||
assert!(text.contains("Closed path · 4 anchors") && text.contains("Segment: 100") && text.contains("Path: 400"), "{text}");
|
||||
assert!(text.contains("Area: 10000.00 pt²") && text.contains("Radius: straight"), "{text}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 199.0, 101.0));
|
||||
let ov = t.overlays(&cx);
|
||||
assert!(ov.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Angle: 90.0°"))), "{ov:?}");
|
||||
// Away from every outline (inside the fill) there is nothing to read.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 150.0, 150.0));
|
||||
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Segment"))));
|
||||
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::MeasureInspect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_puts_back_the_measurement_before() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let mut t = MeasureInspectTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 30.0, 0.0));
|
||||
t.key(&cx, ToolKey::Escape, Mods::default());
|
||||
assert!(!t.busy());
|
||||
assert_eq!(t.options(), Value::Null);
|
||||
assert_eq!(area_label(Unit::Points, 2.0), "2.00 pt²");
|
||||
}
|
||||
}
|
||||
@@ -137,8 +137,9 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
|
||||
&[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")],
|
||||
];
|
||||
|
||||
/// Every tool: the toolbar's, then the add-ons' ([`crate::addons::GROUPS`]).
|
||||
pub fn all_tools() -> impl Iterator<Item = &'static ToolInfo> {
|
||||
TOOL_GROUPS.iter().flat_map(|g| g.iter())
|
||||
TOOL_GROUPS.iter().chain(crate::addons::GROUPS).flat_map(|g| g.iter())
|
||||
}
|
||||
|
||||
pub fn tool_info(id: &str) -> Option<&'static ToolInfo> {
|
||||
|
||||
@@ -171,6 +171,12 @@ pub enum Cursor {
|
||||
MeshAdd,
|
||||
/// Puppet Warp over the selection's mesh where a pin can go: a click adds one.
|
||||
PuppetPin,
|
||||
/// The Anchor Eraser Brush (Path Precision Tools add-on); the brush's circle is drawn round it.
|
||||
AnchorEraser,
|
||||
/// The Extend Path tool (Path Precision Tools add-on).
|
||||
ExtendPath,
|
||||
/// The Measure & Inspect tool (Path Precision Tools add-on).
|
||||
MeasureInspect,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
@@ -261,7 +267,10 @@ impl Cursor {
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => Cursor::Crosshair,
|
||||
| Cursor::PuppetPin
|
||||
| Cursor::AnchorEraser
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect => Cursor::Crosshair,
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
@@ -295,7 +304,10 @@ impl Cursor {
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors => Some(Cursor::Crosshair),
|
||||
| Cursor::Scissors
|
||||
| Cursor::AnchorEraser
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect => Some(Cursor::Crosshair),
|
||||
Cursor::TypeVertical => Some(Cursor::Text),
|
||||
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
|
||||
Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed),
|
||||
|
||||
@@ -672,6 +672,24 @@ group = "Drawing and editing tools"
|
||||
shown_when = ["The Print Tiling tool: a drag moves the printable page area over the artboard (it snaps to the artboard's edges)."]
|
||||
hotspot = "The page corner being placed."
|
||||
|
||||
[[cursor]]
|
||||
id = "AnchorEraser"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Anchor Eraser Brush (Path Precision Tools add-on): a drag over selected paths removes the anchors under the brush that the outline can do without. The app draws the brush's size as a circle around the cursor."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "ExtendPath"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Extend Path tool (Path Precision Tools add-on): a drag from an end of an open path lengthens or shortens the path there."]
|
||||
hotspot = "Exact: the end being dragged follows it."
|
||||
|
||||
[[cursor]]
|
||||
id = "MeasureInspect"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Measure & Inspect tool (Path Precision Tools add-on): drag to measure distance and angle; hover a path to read its lengths, area, curvature and angles."]
|
||||
hotspot = "Exact, because precision matters here (an open centre suits it)."
|
||||
|
||||
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
|
||||
# The brush size is drawn by the app as a circle around the cursor.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! by the control channel and MCP, and keeps tools testable without a UI.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod addons;
|
||||
pub mod bbox;
|
||||
pub mod builder;
|
||||
pub mod catalog;
|
||||
@@ -291,6 +292,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
||||
.or_else(|| intertwine::create(other))
|
||||
.or_else(|| objects_on_path::create(other))
|
||||
.or_else(|| touchtype::create(other))
|
||||
.or_else(|| addons::create(other))
|
||||
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ const ROWS: &[Row] = &[
|
||||
],
|
||||
),
|
||||
(&["artboard"], None, &["moveArt", "scaleArt"]),
|
||||
// The add-ons' tools (`crate::addons`).
|
||||
(&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]),
|
||||
(&[crate::addons::extend_path::ID], None, &["mode"]),
|
||||
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
|
||||
(
|
||||
&["flare"],
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
|
||||
pub mod cleanup;
|
||||
pub mod color;
|
||||
pub mod pathtools;
|
||||
|
||||
use crate::menus::Item;
|
||||
|
||||
/// The add-ons' submenus, then a separator: the head of Object › Plug-ins.
|
||||
pub fn plugin_menu() -> Vec<Item> {
|
||||
vec![Item::Sub("Artwork Cleanup", cleanup::menu()), Item::Sub("Color Effects", color::menu()), Item::Sep]
|
||||
vec![
|
||||
Item::Sub("Artwork Cleanup", cleanup::menu()),
|
||||
Item::Sub("Color Effects", color::menu()),
|
||||
Item::Sub("Path Precision Tools", pathtools::menu()),
|
||||
Item::Sep,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Path Precision Tools in the interface: its submenu (Object › Plug-ins › Path Precision Tools),
|
||||
//! which selects its tools, opens the Anchor Inspector and runs Split / Add Points at Crossings;
|
||||
//! the Anchor Inspector panel (the selected anchor's position and handles as numbers, and the
|
||||
//! add-on's anchor edits); and its tools' options dialog (a double-click on the tool). Everything
|
||||
//! acts through `addon.pathtools.*` and `tool.setOption`.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::dialogs::DialogSpec;
|
||||
use crate::menus::Item;
|
||||
use crate::panels::{empty_state, pstate, set_pstate};
|
||||
use crate::state::Dialog;
|
||||
use crate::widgets::{self, dim_label};
|
||||
|
||||
/// The Anchor Inspector panel's id (`window.panel {panel: "anchorInspector"}`).
|
||||
pub const ID: &str = "anchorInspector";
|
||||
/// Where the panel keeps the count Add Points adds (panel state).
|
||||
const COUNT: &str = "anchor-inspector-count";
|
||||
/// The tools' options dialog.
|
||||
pub const KIND: &str = "pathToolOptions";
|
||||
/// The close modes, as `addon.pathtools.close` names them, and their labels.
|
||||
const CLOSE_MODES: [(&str, &str); 3] = [("handles", "Keep Handles"), ("straight", "Straight"), ("smooth", "Smooth")];
|
||||
const LABEL_W: f32 = 64.0;
|
||||
|
||||
/// Object › Plug-ins › Path Precision Tools.
|
||||
pub fn menu() -> Vec<Item> {
|
||||
let tool = |label, id: &str| Item::Cmd(label, "tool.select", json!({ "tool": id }));
|
||||
vec![
|
||||
tool("Anchor Eraser Brush", anchor_eraser::ID),
|
||||
tool("Extend Path Tool", extend_path::ID),
|
||||
tool("Measure & Inspect Tool", measure_inspect::ID),
|
||||
Item::Sep,
|
||||
Item::Cmd("Anchor Inspector", "window.panel", json!({ "panel": ID })),
|
||||
Item::Sep,
|
||||
Item::Cmd("Add Points at Crossings", "addon.pathtools.crossings", json!({})),
|
||||
Item::Cmd("Split at Crossings", "addon.pathtools.crossings", json!({ "cut": true })),
|
||||
]
|
||||
}
|
||||
|
||||
/// The first direct-selected anchor (path, subpath, anchor) and how many are selected.
|
||||
fn first_anchor(app: &VectorcraftApp) -> Option<(vectorcraft_geom::Anchor, usize)> {
|
||||
let st = app.session.active()?;
|
||||
let count = st.selection.anchors.values().map(|a| a.len()).sum();
|
||||
let (id, set) = st.selection.anchors.iter().find(|(_, a)| !a.is_empty())?;
|
||||
let (si, ai) = set.first()?;
|
||||
let a = st.doc.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai)?;
|
||||
Some((*a, count))
|
||||
}
|
||||
|
||||
/// A handle's (length, direction in degrees counter-clockwise on the page); `None` for none.
|
||||
fn polar(p: Point, h: Point) -> Option<(f64, f64)> {
|
||||
let v = h - p;
|
||||
(v.hypot() > 1e-9).then(|| (v.hypot(), (-v.y).atan2(v.x).to_degrees() + 0.0))
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
if let Err(e) = app.run(id, p) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else {
|
||||
empty_state(ui, "dc-anchor-smooth", tl!("No document"), tl!("Open a document to edit its anchor points."));
|
||||
return;
|
||||
};
|
||||
let selected = !st.selection.is_empty();
|
||||
let unit = app.session.general_unit();
|
||||
let origin = app.ruler_origin();
|
||||
let anchor = first_anchor(app);
|
||||
let mut set: Option<Value> = None;
|
||||
match anchor {
|
||||
Some((_, n)) => dim_label(ui, &crate::i18n::fmt(tl!("Anchors selected: {count}"), &[("count", &n.to_string())])),
|
||||
None => dim_label(ui, tl!("Direct-select anchor points to edit them.")),
|
||||
};
|
||||
ui.add_enabled_ui(anchor.is_some(), |ui| {
|
||||
let a = anchor.map(|(a, _)| a);
|
||||
egui::Grid::new("anchor-inspector-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
for (label, key, v) in [("X:", "x", a.map(|a| a.p.x - origin.x)), ("Y:", "y", a.map(|a| a.p.y - origin.y))] {
|
||||
dim_label(ui, label);
|
||||
if let Some(v) = widgets::num_field_named(ui, ("anchor-inspector", key), Some(tl!(label)), v, unit, 72.0) {
|
||||
let o = if key == "x" { origin.x } else { origin.y };
|
||||
set = Some(json!({ key: v + o }));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for (name, out) in [("In:", false), ("Out:", true)] {
|
||||
let h = a.and_then(|a| polar(a.p, if out { a.h_out } else { a.h_in }));
|
||||
let side = if out { "out" } else { "in" };
|
||||
dim_label(ui, name);
|
||||
let len = h.map(|h| h.0).or(a.map(|_| 0.0));
|
||||
if let Some(v) = widgets::size_field_named(ui, ("anchor-inspector-len", side), Some(tl!("Length")), len, unit, 72.0) {
|
||||
set = Some(json!({ format!("{side}Length"): v }));
|
||||
}
|
||||
dim_label(ui, "∠");
|
||||
if let Some(v) = widgets::mixed_field_named(ui, ("anchor-inspector-angle", side), Some(tl!("Angle")), h.map(|h| h.1), "°", 1, 56.0) {
|
||||
set = Some(json!({ format!("{side}Angle"): v }));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
widgets::divider(ui);
|
||||
let width = ui.available_width();
|
||||
let mut action: Option<(&str, Value)> = None;
|
||||
ui.add_enabled_ui(selected, |ui| {
|
||||
for (label, id) in [
|
||||
("Make Smooth", "addon.pathtools.smooth"),
|
||||
("Make Corner", "addon.pathtools.corner"),
|
||||
("Equalize Handles", "addon.pathtools.equalize"),
|
||||
("Remove Points, Keep Shape", "addon.pathtools.removeKeepShape"),
|
||||
] {
|
||||
if widgets::flat_button(ui, tl!(label), width).clicked() {
|
||||
action = Some((id, json!({})));
|
||||
}
|
||||
}
|
||||
let mut count: f64 = pstate::<Option<f64>>(ui.ctx(), COUNT).unwrap_or(1.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::flat_button(ui, tl!("Add Points"), width - 70.0).clicked() {
|
||||
action = Some(("addon.pathtools.addPoints", json!({ "count": count })));
|
||||
}
|
||||
if let Some(v) =
|
||||
widgets::range_field_named(ui, "anchor-inspector-count", Some(tl!("Points per segment")), count, 1.0..=100.0, "", 0, 60.0)
|
||||
{
|
||||
count = v;
|
||||
}
|
||||
});
|
||||
set_pstate(ui.ctx(), COUNT, Some(count));
|
||||
widgets::label_row(ui, tl!("Close Path:"), LABEL_W + 16.0, |ui| {
|
||||
for (mode, label) in CLOSE_MODES {
|
||||
if widgets::flat_button_fit(ui, tl!(label), 40.0).clicked() {
|
||||
action = Some(("addon.pathtools.close", json!({ "mode": mode })));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(p) = set {
|
||||
run(app, "addon.pathtools.setAnchor", p);
|
||||
}
|
||||
if let Some((id, p)) = action {
|
||||
run(app, id, p);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the tools' options ----------
|
||||
|
||||
/// Tools whose options the dialog edits.
|
||||
pub fn has_options(tool: &str) -> bool {
|
||||
[anchor_eraser::ID, extend_path::ID].contains(&tool)
|
||||
}
|
||||
|
||||
/// Open the options of add-on tool `tool` with its current values.
|
||||
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<Value, String> {
|
||||
let mut fields: Map<String, Value> = app.session.tool_options_of(tool).as_object().cloned().unwrap_or_default();
|
||||
fields.insert("__tool".into(), json!(tool));
|
||||
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
|
||||
Ok(json!({ "dialog": KIND }))
|
||||
}
|
||||
|
||||
pub(crate) const OPTIONS_SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 280.0, ..DialogSpec::FORM };
|
||||
|
||||
fn heading(d: &Dialog) -> String {
|
||||
let label = vectorcraft_tools::tool_info(&d.str("__tool")).map_or("", |t| t.label);
|
||||
crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(label))])
|
||||
}
|
||||
|
||||
fn body(_app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
if d.str("__tool") == extend_path::ID {
|
||||
let modes = [("straight", "Straight"), ("bezier", "Stretch Curve"), ("arc", "Arc")];
|
||||
let labels: Vec<&str> = modes.iter().map(|(_, l)| *l).collect();
|
||||
let current = modes.iter().find(|(m, _)| *m == d.str("mode")).map_or("Straight", |(_, l)| *l);
|
||||
widgets::label_row(ui, tl!("Mode:"), LABEL_W, |ui| {
|
||||
if let Some((m, _)) = widgets::dropdown(ui, "extend-path-mode", current, &labels, 140.0).and_then(|i| modes.get(i)) {
|
||||
d.fields.insert("mode".into(), json!(m));
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
let rows = [
|
||||
("size", "Size:", anchor_eraser::SIZE_RANGE, "px", anchor_eraser::SIZE),
|
||||
("tolerance", "Tolerance:", anchor_eraser::TOLERANCE_RANGE, "", anchor_eraser::TOLERANCE),
|
||||
];
|
||||
for (key, label, (lo, hi), suffix, default) in rows {
|
||||
let v = d.f64(key, default);
|
||||
widgets::label_row(ui, tl!(label), LABEL_W, |ui| {
|
||||
if let Some(x) = widgets::range_field(ui, ("anchor-eraser", key), v, lo..=hi, suffix, 0, 72.0) {
|
||||
d.fields.insert(key.into(), json!(x));
|
||||
}
|
||||
});
|
||||
}
|
||||
if widgets::check(ui, "Pressure", d.bool("pressure"), true) {
|
||||
let on = !d.bool("pressure");
|
||||
d.fields.insert("pressure".into(), json!(on));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// OK: set the tool's options.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let tool = d.str("__tool");
|
||||
let values: Map<String, Value> = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
let r = app.run("tool.setOption", json!({ "tool": tool, "values": values }));
|
||||
app.ui.dialog = None;
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,86 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::{ID, KIND, show};
|
||||
use crate::tests_labels::painted_text;
|
||||
use crate::{VectorcraftApp, menus};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() {
|
||||
let mut app = app();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let under = |label: &str| entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"] && e.label == label);
|
||||
for (label, tool) in
|
||||
[("Anchor Eraser Brush", "anchorEraserBrush"), ("Extend Path Tool", "extendPath"), ("Measure & Inspect Tool", "measureInspect")]
|
||||
{
|
||||
let e = under(label).expect(label);
|
||||
assert_eq!((e.command.as_deref(), &e.params), (Some("tool.select"), &json!({ "tool": tool })));
|
||||
menus::invoke(&mut app, "tool.select", e.params.clone());
|
||||
assert_eq!(app.session.tool_id(), tool);
|
||||
}
|
||||
for (label, cut) in [("Add Points at Crossings", false), ("Split at Crossings", true)] {
|
||||
let e = under(label).expect(label);
|
||||
assert_eq!(e.command.as_deref(), Some("addon.pathtools.crossings"));
|
||||
assert_eq!(e.params.get("cut").and_then(|v| v.as_bool()).unwrap_or(false), cut);
|
||||
}
|
||||
// Artwork Cleanup's submenu and Install Plug-in… are still there.
|
||||
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins", "Artwork Cleanup"]));
|
||||
assert!(entries.iter().any(|e| e.path == ["Object", "Plug-ins"] && e.label == "Install Plug-in…"));
|
||||
let inspector = under("Anchor Inspector").expect("listed");
|
||||
assert!(!crate::panel_docking::showing(&app.ui, ID));
|
||||
menus::invoke(&mut app, "window.panel", inspector.params.clone());
|
||||
assert!(crate::panel_docking::showing(&app.ui, ID), "the panel opens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inspector_shows_the_selected_anchor_and_its_buttons_edit() {
|
||||
let mut app = app();
|
||||
let r = app.run("path.create", json!({"anchors": [{"x": 10, "y": 10}, {"x": 110, "y": 10}, {"x": 110, "y": 110}], "closed": false})).unwrap();
|
||||
let id = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap());
|
||||
let text = painted_text(&mut app, show);
|
||||
assert!(text.contains("Direct-select anchor points to edit them.") && text.contains("Make Smooth") && text.contains("Close Path:"), "{text}");
|
||||
app.session
|
||||
.select(|_, sel| {
|
||||
sel.set([id]);
|
||||
sel.anchors.insert(id, [(0, 1)].into_iter().collect());
|
||||
})
|
||||
.unwrap();
|
||||
let text = painted_text(&mut app, show);
|
||||
assert!(text.contains("Anchors selected: 1"), "{text}");
|
||||
// The panel's buttons run the add-on's commands, each one undo step.
|
||||
let steps = app.session.doc().unwrap().history.undo.len();
|
||||
app.run("addon.pathtools.smooth", json!({})).unwrap();
|
||||
let a = app.session.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[1];
|
||||
assert!(a.has_in() && a.has_out());
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_the_brush_opens_its_options_and_ok_sets_them() {
|
||||
let mut app = app();
|
||||
app.run("tool.options", json!({"tool": "anchorEraserBrush"})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().expect("the dialog");
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!((d.f64("size", 0.0), d.f64("tolerance", 0.0), d.bool("pressure")), (20.0, 10.0, false));
|
||||
let text = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||||
assert!(text.contains("Anchor Eraser Brush Options") && text.contains("Tolerance:") && text.contains("Pressure"), "{text}");
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("size".into(), json!(64));
|
||||
d.fields.insert("pressure".into(), json!(true));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let o = app.session.tool_options_of("anchorEraserBrush");
|
||||
assert_eq!((o["size"].as_f64(), o["pressure"].as_bool()), (Some(64.0), Some(true)));
|
||||
// The Extend Path tool's mode; the Measure & Inspect tool has none.
|
||||
app.run("tool.options", json!({"tool": "extendPath"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("arc"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.tool_options_of("extendPath")["mode"], json!("arc"));
|
||||
assert!(app.run("tool.options", json!({"tool": "measureInspect"})).is_err());
|
||||
}
|
||||
@@ -147,6 +147,9 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
| Cursor::Scissors
|
||||
| Cursor::ScissorsNotAllowed
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
| Cursor::PuppetPin
|
||||
| Cursor::AnchorEraser
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
|
||||
|
||||
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
|
||||
#[cfg(test)]
|
||||
const GLYPHS: [Cursor; 81] = [
|
||||
const GLYPHS: [Cursor; 84] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
@@ -522,6 +522,9 @@ const GLYPHS: [Cursor; 81] = [
|
||||
Cursor::Scissors,
|
||||
Cursor::MeshAdd,
|
||||
Cursor::PuppetPin,
|
||||
Cursor::AnchorEraser,
|
||||
Cursor::ExtendPath,
|
||||
Cursor::MeasureInspect,
|
||||
];
|
||||
|
||||
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
|
||||
|
||||
@@ -385,6 +385,7 @@ registry! {
|
||||
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
|
||||
PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC,
|
||||
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
|
||||
PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC,
|
||||
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
|
||||
}
|
||||
|
||||
|
||||
@@ -349,6 +349,9 @@ fn heading(id: &str) -> &'static str {
|
||||
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
|
||||
return cat;
|
||||
}
|
||||
if let Some(addon) = vectorcraft_tools::addons::heading(id) {
|
||||
return addon;
|
||||
}
|
||||
match id {
|
||||
_ if id.starts_with("symbol") => "Symbols",
|
||||
_ if id.ends_with("Graph") => "Graph",
|
||||
@@ -365,7 +368,7 @@ fn sections() -> &'static [(&'static str, Vec<&'static ToolInfo>)] {
|
||||
static SECTIONS: OnceLock<Vec<(&'static str, Vec<&'static ToolInfo>)>> = OnceLock::new();
|
||||
SECTIONS.get_or_init(|| {
|
||||
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
|
||||
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
|
||||
for tool in vectorcraft_tools::catalog::all_tools() {
|
||||
let h = heading(tool.id);
|
||||
match out.iter_mut().find(|(c, _)| *c == h) {
|
||||
Some((_, tools)) => tools.push(tool),
|
||||
@@ -387,7 +390,7 @@ fn groups() -> &'static [Heading] {
|
||||
static GROUPS: OnceLock<Vec<Heading>> = OnceLock::new();
|
||||
GROUPS.get_or_init(|| {
|
||||
let mut out: Vec<Heading> = sections().iter().map(|(cat, _)| (*cat, vec![])).collect();
|
||||
for g in TOOL_GROUPS {
|
||||
for g in TOOL_GROUPS.iter().chain(vectorcraft_tools::addons::GROUPS) {
|
||||
let Some(first) = g.first() else { continue };
|
||||
let count = |h: &str| g.iter().filter(|t| heading(t.id) == h).count();
|
||||
let h = g.iter().map(|t| heading(t.id)).fold(heading(first.id), |best, h| if count(h) > count(best) { h } else { best });
|
||||
|
||||
@@ -97,7 +97,7 @@ fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2
|
||||
#[test]
|
||||
fn the_drawer_frames_every_flyout_group_once() {
|
||||
let listed: Vec<&str> = groups().iter().flat_map(|(_, gs)| gs.iter().flat_map(|g| g.iter().map(|t| t.id))).collect();
|
||||
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = vectorcraft_tools::catalog::all_tools().map(|t| t.id).collect();
|
||||
assert_eq!(listed.len(), all.len());
|
||||
assert!(all.iter().all(|id| listed.contains(id)));
|
||||
let under = |id: &str| groups().iter().find(|(_, gs)| gs.iter().any(|g| g.iter().any(|t| t.id == id))).map(|(c, _)| *c);
|
||||
@@ -133,7 +133,7 @@ fn toolbar_show_turns_the_controls_under_the_tools_on_and_off() {
|
||||
fn every_tool_is_listed_once_under_a_heading() {
|
||||
let s = sections();
|
||||
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = vectorcraft_tools::catalog::all_tools().map(|t| t.id).collect();
|
||||
assert_eq!(listed.len(), all.len());
|
||||
assert!(all.iter().all(|id| listed.contains(id)));
|
||||
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
|
||||
@@ -372,3 +372,23 @@ fn drop_targets_follow_the_buttons_edges() {
|
||||
assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After));
|
||||
assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before));
|
||||
}
|
||||
|
||||
/// The add-ons' tools are in neither toolbar as they ship: the drawer lists them under their
|
||||
/// add-on's heading, and they can be added from there.
|
||||
#[test]
|
||||
fn addon_tools_are_in_the_drawer_not_the_toolbars() {
|
||||
let mut app = app();
|
||||
let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect"]);
|
||||
for advanced in [false, true] {
|
||||
if advanced {
|
||||
app.run("window.toolbarAdvanced", json!({})).unwrap();
|
||||
}
|
||||
assert!(addon.iter().all(|id| slot_of(&app, id).is_none()), "advanced: {advanced}");
|
||||
}
|
||||
let (heading, tools) = sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == "extendPath")).expect("listed");
|
||||
assert_eq!(*heading, "Path Precision Tools");
|
||||
assert_eq!(tools.iter().map(|t| t.id).collect::<Vec<_>>(), addon);
|
||||
app.run("toolbar.addTool", json!({"tool": "extendPath"})).unwrap();
|
||||
assert!(slot_of(&app, "extendPath").is_some());
|
||||
}
|
||||
|
||||
@@ -5654,3 +5654,36 @@ anchor Points: Точки:
|
||||
@msg `{id}` is not a colour adjustment `{id}` не е цветова корекция
|
||||
@msg missing `effect` липсва `effect`
|
||||
@msg `params` must be an object `params` трябва да бъде обект
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Инструменти за прецизни пътища
|
||||
Anchor Eraser Brush Четка за изтриване на опорни точки
|
||||
Extend Path Tool Инструмент „Удължаване на път“
|
||||
Measure & Inspect Tool Инструмент „Измерване и проверка“
|
||||
Anchor Inspector Инспектор на опорни точки
|
||||
Add Points at Crossings Добавяне на точки в пресичанията
|
||||
Split at Crossings Разделяне в пресичанията
|
||||
Extend Path Удължаване на път
|
||||
Measure & Inspect Измерване и проверка
|
||||
Set Anchor Задаване на опорна точка
|
||||
Make Smooth Направи гладка
|
||||
Make Corner Направи ъглова
|
||||
Equalize Handles Изравняване на дръжките
|
||||
Remove Points, Keep Shape Премахване на точки, запазване на формата
|
||||
Add Points to Segments… Добавяне на точки към сегменти…
|
||||
Add Points to Segments Добавяне на точки към сегменти
|
||||
Open a document to edit its anchor points. Отворете документ, за да редактирате опорните му точки.
|
||||
Anchors selected: {count} Избрани опорни точки: {count}
|
||||
Direct-select anchor points to edit them. Изберете опорните точки директно, за да ги редактирате.
|
||||
In: Вход:
|
||||
Out: Изход:
|
||||
Add Points Добавяне на точки
|
||||
Points per segment Точки на сегмент
|
||||
Close Path: Затваряне на пътя:
|
||||
Keep Handles Запазване на дръжките
|
||||
Straight Прав
|
||||
Stretch Curve Разтягане на кривата
|
||||
@msg direct-select the anchor points to remove изберете директно опорните точки за премахване
|
||||
@msg select segments, or paths, to add points to изберете сегменти или пътища, към които да се добавят точки
|
||||
@msg select open paths to close изберете отворени пътища за затваряне
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) пътищата са твърде много или твърде заплетени, за да се намерят пресичанията им (най-много 10000 сегмента)
|
||||
@msg the paths have too many anchors to edit at once пътищата имат твърде много опорни точки, за да се редактират наведнъж
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5755,3 +5755,36 @@ anchor Points: Punts:
|
||||
@msg `{id}` is not a colour adjustment `{id}` no és un ajust de color
|
||||
@msg missing `effect` falta `effect`
|
||||
@msg `params` must be an object `params` ha de ser un objecte
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Eines de precisió de traçats
|
||||
Anchor Eraser Brush Pinzell esborrador de punts d’ancoratge
|
||||
Extend Path Tool Eina d’allargar el traçat
|
||||
Measure & Inspect Tool Eina de mesurar i inspeccionar
|
||||
Anchor Inspector Inspector de punts d’ancoratge
|
||||
Add Points at Crossings Afegeix punts als encreuaments
|
||||
Split at Crossings Divideix als encreuaments
|
||||
Extend Path Allarga el traçat
|
||||
Measure & Inspect Mesura i inspecciona
|
||||
Set Anchor Defineix el punt d’ancoratge
|
||||
Make Smooth Fes-lo suau
|
||||
Make Corner Fes-lo vèrtex
|
||||
Equalize Handles Iguala les nanses
|
||||
Remove Points, Keep Shape Elimina punts, mantén la forma
|
||||
Add Points to Segments… Afegeix punts als segments…
|
||||
Add Points to Segments Afegeix punts als segments
|
||||
Open a document to edit its anchor points. Obriu un document per editar-ne els punts d’ancoratge.
|
||||
Anchors selected: {count} Punts d’ancoratge seleccionats: {count}
|
||||
Direct-select anchor points to edit them. Seleccioneu directament punts d’ancoratge per editar-los.
|
||||
In: Entrada:
|
||||
Out: Sortida:
|
||||
Add Points Afegeix punts
|
||||
Points per segment Punts per segment
|
||||
Close Path: Tanca el traçat:
|
||||
Keep Handles Mantén les nanses
|
||||
Straight Recte
|
||||
Stretch Curve Estira la corba
|
||||
@msg direct-select the anchor points to remove seleccioneu directament els punts d’ancoratge que voleu eliminar
|
||||
@msg select segments, or paths, to add points to seleccioneu segments, o traçats, als quals afegir punts
|
||||
@msg select open paths to close seleccioneu traçats oberts per tancar-los
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) massa traçats, o massa enredats, per trobar-ne els encreuaments (10000 segments com a màxim)
|
||||
@msg the paths have too many anchors to edit at once els traçats tenen massa punts d’ancoratge per editar-los alhora
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5764,3 +5764,36 @@ anchor Points: Body:
|
||||
@msg `{id}` is not a colour adjustment `{id}` není úprava barev
|
||||
@msg missing `effect` chybí `effect`
|
||||
@msg `params` must be an object `params` musí být objekt
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Přesné nástroje pro cesty
|
||||
Anchor Eraser Brush Štětec gumy kotevních bodů
|
||||
Extend Path Tool Nástroj Prodloužit cestu
|
||||
Measure & Inspect Tool Nástroj Měřit a zkoumat
|
||||
Anchor Inspector Inspektor kotevních bodů
|
||||
Add Points at Crossings Přidat body v křížení
|
||||
Split at Crossings Rozdělit v křížení
|
||||
Extend Path Prodloužit cestu
|
||||
Measure & Inspect Měřit a zkoumat
|
||||
Set Anchor Nastavit kotevní bod
|
||||
Make Smooth Změnit na hladký
|
||||
Make Corner Změnit na rohový
|
||||
Equalize Handles Vyrovnat úchyty
|
||||
Remove Points, Keep Shape Odstranit body, zachovat tvar
|
||||
Add Points to Segments… Přidat body do segmentů…
|
||||
Add Points to Segments Přidat body do segmentů
|
||||
Open a document to edit its anchor points. Otevřete dokument a upravte jeho kotevní body.
|
||||
Anchors selected: {count} Vybrané kotevní body: {count}
|
||||
Direct-select anchor points to edit them. Kotevní body pro úpravy vyberte přímým výběrem.
|
||||
In: Vstup:
|
||||
Out: Výstup:
|
||||
Add Points Přidat body
|
||||
Points per segment Bodů na segment
|
||||
Close Path: Uzavřít cestu:
|
||||
Keep Handles Zachovat úchyty
|
||||
Straight Přímý
|
||||
Stretch Curve Natáhnout křivku
|
||||
@msg direct-select the anchor points to remove přímým výběrem vyberte kotevní body k odstranění
|
||||
@msg select segments, or paths, to add points to vyberte segmenty nebo cesty, do nichž přidat body
|
||||
@msg select open paths to close vyberte otevřené cesty k uzavření
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) příliš mnoho nebo příliš spletených cest pro nalezení jejich křížení (nejvýše 10000 segmentů)
|
||||
@msg the paths have too many anchors to edit at once cesty mají příliš mnoho kotevních bodů na úpravu najednou
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5669,3 +5669,36 @@ anchor Points: Punkter:
|
||||
@msg `{id}` is not a colour adjustment `{id}` er ikke en farvejustering
|
||||
@msg missing `effect` `effect` mangler
|
||||
@msg `params` must be an object `params` skal være et objekt
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Præcisionsværktøjer til stier
|
||||
Anchor Eraser Brush Ankerpunktviskepensel
|
||||
Extend Path Tool Forlæng sti-værktøj
|
||||
Measure & Inspect Tool Mål og undersøg-værktøj
|
||||
Anchor Inspector Ankerpunktinspektør
|
||||
Add Points at Crossings Tilføj punkter ved krydsninger
|
||||
Split at Crossings Opdel ved krydsninger
|
||||
Extend Path Forlæng sti
|
||||
Measure & Inspect Mål og undersøg
|
||||
Set Anchor Angiv ankerpunkt
|
||||
Make Smooth Gør blødt
|
||||
Make Corner Gør til hjørne
|
||||
Equalize Handles Udlign håndtag
|
||||
Remove Points, Keep Shape Fjern punkter, bevar form
|
||||
Add Points to Segments… Tilføj punkter til segmenter…
|
||||
Add Points to Segments Tilføj punkter til segmenter
|
||||
Open a document to edit its anchor points. Åbn et dokument for at redigere dets ankerpunkter.
|
||||
Anchors selected: {count} Valgte ankerpunkter: {count}
|
||||
Direct-select anchor points to edit them. Vælg ankerpunkter direkte for at redigere dem.
|
||||
In: Ind:
|
||||
Out: Ud:
|
||||
Add Points Tilføj punkter
|
||||
Points per segment Punkter pr. segment
|
||||
Close Path: Luk sti:
|
||||
Keep Handles Bevar håndtag
|
||||
Straight Lige
|
||||
Stretch Curve Stræk kurve
|
||||
@msg direct-select the anchor points to remove vælg de ankerpunkter, der skal fjernes, direkte
|
||||
@msg select segments, or paths, to add points to vælg segmenter eller stier at tilføje punkter til
|
||||
@msg select open paths to close vælg åbne stier at lukke
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) for mange eller for sammenfiltrede stier til at finde deres krydsninger (højst 10000 segmenter)
|
||||
@msg the paths have too many anchors to edit at once stierne har for mange ankerpunkter til at blive redigeret på én gang
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5346,3 +5346,37 @@ anchor Points: Punkte:
|
||||
@msg `{id}` is not a colour adjustment `{id}` ist keine Farbkorrektur
|
||||
@msg missing `effect` `effect` fehlt
|
||||
@msg `params` must be an object `params` muss ein Objekt sein
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Pfad-Präzisionswerkzeuge
|
||||
Anchor Eraser Brush Ankerpunkt-Radierpinsel
|
||||
Extend Path Tool Pfad-verlängern-Werkzeug
|
||||
Measure & Inspect Tool Messen-und-Prüfen-Werkzeug
|
||||
Anchor Inspector Ankerpunkt-Inspektor
|
||||
Add Points at Crossings Punkte an Kreuzungen hinzufügen
|
||||
Split at Crossings An Kreuzungen teilen
|
||||
Extend Path Pfad verlängern
|
||||
Measure & Inspect Messen und prüfen
|
||||
Set Anchor Ankerpunkt festlegen
|
||||
Make Smooth Glätten
|
||||
Make Corner Zu Eckpunkt machen
|
||||
Equalize Handles Griffe angleichen
|
||||
Remove Points, Keep Shape Punkte entfernen, Form erhalten
|
||||
Add Points to Segments… Punkte zu Segmenten hinzufügen…
|
||||
Add Points to Segments Punkte zu Segmenten hinzufügen
|
||||
Open a document to edit its anchor points. Öffnen Sie ein Dokument, um seine Ankerpunkte zu bearbeiten.
|
||||
Anchors selected: {count} Ausgewählte Ankerpunkte: {count}
|
||||
Direct-select anchor points to edit them. Wählen Sie Ankerpunkte direkt aus, um sie zu bearbeiten.
|
||||
Length Länge
|
||||
In: Ein:
|
||||
Out: Aus:
|
||||
Add Points Punkte hinzufügen
|
||||
Points per segment Punkte pro Segment
|
||||
Close Path: Pfad schließen:
|
||||
Keep Handles Griffe beibehalten
|
||||
Straight Gerade
|
||||
Stretch Curve Kurve strecken
|
||||
@msg direct-select the anchor points to remove die zu entfernenden Ankerpunkte direkt auswählen
|
||||
@msg select segments, or paths, to add points to Segmente oder Pfade auswählen, denen Punkte hinzugefügt werden sollen
|
||||
@msg select open paths to close offene Pfade zum Schließen auswählen
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) zu viele oder zu verschlungene Pfade, um ihre Kreuzungen zu finden (höchstens 10000 Segmente)
|
||||
@msg the paths have too many anchors to edit at once die Pfade haben zu viele Ankerpunkte, um sie auf einmal zu bearbeiten
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5716,3 +5716,36 @@ anchor Points: Σημεία:
|
||||
@msg `{id}` is not a colour adjustment Το `{id}` δεν είναι διόρθωση χρώματος
|
||||
@msg missing `effect` λείπει το `effect`
|
||||
@msg `params` must be an object Το `params` πρέπει να είναι αντικείμενο
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Εργαλεία ακρίβειας διαδρομών
|
||||
Anchor Eraser Brush Πινέλο διαγραφής σημείων αγκύρωσης (Anchor Eraser Brush)
|
||||
Extend Path Tool Επέκταση διαδρομής (Extend Path Tool)
|
||||
Measure & Inspect Tool Μέτρηση και επιθεώρηση (Measure & Inspect Tool)
|
||||
Anchor Inspector Επιθεωρητής σημείων αγκύρωσης
|
||||
Add Points at Crossings Προσθήκη σημείων στις διασταυρώσεις
|
||||
Split at Crossings Διαχωρισμός στις διασταυρώσεις
|
||||
Extend Path Επέκταση διαδρομής
|
||||
Measure & Inspect Μέτρηση και επιθεώρηση
|
||||
Set Anchor Ορισμός σημείου αγκύρωσης
|
||||
Make Smooth Μετατροπή σε ομαλό
|
||||
Make Corner Μετατροπή σε γωνία
|
||||
Equalize Handles Εξίσωση λαβών
|
||||
Remove Points, Keep Shape Αφαίρεση σημείων, διατήρηση σχήματος
|
||||
Add Points to Segments… Προσθήκη σημείων σε τμήματα…
|
||||
Add Points to Segments Προσθήκη σημείων σε τμήματα
|
||||
Open a document to edit its anchor points. Ανοίξτε ένα έγγραφο για να επεξεργαστείτε τα σημεία αγκύρωσής του.
|
||||
Anchors selected: {count} Επιλεγμένα σημεία αγκύρωσης: {count}
|
||||
Direct-select anchor points to edit them. Επιλέξτε απευθείας σημεία αγκύρωσης για να τα επεξεργαστείτε.
|
||||
In: Είσοδος:
|
||||
Out: Έξοδος:
|
||||
Add Points Προσθήκη σημείων
|
||||
Points per segment Σημεία ανά τμήμα
|
||||
Close Path: Κλείσιμο διαδρομής:
|
||||
Keep Handles Διατήρηση λαβών
|
||||
Straight Ευθύ
|
||||
Stretch Curve Τέντωμα καμπύλης
|
||||
@msg direct-select the anchor points to remove επιλέξτε απευθείας τα σημεία αγκύρωσης προς αφαίρεση
|
||||
@msg select segments, or paths, to add points to επιλέξτε τμήματα ή διαδρομές στα οποία θα προστεθούν σημεία
|
||||
@msg select open paths to close επιλέξτε ανοιχτές διαδρομές για κλείσιμο
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) οι διαδρομές είναι πάρα πολλές ή πολύ μπλεγμένες για να βρεθούν οι διασταυρώσεις τους (έως 10000 τμήματα)
|
||||
@msg the paths have too many anchors to edit at once οι διαδρομές έχουν πάρα πολλά σημεία αγκύρωσης για επεξεργασία με μία φορά
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 164 and column 5.
|
@@ -5706,3 +5706,37 @@ anchor Points: Puntos:
|
||||
@msg `{id}` is not a colour adjustment `{id}` no es un ajuste de color
|
||||
@msg missing `effect` falta `effect`
|
||||
@msg `params` must be an object `params` debe ser un objeto
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Herramientas de precisión de trazados
|
||||
Anchor Eraser Brush Pincel borrador de puntos de ancla
|
||||
Extend Path Tool Herramienta Prolongar trazado
|
||||
Measure & Inspect Tool Herramienta Medir e inspeccionar
|
||||
Anchor Inspector Inspector de puntos de ancla
|
||||
Add Points at Crossings Agregar puntos en los cruces
|
||||
Split at Crossings Dividir en los cruces
|
||||
Extend Path Prolongar trazado
|
||||
Measure & Inspect Medir e inspeccionar
|
||||
Set Anchor Definir punto de ancla
|
||||
Make Smooth Suavizar punto
|
||||
Make Corner Convertir en vértice
|
||||
Equalize Handles Igualar manejadores
|
||||
Remove Points, Keep Shape Quitar puntos, mantener forma
|
||||
Add Points to Segments… Agregar puntos a segmentos…
|
||||
Add Points to Segments Agregar puntos a segmentos
|
||||
Open a document to edit its anchor points. Abra un documento para editar sus puntos de ancla.
|
||||
Anchors selected: {count} Puntos de ancla seleccionados: {count}
|
||||
Direct-select anchor points to edit them. Seleccione directamente puntos de ancla para editarlos.
|
||||
Length Longitud
|
||||
In: Entrada:
|
||||
Out: Salida:
|
||||
Add Points Agregar puntos
|
||||
Points per segment Puntos por segmento
|
||||
Close Path: Cerrar trazado:
|
||||
Keep Handles Mantener manejadores
|
||||
Straight Recto
|
||||
Stretch Curve Estirar curva
|
||||
@msg direct-select the anchor points to remove seleccione directamente los puntos de ancla que quitar
|
||||
@msg select segments, or paths, to add points to seleccione segmentos, o trazados, a los que agregar puntos
|
||||
@msg select open paths to close seleccione trazados abiertos que cerrar
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) hay demasiados trazados, o están demasiado enredados, para hallar sus cruces (10000 segmentos como máximo)
|
||||
@msg the paths have too many anchors to edit at once los trazados tienen demasiados puntos de ancla para editarlos a la vez
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5795,3 +5795,36 @@ anchor Points: Pisteet:
|
||||
@msg `{id}` is not a colour adjustment `{id}` ei ole värisäätö
|
||||
@msg missing `effect` `effect` puuttuu
|
||||
@msg `params` must be an object `params` on oltava objekti
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Polkujen tarkkuustyökalut
|
||||
Anchor Eraser Brush Ankkuripisteiden pyyhintäsivellin
|
||||
Extend Path Tool Polun jatkamistyökalu
|
||||
Measure & Inspect Tool Mittaus- ja tarkastelutyökalu
|
||||
Anchor Inspector Ankkuripisteiden tarkastelu
|
||||
Add Points at Crossings Lisää pisteet risteyskohtiin
|
||||
Split at Crossings Jaa risteyskohdista
|
||||
Extend Path Jatka polkua
|
||||
Measure & Inspect Mittaa ja tarkastele
|
||||
Set Anchor Aseta ankkuripiste
|
||||
Make Smooth Tee tasaiseksi
|
||||
Make Corner Tee kulmapisteeksi
|
||||
Equalize Handles Tasaa kahvat
|
||||
Remove Points, Keep Shape Poista pisteet, säilytä muoto
|
||||
Add Points to Segments… Lisää pisteitä osiin…
|
||||
Add Points to Segments Lisää pisteitä osiin
|
||||
Open a document to edit its anchor points. Avaa asiakirja muokataksesi sen ankkuripisteitä.
|
||||
Anchors selected: {count} Valittuja ankkuripisteitä: {count}
|
||||
Direct-select anchor points to edit them. Valitse ankkuripisteet suoraan muokataksesi niitä.
|
||||
In: Sisään:
|
||||
Out: Ulos:
|
||||
Add Points Lisää pisteitä
|
||||
Points per segment Pisteitä osaa kohden
|
||||
Close Path: Sulje polku:
|
||||
Keep Handles Säilytä kahvat
|
||||
Straight Suora
|
||||
Stretch Curve Venytä käyrää
|
||||
@msg direct-select the anchor points to remove valitse poistettavat ankkuripisteet suoraan
|
||||
@msg select segments, or paths, to add points to valitse osat tai polut, joihin pisteitä lisätään
|
||||
@msg select open paths to close valitse suljettavat avoimet polut
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) liian monta tai liian sotkuista polkua niiden risteyskohtien löytämiseen (enintään 10000 osaa)
|
||||
@msg the paths have too many anchors to edit at once poluissa on liian monta ankkuripistettä muokattavaksi kerralla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5763,3 +5763,36 @@ anchor Points: Points :
|
||||
@msg `{id}` is not a colour adjustment `{id}` n'est pas un réglage de couleur
|
||||
@msg missing `effect` `effect` manquant
|
||||
@msg `params` must be an object `params` doit être un objet
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Outils de précision des tracés
|
||||
Anchor Eraser Brush Pinceau effaceur de points d’ancrage
|
||||
Extend Path Tool Outil Prolonger le tracé
|
||||
Measure & Inspect Tool Outil Mesurer et inspecter
|
||||
Anchor Inspector Inspecteur de points d’ancrage
|
||||
Add Points at Crossings Ajouter des points aux croisements
|
||||
Split at Crossings Scinder aux croisements
|
||||
Extend Path Prolonger le tracé
|
||||
Measure & Inspect Mesurer et inspecter
|
||||
Set Anchor Définir le point d’ancrage
|
||||
Make Smooth Rendre lisse
|
||||
Make Corner Rendre angulaire
|
||||
Equalize Handles Égaliser les poignées
|
||||
Remove Points, Keep Shape Supprimer des points, conserver la forme
|
||||
Add Points to Segments… Ajouter des points aux segments…
|
||||
Add Points to Segments Ajouter des points aux segments
|
||||
Open a document to edit its anchor points. Ouvrez un document pour modifier ses points d’ancrage.
|
||||
Anchors selected: {count} Points d’ancrage sélectionnés : {count}
|
||||
Direct-select anchor points to edit them. Sélectionnez directement des points d’ancrage pour les modifier.
|
||||
In: Entrée :
|
||||
Out: Sortie :
|
||||
Add Points Ajouter des points
|
||||
Points per segment Points par segment
|
||||
Close Path: Fermer le tracé :
|
||||
Keep Handles Conserver les poignées
|
||||
Straight Droit
|
||||
Stretch Curve Étirer la courbe
|
||||
@msg direct-select the anchor points to remove sélectionnez directement les points d’ancrage à supprimer
|
||||
@msg select segments, or paths, to add points to sélectionnez des segments, ou des tracés, auxquels ajouter des points
|
||||
@msg select open paths to close sélectionnez des tracés ouverts à fermer
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) trop de tracés, ou trop enchevêtrés, pour trouver leurs croisements (10000 segments au plus)
|
||||
@msg the paths have too many anchors to edit at once les tracés ont trop de points d’ancrage pour être modifiés en une fois
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5750,3 +5750,37 @@ anchor Points: Pontok:
|
||||
@msg `{id}` is not a colour adjustment `{id}` nem színkorrekció
|
||||
@msg missing `effect` hiányzik: `effect`
|
||||
@msg `params` must be an object `params`: objektum kell
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Útvonal-precíziós eszközök
|
||||
Anchor Eraser Brush Horgonypont-radírecset
|
||||
Extend Path Tool Útvonal meghosszabbítása eszköz
|
||||
Measure & Inspect Tool Mérés és vizsgálat eszköz
|
||||
Anchor Inspector Horgonypont-vizsgáló
|
||||
Add Points at Crossings Pontok hozzáadása a kereszteződésekben
|
||||
Split at Crossings Felosztás a kereszteződésekben
|
||||
Extend Path Útvonal meghosszabbítása
|
||||
Measure & Inspect Mérés és vizsgálat
|
||||
Set Anchor Horgonypont beállítása
|
||||
Make Smooth Simává alakítás
|
||||
Make Corner Sarokká alakítás
|
||||
Equalize Handles Fogantyúk kiegyenlítése
|
||||
Remove Points, Keep Shape Pontok eltávolítása, alak megtartása
|
||||
Add Points to Segments… Pontok hozzáadása a szakaszokhoz…
|
||||
Add Points to Segments Pontok hozzáadása a szakaszokhoz
|
||||
Open a document to edit its anchor points. Nyisson meg egy dokumentumot a horgonypontjai szerkesztéséhez.
|
||||
Anchors selected: {count} Kijelölt horgonypontok: {count}
|
||||
Direct-select anchor points to edit them. A szerkesztéshez jelölje ki közvetlenül a horgonypontokat.
|
||||
Length Hossz
|
||||
In: Be:
|
||||
Out: Ki:
|
||||
Add Points Pontok hozzáadása
|
||||
Points per segment Pont szakaszonként
|
||||
Close Path: Útvonal lezárása:
|
||||
Keep Handles Fogantyúk megtartása
|
||||
Straight Egyenes
|
||||
Stretch Curve Görbe nyújtása
|
||||
@msg direct-select the anchor points to remove jelölje ki közvetlenül az eltávolítandó horgonypontokat
|
||||
@msg select segments, or paths, to add points to jelöljön ki szakaszokat vagy útvonalakat, amelyekhez pontokat ad
|
||||
@msg select open paths to close jelöljön ki lezárandó nyitott útvonalakat
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) túl sok vagy túl összegabalyodott útvonal a kereszteződések megtalálásához (legfeljebb 10000 szakasz)
|
||||
@msg the paths have too many anchors to edit at once az útvonalaknak túl sok horgonypontjuk van az egyszerre szerkesztéshez
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5669,3 +5669,36 @@ anchor Points: Titik:
|
||||
@msg `{id}` is not a colour adjustment `{id}` bukan penyesuaian warna
|
||||
@msg missing `effect` `effect` tidak ada
|
||||
@msg `params` must be an object `params` harus berupa objek
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Alat presisi jalur
|
||||
Anchor Eraser Brush Kuas penghapus titik jangkar
|
||||
Extend Path Tool Alat perpanjang jalur
|
||||
Measure & Inspect Tool Alat ukur dan periksa
|
||||
Anchor Inspector Pemeriksa titik jangkar
|
||||
Add Points at Crossings Tambahkan titik di persilangan
|
||||
Split at Crossings Pisahkan di persilangan
|
||||
Extend Path Perpanjang jalur
|
||||
Measure & Inspect Ukur dan periksa
|
||||
Set Anchor Atur titik jangkar
|
||||
Make Smooth Jadikan halus
|
||||
Make Corner Jadikan sudut
|
||||
Equalize Handles Samakan pegangan
|
||||
Remove Points, Keep Shape Hapus titik, pertahankan bentuk
|
||||
Add Points to Segments… Tambahkan titik ke segmen…
|
||||
Add Points to Segments Tambahkan titik ke segmen
|
||||
Open a document to edit its anchor points. Buka dokumen untuk menyunting titik jangkarnya.
|
||||
Anchors selected: {count} Titik jangkar terpilih: {count}
|
||||
Direct-select anchor points to edit them. Pilih langsung titik jangkar untuk menyuntingnya.
|
||||
In: Masuk:
|
||||
Out: Keluar:
|
||||
Add Points Tambahkan titik
|
||||
Points per segment Titik per segmen
|
||||
Close Path: Tutup jalur:
|
||||
Keep Handles Pertahankan pegangan
|
||||
Straight Lurus
|
||||
Stretch Curve Regangkan kurva
|
||||
@msg direct-select the anchor points to remove pilih langsung titik jangkar yang akan dihapus
|
||||
@msg select segments, or paths, to add points to pilih segmen, atau jalur, untuk ditambahi titik
|
||||
@msg select open paths to close pilih jalur terbuka untuk ditutup
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) jalur terlalu banyak atau terlalu kusut untuk menemukan persilangannya (paling banyak 10000 segmen)
|
||||
@msg the paths have too many anchors to edit at once jalur memiliki terlalu banyak titik jangkar untuk disunting sekaligus
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2169 and column 5.
|
@@ -5380,3 +5380,37 @@ anchor Points: Punti:
|
||||
@msg `{id}` is not a colour adjustment `{id}` non è una regolazione del colore
|
||||
@msg missing `effect` manca `effect`
|
||||
@msg `params` must be an object `params` deve essere un oggetto
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Strumenti di precisione per tracciati
|
||||
Anchor Eraser Brush Pennello gomma punti di ancoraggio
|
||||
Extend Path Tool Strumento Estendi tracciato
|
||||
Measure & Inspect Tool Strumento Misura e ispeziona
|
||||
Anchor Inspector Ispettore punti di ancoraggio
|
||||
Add Points at Crossings Aggiungi punti agli incroci
|
||||
Split at Crossings Dividi agli incroci
|
||||
Extend Path Estendi tracciato
|
||||
Measure & Inspect Misura e ispeziona
|
||||
Set Anchor Imposta punto di ancoraggio
|
||||
Make Smooth Rendi arrotondato
|
||||
Make Corner Rendi angolo
|
||||
Equalize Handles Uguaglia maniglie
|
||||
Remove Points, Keep Shape Rimuovi punti, mantieni forma
|
||||
Add Points to Segments… Aggiungi punti ai segmenti…
|
||||
Add Points to Segments Aggiungi punti ai segmenti
|
||||
Open a document to edit its anchor points. Apri un documento per modificarne i punti di ancoraggio.
|
||||
Anchors selected: {count} Punti di ancoraggio selezionati: {count}
|
||||
Direct-select anchor points to edit them. Seleziona direttamente i punti di ancoraggio per modificarli.
|
||||
Length Lunghezza
|
||||
In: Entrata:
|
||||
Out: Uscita:
|
||||
Add Points Aggiungi punti
|
||||
Points per segment Punti per segmento
|
||||
Close Path: Chiudi tracciato:
|
||||
Keep Handles Mantieni maniglie
|
||||
Straight Diritto
|
||||
Stretch Curve Allunga curva
|
||||
@msg direct-select the anchor points to remove seleziona direttamente i punti di ancoraggio da rimuovere
|
||||
@msg select segments, or paths, to add points to seleziona segmenti, o tracciati, a cui aggiungere punti
|
||||
@msg select open paths to close seleziona tracciati aperti da chiudere
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) tracciati troppi o troppo intricati per trovarne gli incroci (al massimo 10000 segmenti)
|
||||
@msg the paths have too many anchors to edit at once i tracciati hanno troppi punti di ancoraggio da modificare in una volta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5449,3 +5449,37 @@ anchor Points: ポイント:
|
||||
@msg `{id}` is not a colour adjustment `{id}` はカラー補正ではありません
|
||||
@msg missing `effect` `effect` が指定されていません
|
||||
@msg `params` must be an object `params` にはオブジェクトを指定してください
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools パス精密ツール
|
||||
Anchor Eraser Brush アンカーポイント消去ブラシ
|
||||
Extend Path Tool パス延長ツール
|
||||
Measure & Inspect Tool 計測・検査ツール
|
||||
Anchor Inspector アンカーポイントインスペクター
|
||||
Add Points at Crossings 交差点にポイントを追加
|
||||
Split at Crossings 交差点で分割
|
||||
Extend Path パスを延長
|
||||
Measure & Inspect 計測・検査
|
||||
Set Anchor アンカーポイントを設定
|
||||
Make Smooth スムーズにする
|
||||
Make Corner コーナーにする
|
||||
Equalize Handles ハンドルの長さを揃える
|
||||
Remove Points, Keep Shape ポイントを削除(形状を維持)
|
||||
Add Points to Segments… セグメントにポイントを追加…
|
||||
Add Points to Segments セグメントにポイントを追加
|
||||
Open a document to edit its anchor points. アンカーポイントを編集するにはドキュメントを開いてください。
|
||||
Anchors selected: {count} 選択中のアンカーポイント:{count}
|
||||
Direct-select anchor points to edit them. 編集するアンカーポイントをダイレクト選択してください。
|
||||
Length 長さ
|
||||
In: イン:
|
||||
Out: アウト:
|
||||
Add Points ポイントを追加
|
||||
Points per segment セグメントあたりのポイント数
|
||||
Close Path: パスを閉じる:
|
||||
Keep Handles ハンドルを維持
|
||||
Straight 直線
|
||||
Stretch Curve カーブを延長
|
||||
@msg direct-select the anchor points to remove 削除するアンカーポイントをダイレクト選択してください
|
||||
@msg select segments, or paths, to add points to ポイントを追加するセグメントまたはパスを選択してください
|
||||
@msg select open paths to close 閉じるオープンパスを選択してください
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) パスが多すぎるか複雑すぎて交差点を見つけられません(最大 10000 セグメント)
|
||||
@msg the paths have too many anchors to edit at once パスのアンカーポイントが多すぎて一度に編集できません
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5753,3 +5753,37 @@ anchor Points: წერტილები:
|
||||
@msg `{id}` is not a colour adjustment `{id}` ფერის კორექცია არ არის
|
||||
@msg missing `effect` აკლია `effect`
|
||||
@msg `params` must be an object `params` უნდა იყოს ობიექტი
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools ტრაექტორიების ზუსტი ხელსაწყოები
|
||||
Anchor Eraser Brush საყრდენი წერტილების საშლელი ფუნჯი
|
||||
Extend Path Tool ტრაექტორიის გაგრძელების ხელსაწყო
|
||||
Measure & Inspect Tool გაზომვისა და შემოწმების ხელსაწყო
|
||||
Anchor Inspector საყრდენი წერტილების ინსპექტორი
|
||||
Add Points at Crossings წერტილების დამატება გადაკვეთებზე
|
||||
Split at Crossings დაყოფა გადაკვეთებზე
|
||||
Extend Path ტრაექტორიის გაგრძელება
|
||||
Measure & Inspect გაზომვა და შემოწმება
|
||||
Set Anchor საყრდენი წერტილის დაყენება
|
||||
Make Smooth გლუვად ქცევა
|
||||
Make Corner კუთხოვნად ქცევა
|
||||
Equalize Handles სახელურების გათანაბრება
|
||||
Remove Points, Keep Shape წერტილების მოცილება ფორმის შენარჩუნებით
|
||||
Add Points to Segments… წერტილების დამატება სეგმენტებში…
|
||||
Add Points to Segments წერტილების დამატება სეგმენტებში
|
||||
Open a document to edit its anchor points. გახსენით დოკუმენტი მისი საყრდენი წერტილების დასარედაქტირებლად.
|
||||
Anchors selected: {count} მონიშნული საყრდენი წერტილები: {count}
|
||||
Direct-select anchor points to edit them. დასარედაქტირებლად საყრდენი წერტილები პირდაპირ მონიშნეთ.
|
||||
Length სიგრძე
|
||||
In: შესასვლელი:
|
||||
Out: გამოსასვლელი:
|
||||
Add Points წერტილების დამატება
|
||||
Points per segment წერტილები სეგმენტზე
|
||||
Close Path: ტრაექტორიის შეკვრა:
|
||||
Keep Handles სახელურების შენარჩუნება
|
||||
Straight სწორი
|
||||
Stretch Curve მრუდის გაჭიმვა
|
||||
@msg direct-select the anchor points to remove პირდაპირ მონიშნეთ მოსაცილებელი საყრდენი წერტილები
|
||||
@msg select segments, or paths, to add points to მონიშნეთ სეგმენტები ან ტრაექტორიები წერტილების დასამატებლად
|
||||
@msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) ტრაექტორიები ზედმეტად ბევრი ან ჩახლართულია გადაკვეთების საპოვნელად (მაქსიმუმ 10000 სეგმენტი)
|
||||
@msg the paths have too many anchors to edit at once ტრაექტორიებს ზედმეტად ბევრი საყრდენი წერტილი აქვს ერთბაშად დასარედაქტირებლად
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5343,3 +5343,37 @@ anchor Points: 포인트:
|
||||
@msg `{id}` is not a colour adjustment `{id}`은(는) 색상 조정이 아닙니다
|
||||
@msg missing `effect` `effect`이 없습니다
|
||||
@msg `params` must be an object `params`는 개체여야 합니다
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools 패스 정밀 도구
|
||||
Anchor Eraser Brush 앵커 포인트 지우개 브러시
|
||||
Extend Path Tool 패스 연장 도구
|
||||
Measure & Inspect Tool 측정 및 검사 도구
|
||||
Anchor Inspector 앵커 포인트 검사기
|
||||
Add Points at Crossings 교차점에 포인트 추가
|
||||
Split at Crossings 교차점에서 분할
|
||||
Extend Path 패스 연장
|
||||
Measure & Inspect 측정 및 검사
|
||||
Set Anchor 앵커 포인트 설정
|
||||
Make Smooth 매끄럽게 만들기
|
||||
Make Corner 모서리로 만들기
|
||||
Equalize Handles 핸들 길이 맞추기
|
||||
Remove Points, Keep Shape 포인트 제거(모양 유지)
|
||||
Add Points to Segments… 세그먼트에 포인트 추가…
|
||||
Add Points to Segments 세그먼트에 포인트 추가
|
||||
Open a document to edit its anchor points. 앵커 포인트를 편집하려면 문서를 여세요.
|
||||
Anchors selected: {count} 선택한 앵커 포인트: {count}
|
||||
Direct-select anchor points to edit them. 편집할 앵커 포인트를 직접 선택하세요.
|
||||
Length 길이
|
||||
In: 들어옴:
|
||||
Out: 나감:
|
||||
Add Points 포인트 추가
|
||||
Points per segment 세그먼트당 포인트
|
||||
Close Path: 패스 닫기:
|
||||
Keep Handles 핸들 유지
|
||||
Straight 직선
|
||||
Stretch Curve 곡선 늘이기
|
||||
@msg direct-select the anchor points to remove 제거할 앵커 포인트를 직접 선택하세요
|
||||
@msg select segments, or paths, to add points to 포인트를 추가할 세그먼트나 패스를 선택하세요
|
||||
@msg select open paths to close 닫을 열린 패스를 선택하세요
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 패스가 너무 많거나 너무 얽혀 교차점을 찾을 수 없습니다(최대 10000개 세그먼트)
|
||||
@msg the paths have too many anchors to edit at once 패스의 앵커 포인트가 너무 많아 한 번에 편집할 수 없습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5713,3 +5713,36 @@ anchor Points: Punten:
|
||||
@msg `{id}` is not a colour adjustment `{id}` is geen kleuraanpassing
|
||||
@msg missing `effect` `effect` ontbreekt
|
||||
@msg `params` must be an object `params` moet een object zijn
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Precisiegereedschappen voor paden
|
||||
Anchor Eraser Brush Ankerpunt-gumpenseel
|
||||
Extend Path Tool Pad verlengen-gereedschap
|
||||
Measure & Inspect Tool Meten en inspecteren-gereedschap
|
||||
Anchor Inspector Ankerpuntinspecteur
|
||||
Add Points at Crossings Punten toevoegen bij kruisingen
|
||||
Split at Crossings Splitsen bij kruisingen
|
||||
Extend Path Pad verlengen
|
||||
Measure & Inspect Meten en inspecteren
|
||||
Set Anchor Ankerpunt instellen
|
||||
Make Smooth Vloeiend maken
|
||||
Make Corner Hoekpunt maken
|
||||
Equalize Handles Grepen gelijkmaken
|
||||
Remove Points, Keep Shape Punten verwijderen, vorm behouden
|
||||
Add Points to Segments… Punten aan segmenten toevoegen…
|
||||
Add Points to Segments Punten aan segmenten toevoegen
|
||||
Open a document to edit its anchor points. Open een document om de ankerpunten ervan te bewerken.
|
||||
Anchors selected: {count} Geselecteerde ankerpunten: {count}
|
||||
Direct-select anchor points to edit them. Selecteer ankerpunten direct om ze te bewerken.
|
||||
In: In:
|
||||
Out: Uit:
|
||||
Add Points Punten toevoegen
|
||||
Points per segment Punten per segment
|
||||
Close Path: Pad sluiten:
|
||||
Keep Handles Grepen behouden
|
||||
Straight Recht
|
||||
Stretch Curve Curve uitrekken
|
||||
@msg direct-select the anchor points to remove selecteer de te verwijderen ankerpunten direct
|
||||
@msg select segments, or paths, to add points to selecteer segmenten, of paden, om punten aan toe te voegen
|
||||
@msg select open paths to close selecteer open paden om te sluiten
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) te veel of te verwarde paden om hun kruisingen te vinden (hoogstens 10000 segmenten)
|
||||
@msg the paths have too many anchors to edit at once de paden hebben te veel ankerpunten om in één keer te bewerken
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5618,3 +5618,37 @@ anchor Points: Punkty:
|
||||
@msg `{id}` is not a colour adjustment `{id}` nie jest korektą koloru
|
||||
@msg missing `effect` brak `effect`
|
||||
@msg `params` must be an object `params` musi być obiektem
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Precyzyjne narzędzia ścieżek
|
||||
Anchor Eraser Brush Pędzel gumki punktów kontrolnych
|
||||
Extend Path Tool Narzędzie Przedłuż ścieżkę
|
||||
Measure & Inspect Tool Narzędzie Mierz i badaj
|
||||
Anchor Inspector Inspektor punktów kontrolnych
|
||||
Add Points at Crossings Dodaj punkty na skrzyżowaniach
|
||||
Split at Crossings Podziel na skrzyżowaniach
|
||||
Extend Path Przedłuż ścieżkę
|
||||
Measure & Inspect Mierz i badaj
|
||||
Set Anchor Ustaw punkt kontrolny
|
||||
Make Smooth Zmień na gładki
|
||||
Make Corner Zmień na narożny
|
||||
Equalize Handles Wyrównaj uchwyty
|
||||
Remove Points, Keep Shape Usuń punkty, zachowaj kształt
|
||||
Add Points to Segments… Dodaj punkty do segmentów…
|
||||
Add Points to Segments Dodaj punkty do segmentów
|
||||
Open a document to edit its anchor points. Otwórz dokument, aby edytować jego punkty kontrolne.
|
||||
Anchors selected: {count} Zaznaczone punkty kontrolne: {count}
|
||||
Direct-select anchor points to edit them. Zaznacz bezpośrednio punkty kontrolne, aby je edytować.
|
||||
Length Długość
|
||||
In: Wejście:
|
||||
Out: Wyjście:
|
||||
Add Points Dodaj punkty
|
||||
Points per segment Punkty na segment
|
||||
Close Path: Zamknij ścieżkę:
|
||||
Keep Handles Zachowaj uchwyty
|
||||
Straight Prosty
|
||||
Stretch Curve Rozciągnij krzywą
|
||||
@msg direct-select the anchor points to remove zaznacz bezpośrednio punkty kontrolne do usunięcia
|
||||
@msg select segments, or paths, to add points to zaznacz segmenty lub ścieżki, do których dodać punkty
|
||||
@msg select open paths to close zaznacz otwarte ścieżki do zamknięcia
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) za dużo ścieżek lub są zbyt splątane, by znaleźć ich skrzyżowania (najwyżej 10000 segmentów)
|
||||
@msg the paths have too many anchors to edit at once ścieżki mają za dużo punktów kontrolnych, by edytować je naraz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5681,3 +5681,37 @@ anchor Points: Pontos:
|
||||
@msg `{id}` is not a colour adjustment `{id}` não é um ajuste de cor
|
||||
@msg missing `effect` falta `effect`
|
||||
@msg `params` must be an object `params` deve ser um objeto
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Ferramentas de Precisão de Caminhos
|
||||
Anchor Eraser Brush Pincel Apagador de Pontos de Ancoragem
|
||||
Extend Path Tool Ferramenta Estender Caminho
|
||||
Measure & Inspect Tool Ferramenta Medir e Inspecionar
|
||||
Anchor Inspector Inspetor de Pontos de Ancoragem
|
||||
Add Points at Crossings Adicionar Pontos nos Cruzamentos
|
||||
Split at Crossings Dividir nos Cruzamentos
|
||||
Extend Path Estender Caminho
|
||||
Measure & Inspect Medir e Inspecionar
|
||||
Set Anchor Definir Ponto de Ancoragem
|
||||
Make Smooth Tornar Suave
|
||||
Make Corner Tornar Canto
|
||||
Equalize Handles Igualar Alças
|
||||
Remove Points, Keep Shape Remover Pontos, Manter Forma
|
||||
Add Points to Segments… Adicionar Pontos aos Segmentos…
|
||||
Add Points to Segments Adicionar Pontos aos Segmentos
|
||||
Open a document to edit its anchor points. Abra um documento para editar seus pontos de ancoragem.
|
||||
Anchors selected: {count} Pontos de ancoragem selecionados: {count}
|
||||
Direct-select anchor points to edit them. Selecione diretamente pontos de ancoragem para editá-los.
|
||||
Length Comprimento
|
||||
In: Entrada:
|
||||
Out: Saída:
|
||||
Add Points Adicionar Pontos
|
||||
Points per segment Pontos por segmento
|
||||
Close Path: Fechar Caminho:
|
||||
Keep Handles Manter Alças
|
||||
Straight Reto
|
||||
Stretch Curve Esticar Curva
|
||||
@msg direct-select the anchor points to remove selecione diretamente os pontos de ancoragem a remover
|
||||
@msg select segments, or paths, to add points to selecione segmentos, ou caminhos, aos quais adicionar pontos
|
||||
@msg select open paths to close selecione caminhos abertos para fechar
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) caminhos demais, ou emaranhados demais, para encontrar seus cruzamentos (no máximo 10000 segmentos)
|
||||
@msg the paths have too many anchors to edit at once os caminhos têm pontos de ancoragem demais para editar de uma vez
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -5742,3 +5742,36 @@ anchor Points: Точки:
|
||||
@msg `{id}` is not a colour adjustment `{id}` не является цветокоррекцией
|
||||
@msg missing `effect` отсутствует `effect`
|
||||
@msg `params` must be an object `params` должно быть объектом
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Инструменты точной правки контуров
|
||||
Anchor Eraser Brush Кисть-ластик опорных точек
|
||||
Extend Path Tool Инструмент «Продление контура»
|
||||
Measure & Inspect Tool Инструмент «Измерение и анализ»
|
||||
Anchor Inspector Инспектор опорных точек
|
||||
Add Points at Crossings Добавить точки на пересечениях
|
||||
Split at Crossings Разрезать на пересечениях
|
||||
Extend Path Продлить контур
|
||||
Measure & Inspect Измерение и анализ
|
||||
Set Anchor Задать опорную точку
|
||||
Make Smooth Сделать гладкой
|
||||
Make Corner Сделать угловой
|
||||
Equalize Handles Выровнять длину маркеров
|
||||
Remove Points, Keep Shape Убрать точки, сохранить форму
|
||||
Add Points to Segments… Добавить точки в сегменты…
|
||||
Add Points to Segments Добавить точки в сегменты
|
||||
Open a document to edit its anchor points. Откройте документ, чтобы править его опорные точки.
|
||||
Anchors selected: {count} Выбрано опорных точек: {count}
|
||||
Direct-select anchor points to edit them. Выделите опорные точки напрямую, чтобы править их.
|
||||
In: Вход:
|
||||
Out: Выход:
|
||||
Add Points Добавить точки
|
||||
Points per segment Точек на сегмент
|
||||
Close Path: Замкнуть контур:
|
||||
Keep Handles Сохранить маркеры
|
||||
Straight Прямой
|
||||
Stretch Curve Растянуть кривую
|
||||
@msg direct-select the anchor points to remove выделите напрямую опорные точки, которые нужно убрать
|
||||
@msg select segments, or paths, to add points to выделите сегменты или контуры, в которые нужно добавить точки
|
||||
@msg select open paths to close выделите незамкнутые контуры, чтобы замкнуть их
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контуров слишком много или они слишком запутаны, чтобы найти их пересечения (не более 10000 сегментов)
|
||||
@msg the paths have too many anchors to edit at once у контуров слишком много опорных точек, чтобы править их за раз
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5788,3 +5788,36 @@ anchor Points: Punkter:
|
||||
@msg `{id}` is not a colour adjustment `{id}` är ingen färgjustering
|
||||
@msg missing `effect` `effect` saknas
|
||||
@msg `params` must be an object `params` måste vara ett objekt
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Precisionsverktyg för banor
|
||||
Anchor Eraser Brush Ankarpunktsraderpensel
|
||||
Extend Path Tool Förläng bana-verktyget
|
||||
Measure & Inspect Tool Mät och granska-verktyget
|
||||
Anchor Inspector Ankarpunktsgranskare
|
||||
Add Points at Crossings Lägg till punkter vid korsningar
|
||||
Split at Crossings Dela vid korsningar
|
||||
Extend Path Förläng bana
|
||||
Measure & Inspect Mät och granska
|
||||
Set Anchor Ange ankarpunkt
|
||||
Make Smooth Gör mjuk
|
||||
Make Corner Gör till hörn
|
||||
Equalize Handles Utjämna handtag
|
||||
Remove Points, Keep Shape Ta bort punkter, behåll formen
|
||||
Add Points to Segments… Lägg till punkter i segment…
|
||||
Add Points to Segments Lägg till punkter i segment
|
||||
Open a document to edit its anchor points. Öppna ett dokument för att redigera dess ankarpunkter.
|
||||
Anchors selected: {count} Valda ankarpunkter: {count}
|
||||
Direct-select anchor points to edit them. Direktmarkera ankarpunkter för att redigera dem.
|
||||
In: In:
|
||||
Out: Ut:
|
||||
Add Points Lägg till punkter
|
||||
Points per segment Punkter per segment
|
||||
Close Path: Stäng bana:
|
||||
Keep Handles Behåll handtag
|
||||
Straight Rak
|
||||
Stretch Curve Sträck kurvan
|
||||
@msg direct-select the anchor points to remove direktmarkera ankarpunkterna som ska tas bort
|
||||
@msg select segments, or paths, to add points to markera segment, eller banor, att lägga till punkter i
|
||||
@msg select open paths to close markera öppna banor att stänga
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) för många eller för trassliga banor för att hitta deras korsningar (högst 10000 segment)
|
||||
@msg the paths have too many anchors to edit at once banorna har för många ankarpunkter för att redigeras på en gång
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5722,3 +5722,36 @@ anchor Points: Noktalar:
|
||||
@msg `{id}` is not a colour adjustment `{id}` bir renk ayarı değil
|
||||
@msg missing `effect` "isim" eksik
|
||||
@msg `params` must be an object "gradyan" bir nesne olmalıdır
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Yol Hassasiyet Araçları
|
||||
Anchor Eraser Brush Bağlantı Noktası Silgi Fırçası
|
||||
Extend Path Tool Yolu Uzat Aracı
|
||||
Measure & Inspect Tool Ölç ve İncele Aracı
|
||||
Anchor Inspector Bağlantı Noktası Denetçisi
|
||||
Add Points at Crossings Kesişimlere Nokta Ekle
|
||||
Split at Crossings Kesişimlerden Böl
|
||||
Extend Path Yolu Uzat
|
||||
Measure & Inspect Ölç ve İncele
|
||||
Set Anchor Bağlantı Noktasını Ayarla
|
||||
Make Smooth Yumuşak Yap
|
||||
Make Corner Köşe Yap
|
||||
Equalize Handles Kolları Eşitle
|
||||
Remove Points, Keep Shape Noktaları Kaldır, Şekli Koru
|
||||
Add Points to Segments… Bölümlere Nokta Ekle…
|
||||
Add Points to Segments Bölümlere Nokta Ekle
|
||||
Open a document to edit its anchor points. Bağlantı noktalarını düzenlemek için bir belge açın.
|
||||
Anchors selected: {count} Seçili bağlantı noktaları: {count}
|
||||
Direct-select anchor points to edit them. Düzenlemek için bağlantı noktalarını doğrudan seçin.
|
||||
In: Giriş:
|
||||
Out: Çıkış:
|
||||
Add Points Nokta Ekle
|
||||
Points per segment Bölüm başına nokta
|
||||
Close Path: Yolu Kapat:
|
||||
Keep Handles Kolları Koru
|
||||
Straight Düz
|
||||
Stretch Curve Eğriyi Uzat
|
||||
@msg direct-select the anchor points to remove kaldırılacak bağlantı noktalarını doğrudan seçin
|
||||
@msg select segments, or paths, to add points to nokta eklenecek bölümleri veya yolları seçin
|
||||
@msg select open paths to close kapatılacak açık yolları seçin
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) kesişimlerini bulmak için yollar çok fazla veya çok karışık (en fazla 10000 bölüm)
|
||||
@msg the paths have too many anchors to edit at once yolların aynı anda düzenlenemeyecek kadar çok bağlantı noktası var
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5360,3 +5360,37 @@ anchor Points: Точки:
|
||||
@msg `{id}` is not a colour adjustment `{id}` не є корекцією кольору
|
||||
@msg missing `effect` відсутня `effect`
|
||||
@msg `params` must be an object `params` має бути об’єктом
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Інструменти точного редагування контурів
|
||||
Anchor Eraser Brush Пензель-гумка опорних точок
|
||||
Extend Path Tool Інструмент «Подовження контуру»
|
||||
Measure & Inspect Tool Інструмент «Вимірювання й аналіз»
|
||||
Anchor Inspector Інспектор опорних точок
|
||||
Add Points at Crossings Додати точки на перетинах
|
||||
Split at Crossings Розрізати на перетинах
|
||||
Extend Path Подовжити контур
|
||||
Measure & Inspect Вимірювання й аналіз
|
||||
Set Anchor Задати опорну точку
|
||||
Make Smooth Зробити гладкою
|
||||
Make Corner Зробити кутовою
|
||||
Equalize Handles Вирівняти довжину маркерів
|
||||
Remove Points, Keep Shape Забрати точки, зберегти форму
|
||||
Add Points to Segments… Додати точки до сегментів…
|
||||
Add Points to Segments Додати точки до сегментів
|
||||
Open a document to edit its anchor points. Відкрийте документ, щоб редагувати його опорні точки.
|
||||
Anchors selected: {count} Вибрано опорних точок: {count}
|
||||
Direct-select anchor points to edit them. Виділіть опорні точки напряму, щоб редагувати їх.
|
||||
Length Довжина
|
||||
In: Вхід:
|
||||
Out: Вихід:
|
||||
Add Points Додати точки
|
||||
Points per segment Точок на сегмент
|
||||
Close Path: Замкнути контур:
|
||||
Keep Handles Зберегти маркери
|
||||
Straight Прямий
|
||||
Stretch Curve Розтягнути криву
|
||||
@msg direct-select the anchor points to remove виділіть напряму опорні точки, які треба забрати
|
||||
@msg select segments, or paths, to add points to виділіть сегменти або контури, до яких треба додати точки
|
||||
@msg select open paths to close виділіть незамкнені контури, щоб замкнути їх
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контурів забагато або вони надто заплутані, щоб знайти їхні перетини (не більше 10000 сегментів)
|
||||
@msg the paths have too many anchors to edit at once у контурів забагато опорних точок, щоб редагувати їх за раз
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 178 and column 5.
|
@@ -5362,3 +5362,37 @@ anchor Points: Điểm:
|
||||
@msg `{id}` is not a colour adjustment `{id}` không phải là điều chỉnh màu
|
||||
@msg missing `effect` thiếu `effect`
|
||||
@msg `params` must be an object `params` phải là một đối tượng
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools Công cụ chính xác cho đường dẫn
|
||||
Anchor Eraser Brush Cọ tẩy điểm neo
|
||||
Extend Path Tool Công cụ Kéo dài đường dẫn
|
||||
Measure & Inspect Tool Công cụ Đo và kiểm tra
|
||||
Anchor Inspector Trình kiểm tra điểm neo
|
||||
Add Points at Crossings Thêm điểm tại chỗ giao nhau
|
||||
Split at Crossings Tách tại chỗ giao nhau
|
||||
Extend Path Kéo dài đường dẫn
|
||||
Measure & Inspect Đo và kiểm tra
|
||||
Set Anchor Đặt điểm neo
|
||||
Make Smooth Làm mượt
|
||||
Make Corner Chuyển thành góc
|
||||
Equalize Handles Cân bằng tay nắm
|
||||
Remove Points, Keep Shape Gỡ điểm, giữ hình dạng
|
||||
Add Points to Segments… Thêm điểm vào đoạn…
|
||||
Add Points to Segments Thêm điểm vào đoạn
|
||||
Open a document to edit its anchor points. Mở một tài liệu để sửa các điểm neo của nó.
|
||||
Anchors selected: {count} Điểm neo đã chọn: {count}
|
||||
Direct-select anchor points to edit them. Chọn trực tiếp các điểm neo để sửa chúng.
|
||||
Length Độ dài
|
||||
In: Vào:
|
||||
Out: Ra:
|
||||
Add Points Thêm điểm
|
||||
Points per segment Số điểm mỗi đoạn
|
||||
Close Path: Đóng đường dẫn:
|
||||
Keep Handles Giữ tay nắm
|
||||
Straight Thẳng
|
||||
Stretch Curve Kéo giãn đường cong
|
||||
@msg direct-select the anchor points to remove chọn trực tiếp các điểm neo cần gỡ
|
||||
@msg select segments, or paths, to add points to chọn các đoạn hoặc đường dẫn để thêm điểm
|
||||
@msg select open paths to close chọn các đường dẫn mở để đóng
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) quá nhiều đường dẫn hoặc chúng quá rối để tìm chỗ giao nhau (tối đa 10000 đoạn)
|
||||
@msg the paths have too many anchors to edit at once các đường dẫn có quá nhiều điểm neo để sửa cùng lúc
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 200 and column 5.
|
@@ -5603,3 +5603,37 @@ anchor Points: 点:
|
||||
@msg `{id}` is not a colour adjustment `{id}` 不是颜色调整
|
||||
@msg missing `effect` 缺少 `effect`
|
||||
@msg `params` must be an object `params` 必须是对象
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools 路径精确工具
|
||||
Anchor Eraser Brush 锚点橡皮擦画笔
|
||||
Extend Path Tool 延长路径工具
|
||||
Measure & Inspect Tool 测量与检查工具
|
||||
Anchor Inspector 锚点检查器
|
||||
Add Points at Crossings 在交叉处添加锚点
|
||||
Split at Crossings 在交叉处分割
|
||||
Extend Path 延长路径
|
||||
Measure & Inspect 测量与检查
|
||||
Set Anchor 设置锚点
|
||||
Make Smooth 转为平滑
|
||||
Make Corner 转为尖角
|
||||
Equalize Handles 等长手柄
|
||||
Remove Points, Keep Shape 删除锚点并保持形状
|
||||
Add Points to Segments… 在线段中添加锚点…
|
||||
Add Points to Segments 在线段中添加锚点
|
||||
Open a document to edit its anchor points. 打开文档以编辑其锚点。
|
||||
Anchors selected: {count} 已选锚点:{count}
|
||||
Direct-select anchor points to edit them. 直接选择锚点以编辑它们。
|
||||
Length 长度
|
||||
In: 入:
|
||||
Out: 出:
|
||||
Add Points 添加锚点
|
||||
Points per segment 每段锚点数
|
||||
Close Path: 闭合路径:
|
||||
Keep Handles 保留手柄
|
||||
Straight 直线
|
||||
Stretch Curve 拉伸曲线
|
||||
@msg direct-select the anchor points to remove 请直接选择要删除的锚点
|
||||
@msg select segments, or paths, to add points to 请选择要添加锚点的线段或路径
|
||||
@msg select open paths to close 请选择要闭合的开放路径
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路径太多或太复杂,无法找到交叉处(最多 10000 段)
|
||||
@msg the paths have too many anchors to edit at once 路径的锚点太多,无法一次编辑
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5399,3 +5399,37 @@ anchor Points: 點:
|
||||
@msg `{id}` is not a colour adjustment `{id}` 不是顏色調整
|
||||
@msg missing `effect` 未提供 `effect`
|
||||
@msg `params` must be an object `params` 必須是物件
|
||||
# Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): its tools, Anchor Inspector panel, commands and messages.
|
||||
Path Precision Tools 路徑精確工具
|
||||
Anchor Eraser Brush 錨點橡皮擦筆刷
|
||||
Extend Path Tool 延長路徑工具
|
||||
Measure & Inspect Tool 測量與檢查工具
|
||||
Anchor Inspector 錨點檢查器
|
||||
Add Points at Crossings 在交叉處增加錨點
|
||||
Split at Crossings 在交叉處分割
|
||||
Extend Path 延長路徑
|
||||
Measure & Inspect 測量與檢查
|
||||
Set Anchor 設定錨點
|
||||
Make Smooth 轉為平滑
|
||||
Make Corner 轉為角點
|
||||
Equalize Handles 等長控制把手
|
||||
Remove Points, Keep Shape 刪除錨點並保持形狀
|
||||
Add Points to Segments… 在線段中增加錨點…
|
||||
Add Points to Segments 在線段中增加錨點
|
||||
Open a document to edit its anchor points. 開啟文件以編輯其錨點。
|
||||
Anchors selected: {count} 已選取錨點:{count}
|
||||
Direct-select anchor points to edit them. 直接選取錨點以編輯它們。
|
||||
Length 長度
|
||||
In: 入:
|
||||
Out: 出:
|
||||
Add Points 增加錨點
|
||||
Points per segment 每段錨點數
|
||||
Close Path: 封閉路徑:
|
||||
Keep Handles 保留控制把手
|
||||
Straight 直線
|
||||
Stretch Curve 延展曲線
|
||||
@msg direct-select the anchor points to remove 請直接選取要刪除的錨點
|
||||
@msg select segments, or paths, to add points to 請選取要增加錨點的線段或路徑
|
||||
@msg select open paths to close 請選取要封閉的開放路徑
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路徑太多或太複雜,無法找到交叉處(最多 10000 段)
|
||||
@msg the paths have too many anchors to edit at once 路徑的錨點太多,無法一次編輯
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -181,6 +181,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
css_properties::ID => css_properties::show(app, ui),
|
||||
crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui),
|
||||
crate::addons::color::ID => crate::addons::color::show(app, ui),
|
||||
crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui),
|
||||
id if library_panel::parse(id).is_some() => library_panel::show(app, ui, id),
|
||||
_ => {
|
||||
dim_label(ui, tl!("This panel is on the roadmap (see the parity plan)."));
|
||||
|
||||
@@ -199,6 +199,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
(crate::panels::variables::ID, "Variables", "chart-column"),
|
||||
(crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"),
|
||||
(crate::addons::color::ID, "Color Effects", "sun"),
|
||||
(crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
|
||||
@@ -389,6 +389,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
|
||||
crate::menus::invoke(app, id, json!({}));
|
||||
Ok(json!({ "dialog": "command" }))
|
||||
}
|
||||
// The add-ons' tools.
|
||||
_ if crate::addons::pathtools::has_options(tool) => crate::addons::pathtools::open_options(app, tool),
|
||||
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
|
||||
_ => Err(format!("the {tool} tool has no options")),
|
||||
}
|
||||
|
||||
+58
-8
@@ -5709,30 +5709,80 @@ topmost piece.
|
||||
{"name":"run_command","arguments":{"command":"addon.cleanup.simplify","params":{"tolerance":40,"protectCorners":true}}}
|
||||
```
|
||||
|
||||
## Color Effects add-on
|
||||
## Path Precision Tools add-on
|
||||
|
||||
Path Precision Tools is a built-in add-on for exact anchor and path work: its commands are `addon.pathtools.*`, and in
|
||||
the desktop app it lives in Object › Plug-ins › Path Precision Tools, in its Anchor Inspector panel
|
||||
(`window.panel {panel: "anchorInspector"}`) and in three tools that the toolbars don't show until they are added from the
|
||||
All Tools drawer (or chosen with `tool.select`): `anchorEraserBrush`, `extendPath` and `measureInspect`. Each edit is one
|
||||
undo step, and an edit that changes nothing makes no step.
|
||||
|
||||
Tools:
|
||||
|
||||
- **Anchor Eraser Brush** (`anchorEraserBrush`): a drag over selected paths runs `addon.pathtools.erase` with the
|
||||
stroke. Options (`tool.setOption`, or a double-click on the tool): `size` (screen pixels, 1 to 2500, 20), `tolerance`
|
||||
(1 to 100, 10) and `pressure` (a pen's pressure scales the brush). Alt removes every anchor under the brush that can
|
||||
go whatever the tolerance; Cmd/Ctrl deletes them without refitting; `[` and `]` resize the brush.
|
||||
- **Extend Path tool** (`extendPath`): a drag from an end of an open path runs `addon.pathtools.extend` with the length
|
||||
the pointer asks for: forward carries the path on, back along it shortens it. Option `mode`: `straight` (default),
|
||||
`bezier` or `arc`; E cycles it. Shift steps the length by the keyboard increment.
|
||||
- **Measure & Inspect tool** (`measureInspect`): a drag measures distance, angle and offsets, as the Measure tool does
|
||||
(`tool.options` reports the last one); hovering a path reads it, as `addon.pathtools.inspect` does. Nothing goes into
|
||||
the document.
|
||||
|
||||
Commands:
|
||||
|
||||
- `addon.pathtools.erase {points, diameter?, tolerance?, ignoreTolerance?, plain?, ids?}` removes the anchors of the
|
||||
selected paths that a round brush of `diameter` pt (20 by default), swept through `points` (`[x, y]`, or `[x, y,
|
||||
pressure]` to scale it), passes over, wherever the outline stays within tolerance × 0.02 pt of where it was (the
|
||||
scale `addon.cleanup.simplify` uses), refitting the handles beside each. Open ends stay. → `{ids, removed}`.
|
||||
- `addon.pathtools.extend {id, subpath?, end, length | to, mode?}` lengthens an open path at `end` (`"start"` or
|
||||
`"end"`) by `length` pt, or shortens it by a negative length (never all of it). With `to: [x, y]` instead, the length
|
||||
is the one that brings the end nearest that point. `mode`: `straight` (along the end's direction), `bezier` (the last
|
||||
segment's curve stretched on, no anchor added) or `arc` (along the circle the end curves on, at most half way round;
|
||||
a straight end goes on straight).
|
||||
- `addon.pathtools.inspect {id, x, y, tolerance?}` (a query) reads the path at its point nearest `(x, y)`: open or
|
||||
closed, anchor count, the subpath's length and enclosed area (an open one closed by a straight line), the segment's
|
||||
length, the direction the path runs there and its radius of curvature (`null` where straight), and, within
|
||||
`tolerance` (3 pt) of an anchor, that anchor's angle (90 at a square's corner, 180 where the path runs straight on;
|
||||
`null` at an open end) and its handles' lengths and directions.
|
||||
- The Anchor Inspector's edits act on the direct-selected anchors, or on every anchor of a path selected whole:
|
||||
`addon.pathtools.setAnchor {x?, y?, inLength?, inAngle?, outLength?, outAngle?}` (page coordinates; angles in
|
||||
degrees counter-clockwise; a length of 0 retracts), `addon.pathtools.smooth {ratio?}`, `addon.pathtools.corner
|
||||
{retract?}` and `addon.pathtools.equalize {}`. Make Smooth mirrors a lone handle; an anchor without handles gets two
|
||||
at right angles to the bisector of the angle its neighbours make at it, each `ratio` (0.1 to 0.5, 0.3905 by default:
|
||||
the ratio that makes a square's corners a circle) × the nearer neighbour's distance long; an open end aims its handle
|
||||
at its neighbour's. Equalize gives both handles their average length and makes a missing one opposite the other.
|
||||
- `addon.pathtools.removeKeepShape {}` removes the direct-selected anchors, refitting the handles beside each.
|
||||
- `addon.pathtools.addPoints {count?}` splits each selected segment (between two selected anchors, or every segment of
|
||||
a path selected whole) into `count + 1` (1 to 100) pieces of equal length.
|
||||
- `addon.pathtools.close {mode?, ratio?}` closes the selected open paths keeping the ends' handles (`handles`), with a
|
||||
straight segment (`straight`) or with both ends made smooth (`smooth`).
|
||||
- `addon.pathtools.crossings {cut?, selfCrossings?, ids?}` adds anchors where the selected paths cross one another (and
|
||||
where each crosses itself, with `selfCrossings`), or with `cut` also cuts them there: a path of one subpath becomes a
|
||||
path per piece. Paths that meet at a shared anchor don't cross there; at most 10,000 segments at once.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.extend","params":{"id":12,"end":"end","length":40,"mode":"arc"}}}
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.erase","params":{"points":[[100,40],[100,160]],"diameter":30}}}
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.crossings","params":{"cut":true}}}
|
||||
```
|
||||
## Color Effects add-on
|
||||
Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is
|
||||
`addon.color.filter`, and in the desktop app it also lives in Object › Plug-ins › Color Effects and in its panel
|
||||
(`window.panel {panel: "colorEffects"}`).
|
||||
|
||||
Its live effects go on through `effect.apply` like any other and recolour what the object paints, in stack order with
|
||||
the other colour adjustments (Effect › Color Adjustments); each colour keeps its colour model:
|
||||
|
||||
| Effect | Parameters | Does |
|
||||
|---|---|---|
|
||||
| `addon.color.exposure` (Exposure…) | `exposure` stops −20…20 (0), `offset` −0.5…0.5 (0), `gamma` 0.1…10 (1) | Scales the light in linear light (each stop doubles or halves it, mostly in the highlights), adds the offset (shadows and midtones) and applies the gamma |
|
||||
| `addon.color.desaturate` (Desaturate) | none | Turns every colour to the grey of its lightness |
|
||||
| `addon.color.invert` (Invert Colors) | none | Turns every colour to its RGB inverse |
|
||||
|
||||
`addon.color.filter {effect, params?, fill?, stroke?, includeImages?, includePatterns?, ids?}` applies any colour
|
||||
adjustment, the add-on's or one of Effect › Color Adjustments', to the colours themselves instead of as a live effect:
|
||||
fills, strokes, type, gradient meshes, embedded images and pattern tiles, as `edit.colors.invert` reaches them, as one
|
||||
undo step. Missing `params` take the effect's dialog defaults; the objects' own effects stay as they are.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"addon.color.exposure","params":{"exposure":1.5}}}}
|
||||
{"name":"run_command","arguments":{"command":"addon.color.filter","params":{"effect":"adjust.hueSaturation","params":{"hue":120}}}}
|
||||
```
|
||||
|
||||
The panel picks an adjustment (Brightness/Contrast, Hue/Saturation, Levels, Temperature/Tint, Exposure, Desaturate or
|
||||
Invert Colors), sets its values, and then Add as Effect (`effect.apply`) or Apply as Filter (`addon.color.filter`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools, a built-in add-on beyond Illustrator; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -1216,8 +1216,20 @@ Show, Fix Selected and Fix All (each one undo step), Deep Ungroup and Rejoin Pat
|
||||
alone; Fix All as one step; scopes; rejoin within the gap only) and `addons::cleanup::tests` in `vectorcraft-ui-egui`
|
||||
(the submenu opens the panel; Check lists what it finds). Commands: `docs/mcp.md` › Artwork Cleanup add-on.
|
||||
|
||||
## Color Effects add-on (2026-10-30)
|
||||
## Path Precision Tools add-on (2026-10-30)
|
||||
|
||||
A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Path Precision Tools,
|
||||
its Anchor Inspector panel and three tools kept out of the shipped toolbars (listed in the All Tools drawer) run
|
||||
`addon.pathtools.*`: an Anchor Eraser Brush on the shared keep-shape kernel, an Extend Path tool (straight, stretched
|
||||
curve or arc; shortening back along the path), a Measure & Inspect tool with hover readouts (lengths, area, curvature,
|
||||
angles), the Anchor Inspector's numeric anchor and handle fields with Make Smooth (auto-smooth rule), Make Corner,
|
||||
Equalize Handles, Remove Points Keep Shape, Add Points and three ways to close, and Add Points at / Split at Crossings.
|
||||
Kernels in `vectorcraft_pathops::addons::pathtools`. Tests: `addons::pathtools` in `vectorcraft-pathops`,
|
||||
`vectorcraft-tools` and `vectorcraft-ui-egui`, `cmd::addons::pathtools::tests` in `vectorcraft-engine` (each command one
|
||||
undo step, bad params refused, the brush's stroke one step) and `edit_toolbar::tests::addon_tools_are_in_the_drawer_not_the_toolbars`.
|
||||
Commands: `docs/mcp.md` › Path Precision Tools add-on.
|
||||
|
||||
## Color Effects add-on (2026-10-30)
|
||||
A VectorCraft addition, not an Illustrator feature, so no area score moves. Effect › Color Effects adds live Exposure
|
||||
(stops of linear light, offset and gamma), Desaturate and Invert Colors as colour adjustments evaluated with those of
|
||||
Effect › Color Adjustments (`vectorcraft_effects::addons::color`), and `addon.color.filter` applies any colour adjustment
|
||||
@@ -1226,11 +1238,11 @@ Tests: `addons::color` in `vectorcraft-effects` (exposure in stops, desaturate k
|
||||
identity), `cmd::addons::color::tests` in `vectorcraft-engine` (drawn live without changing the colours; a filter as one
|
||||
undo step, with any adjustment and its params; bad requests refused) and `addons::color::tests` in
|
||||
`vectorcraft-ui-egui` (the menus and the panel). Commands: `docs/mcp.md` › Color Effects add-on.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Path Precision Tools add-on (a VectorCraft addition): evidence added; scores unchanged. |
|
||||
| 2026-10-30 | minor | Color Effects, a built-in add-on beyond Illustrator; scores and estimates unchanged |
|
||||
| 2026-10-30 | minor | Artwork Cleanup add-on (a VectorCraft addition): evidence added; scores unchanged. |
|
||||
| 2026-10-30 | minor | Hardware: a second finger cancels the tool press the first finger began, and a finger landing while another is down presses nothing, so a pinch only navigates (#4198); evidence added; scores unchanged (estimated). |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects add-on: its effects, panel and Object › Plug-ins submenu; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools add-on: its tools, Anchor Inspector panel and Object › Plug-ins submenu; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -321,6 +321,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
| Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned |
|
||||
| Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 |
|
||||
| Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines |
|
||||
| Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored |
|
||||
| Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored |
|
||||
| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored |
|
||||
|
||||
@@ -363,6 +364,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Path Precision Tools add-on (a VectorCraft addition): its tools, Anchor Inspector panel and Object › Plug-ins › Path Precision Tools; scores and estimates unchanged |
|
||||
| 2026-10-30 | minor | Color Effects add-on: its effects, panel and Object › Plug-ins submenu; scores and estimates unchanged |
|
||||
| 2026-10-30 | minor | Artwork Cleanup add-on (a VectorCraft addition): its panel and Object › Plug-ins › Artwork Cleanup; scores and estimates unchanged |
|
||||
| 2026-10-30 | minor | Spinners: a held arrow repeats after a pause (#4267); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user