Path: anchor edits on a long selected path skip measuring it for the canvas check

The check after each edit that the selected objects stay within the
canvas measured the whole of every selected path. Moving one anchor of
a 10,000-anchor path measured all 9,999 segments. A path or compound
path whose anchors and handles are all finite and inside the canvas
range now passes without being measured, because its curves stay inside
their control points. A path with a point outside that range is still
measured, so the check accepts and refuses the same edits as before.

path.moveAnchor to the point the anchor is already at, and
path.setHandle to the point the handle is already at, now leave the
document as it is and record no undo step.

vectorcraft_geom::path::measured_segments counts the segments that
PathData::bounds measures, and the new engine tests use it.
This commit is contained in:
dotCooCoo
2026-11-13 13:46:30 -08:00
parent 4558196e16
commit e51e67116d
7 changed files with 171 additions and 1 deletions
+25
View File
@@ -314,6 +314,15 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = switch_or(p, "independent", false, "path.setHandle")?;
// A handle already there, on an anchor already of the kind it would get, changes nothing and
// records no undo step.
if let Some(a) = anchor_of(s, id, si, ai)? {
let mut b = a;
b.set_handle(out, pos, independent);
if b == a {
return ok();
}
}
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
@@ -330,6 +339,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
// An anchor already at (x, y) changes nothing and records no undo step.
if let Some(a) = anchor_of(s, id, si, ai)?
&& a.transform(vectorcraft_geom::Affine::translate(pos - a.p)) == a
{
return ok();
}
s.edit("Move", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
@@ -345,6 +360,16 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Anchor `ai` of subpath `si` of path `id` in the active document, when `id` is a path that has it.
/// Busy while a script runs, as [`Session::edit`] is.
fn anchor_of(s: &Session, id: NodeId, si: usize, ai: usize) -> Result<Option<Anchor>> {
if s.script_running() {
return Err(EngineError::Busy);
}
let d = &s.doc()?.doc;
Ok(d.node(id).and_then(|n| n.path_data()).and_then(|path| path.subpaths.get(si)).and_then(|sp| sp.anchors.get(ai)).copied())
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
+20 -1
View File
@@ -54,7 +54,26 @@ fn rect_in_range(r: vectorcraft_geom::Rect) -> bool {
}
/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)?
///
/// A path or compound path whose anchors and handles are all finite and inside the range by a
/// margin passes without being measured: its curves stay inside their control points. Any other
/// object, and a path with a point outside that margin, is measured.
pub(crate) fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool {
let paths = d.index_paths(ids.iter().copied());
ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range))
ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).is_none_or(|n| points_in_range(n) || n.geometric_bounds().is_none_or(rect_in_range)))
}
/// Coordinates at most this far from 0 keep a curve's measured bounds within [`MAX_COORD`]
/// whatever the rounding of its extrema.
const POINTS_LIMIT: f64 = MAX_COORD * (1.0 - 1e-9);
/// Whether every anchor and handle of path `n`, or of each path of compound path `n`, is finite and
/// within [`POINTS_LIMIT`]. `false` for any other object.
fn points_in_range(n: &vectorcraft_doc::Node) -> bool {
let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= POINTS_LIMIT && p.y.abs() <= POINTS_LIMIT;
match &n.kind {
NodeKind::Path { path, .. } => path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)),
NodeKind::Compound { .. } => n.editing_children().iter().all(|c| points_in_range(c)),
_ => false,
}
}
+1
View File
@@ -7,6 +7,7 @@ use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
mod actionset;
pub(super) mod adjust;
mod ai_open_path;
mod anchor_edit_work;
mod appearance;
mod areafit;
mod arrowheads;
+111
View File
@@ -0,0 +1,111 @@
//! An edit of one anchor of a long selected path does not measure the path's segments
//! ([`vectorcraft_geom::path::measured_segments`]) to check that it stays within the canvas, and
//! an edit that leaves the anchor as it was records no undo step.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
use vectorcraft_geom::path::measured_segments;
use vectorcraft_geom::{Anchor, PathData, Point, SubPath};
use crate::*;
/// Most segments one anchor edit may measure, however many anchors the path has.
const MOST_MEASURED: u64 = 16;
/// A session whose document holds one open zigzag path of `n` anchors, selected, with its anchor 5
/// direct-selected.
fn long_path(n: usize) -> (Session, NodeId) {
let mut d = Document::new(2000.0, 1000.0);
let anchors = (0..n).map(|i| Anchor::corner(Point::new(i as f64 * 0.1, if i % 2 == 0 { 100.0 } else { 110.0 }))).collect();
let id = d.alloc_id();
let node = Node::path(id, PathData::single(SubPath::new(anchors, false)), Appearance::default_art());
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
ch.push(Arc::new(node));
}
let mut s = Session::new();
s.add_document(d, None);
let st = s.doc_mut().unwrap();
st.selection.set([id]);
st.selection.anchors.insert(id, BTreeSet::from([(0, 5)]));
(s, id)
}
fn anchor(s: &Session, id: NodeId, ai: usize) -> Anchor {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai]
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Runs `cmd` with `p` and returns how many segments it measured.
fn measured(s: &mut Session, cmd: &str, p: Value) -> u64 {
let before = measured_segments();
s.execute(cmd, &p).unwrap();
measured_segments() - before
}
#[test]
fn one_anchor_edit_of_a_long_path_measures_few_segments() {
for n in [100, 10_000] {
let (mut s, id) = long_path(n);
let a = anchor(&s, id, 5);
let m = measured(&mut s, "path.moveAnchor", json!({"id": id.0, "anchor": 5, "x": 3, "y": 50}));
assert!(m <= MOST_MEASURED, "path.moveAnchor on {n} anchors measured {m} segments");
assert_eq!(anchor(&s, id, 5).p, Point::new(3.0, 50.0));
let m = measured(&mut s, "path.setHandle", json!({"id": id.0, "anchor": 5, "which": "out", "x": 4, "y": 40}));
assert!(m <= MOST_MEASURED, "path.setHandle on {n} anchors measured {m} segments");
assert_eq!(anchor(&s, id, 5).h_out, Point::new(4.0, 40.0));
let m = measured(&mut s, "path.moveAnchors", json!({"dx": 1, "dy": 0}));
assert!(m <= MOST_MEASURED, "path.moveAnchors on {n} anchors measured {m} segments");
let m = measured(&mut s, "object.nudge", json!({"dx": 1, "dy": 0}));
assert!(m <= MOST_MEASURED, "object.nudge on {n} anchors measured {m} segments");
assert_eq!(anchor(&s, id, 5).p, Point::new(5.0, 50.0));
// Only anchor 5 moved, and undoing the four edits gives it back.
assert_eq!(anchor(&s, id, 4), Anchor::corner(Point::new(4.0 * 0.1, 100.0)));
assert_eq!(anchor(&s, id, 6), Anchor::corner(Point::new(6.0 * 0.1, 100.0)));
for _ in 0..4 {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(anchor(&s, id, 5), a);
}
}
/// A handle past the canvas is measured: the edit stands while the curve stays within the canvas,
/// and is refused once the curve leaves it.
#[test]
fn a_handle_past_the_canvas_is_measured() {
let (mut s, id) = long_path(10_000);
// The curve from anchor 5 reaches 4/9 of the way to its outgoing handle.
let x = 2.0 * MAX_COORD;
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": x, "y": 110})).unwrap();
assert_eq!(anchor(&s, id, 5).h_out, Point::new(x, 110.0));
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(&s);
let x = 3.0 * MAX_COORD;
assert!(s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": x, "y": 110})).is_err());
assert!(s.doc().unwrap().doc == before, "the refused edit changed the document");
assert_eq!(undo_steps(&s), undo);
}
/// `path.moveAnchor` to where the anchor is and `path.setHandle` to where the handle is change
/// nothing and record no undo step.
#[test]
fn an_anchor_edit_that_changes_nothing_records_no_undo_step() {
let (mut s, id) = long_path(100);
let a = anchor(&s, id, 5);
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 5, "x": a.p.x, "y": a.p.y})).unwrap();
assert_eq!(undo_steps(&s), 0, "path.moveAnchor recorded an undo step");
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": a.h_out.x, "y": a.h_out.y})).unwrap();
assert_eq!(undo_steps(&s), 0, "path.setHandle recorded an undo step");
assert_eq!(anchor(&s, id, 5), a);
// An anchor that is not there is still refused.
assert!(s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 100, "x": 0, "y": 0})).is_err());
assert!(s.execute("path.setHandle", &json!({"id": id.0, "anchor": 100, "which": "out", "x": 0, "y": 0})).is_err());
// A change still records one.
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 5, "x": a.p.x + 1.0, "y": a.p.y})).unwrap();
assert_eq!(undo_steps(&s), 1);
}
+11
View File
@@ -248,6 +248,16 @@ fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Anchor>,
d.deserialize_seq(V)
}
thread_local! {
/// [`measured_segments`] of this thread.
static MEASURED_SEGMENTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// How many segments [`PathData::bounds`] has measured on this thread.
pub fn measured_segments() -> u64 {
MEASURED_SEGMENTS.get()
}
/// An open or closed run of anchors.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SubPath {
@@ -568,6 +578,7 @@ impl PathData {
}
let mut bbox: Option<Rect> = None;
for sp in &self.subpaths {
MEASURED_SEGMENTS.set(MEASURED_SEGMENTS.get().saturating_add(sp.segment_count() as u64));
for i in 0..sp.segment_count() {
let c = sp.segment(i);
// `to_bezpath_into` writes a line where neither handle is out, else a cubic.
+2
View File
@@ -5052,6 +5052,8 @@ the canvas (coordinates out of range)".
`path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that
is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such
an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded.
`path.moveAnchor` to the point the anchor is at, and `path.setHandle` to the point the handle is at on an anchor that
keeps its kind, leave the document as it is and record no undo step.
`path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those
after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays
selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and
+1
View File
@@ -1766,6 +1766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs).
When an edit changes a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve), the canvas draws again where its art was and is, measured by that art: type and groups were measured by their letters or members, so a shallower or turned solid left pieces of the old one on the canvas. When the canvas draws the part on its own thread, it measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type, where they cropped it to the letters, and Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once unless the object has brushed strokes: 30 extruded type objects measured over 30 artboards take about 2 ms in a release build, where measuring their letters took under 1 ms. A document's first frame builds every 3D object's solid, off the view too, to know whether it reaches into the view.
When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times).
The check after an edit that a selected path or compound path stays within the canvas passes without measuring the path when every anchor and handle of it is within the canvas, and measures it only when one is not. Moving one anchor of a selected open path of 10,000 anchors (`path.moveAnchor`, `path.setHandle`, `path.moveAnchors` or a nudge) measured all 9,999 segments of the path and now measures at most 16 (counted in the engine's tests). The edit still copies the path's anchors for undo, and the renderer still compares every anchor of the path to find the moved ones.
Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million.
Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms.
A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460).