From 666084fb814804dc5a81059fd510e3b1fbcd44fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:46:06 -0800 Subject: [PATCH] Blend: find the spine segment for an arc length with a binary search Spine::at_length walked the first subpath's segments from the start until one ended at or past the arc length, so placing every blend step, spread key, blend widget and Objects on Path attachment cost one comparison per earlier segment. It now searches the anchors' arc lengths, which never decrease, and picks the same segment: the first whose end is at or past the length, so an exact anchor length and repeated lengths from zero-length segments keep their segment. The point and tangent evaluation is unchanged. Tests compare the search with the old walk on open and closed spines, one segment, curves, zero-length segments, every anchor, both ends, out-of-range and non-finite lengths, and check that a 4,096-segment spine needs at most 13 comparisons. --- crates/doc/src/blend.rs | 116 ++++++++++++++++++++++++++++++++------ docs/target-app-parity.md | 1 + 2 files changed, 100 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d9e179286f..f4877b0a49 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1326,27 +1326,39 @@ impl Spine { let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; let sp = &self.subpath; let n = sp.segment_count(); - let mut start = 0.0; - for i in 0..n { - let c = sp.segment(i); - let end = self.anchor_length(i + 1).unwrap_or(self.length()); - if s <= end || i + 1 == n { - let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); - let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); - let e = c.eval(t); - let tv = c.deriv().eval(t).to_vec2(); - // Where a handle sits on its anchor the derivative vanishes there: take the - // direction between points a little before and after instead. - let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; - return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); - } - start = end; + if n == 0 { + return (self.pts.first().copied().unwrap_or_default(), 0.0); } - // Only a spine without segments gets here: the loop returns at the last one. - (self.pts.first().copied().unwrap_or_default(), 0.0) + let i = segment_at(&self.anchors, n, s, || {}); + let c = sp.segment(i); + let start = if i == 0 { 0.0 } else { self.anchor_length(i).unwrap_or(0.0) }; + let end = self.anchor_length(i + 1).unwrap_or(self.length()); + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + // Where a handle sits on its anchor the derivative vanishes there: take the + // direction between points a little before and after instead. + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + (Point::new(e.x, e.y), tv.y.atan2(tv.x)) } } +/// The segment of a spine with `n` segments (`n > 0`) that holds arc length `s`: the first one whose +/// end is at or past `s`, or the last one when none is. `anchors` holds the arc length at each +/// anchor, so segment `i` ends at `anchors[i + 1]`, and the last segment of a closed spine, which has +/// no entry for its end, ends at the spine's length. The lengths never decrease (each is a sum of +/// distances that are not negative, at most `+inf` and never NaN), so a binary search finds the +/// segment in about `log2(n)` comparisons. `probe` is called once for each comparison. +fn segment_at(anchors: &[f64], n: usize, s: f64, mut probe: impl FnMut()) -> usize { + let ends = anchors.get(1..n.min(anchors.len())).unwrap_or_default(); + ends.partition_point(|&end| { + probe(); + end < s + }) + .min(n.saturating_sub(1)) +} + /// The anchor of the first subpath each key sits on, when they are all valid for `n` anchors. fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { (spec.key_anchors.len() == keys && keys >= 2 && spec.key_anchors.iter().all(|a| (*a as usize) < n)) @@ -1891,6 +1903,76 @@ mod tests { assert!(end.is_finite(), "{end}"); } + /// [`Spine::at_length`] by walking the segments from the first, as a reference for the search. + fn walked_at_length(sp: &Spine, s: f64) -> (Point, f64) { + let s = if s.is_finite() { s.clamp(0.0, sp.length()) } else { 0.0 }; + let n = sp.subpath.segment_count(); + let mut start = 0.0; + for i in 0..n { + let c = sp.subpath.segment(i); + let end = sp.anchor_length(i + 1).unwrap_or(sp.length()); + if s <= end || i + 1 == n { + let local_s = (s - start).max(0.0) / (end - start).max(1e-12) * c.arclen(1e-9); + let t = c.inv_arclen(local_s, 1e-9).clamp(0.0, 1.0); + let e = c.eval(t); + let tv = c.deriv().eval(t).to_vec2(); + let tv = if tv.hypot2() < 1e-24 { c.eval((t + 1e-3).min(1.0)) - c.eval((t - 1e-3).max(0.0)) } else { tv }; + return (Point::new(e.x, e.y), tv.y.atan2(tv.x)); + } + start = end; + } + panic!("a spine without segments") + } + + /// The search picks the same segment as walking them in order, so points and tangents stay the + /// same: on open and closed spines, one segment, curves, and zero-length segments whose anchors + /// share an arc length; at every anchor, between anchors, at both ends, past them, and for + /// values that are not finite. + #[test] + fn spine_lookup_matches_walking_the_segments() { + let p = Point::new; + let bow = SubPath::new( + vec![Anchor::smooth(p(0.0, 0.0), p(50.0, 50.0)), Anchor::smooth(p(100.0, 0.0), p(150.0, -50.0)), Anchor::corner(p(200.0, 0.0))], + false, + ); + let spines = [ + SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(30.0, 5.0), p(30.0, 5.0)], false), + SubPath::polyline(&[p(0.0, 0.0), p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0)], true), + SubPath::polyline(&[p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0), p(0.0, 0.0)], true), + SubPath::polyline(&[p(0.0, 0.0), p(25.0, 25.0)], false), + SubPath::polyline(&[p(0.0, 0.0), p(25.0, 25.0)], true), + bow.clone(), + SubPath { closed: true, ..bow }, + ]; + for sub in spines { + let sp = Spine::new(&PathData::single(sub)).unwrap(); + let len = sp.length(); + let mut lengths = vec![0.0, len, -1.0, len + 1.0, -len, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN]; + lengths.extend(sp.anchors.iter().flat_map(|&a| [a, a - 1e-9, a + 1e-9])); + lengths.extend((0..=400).map(|k| len * k as f64 / 400.0)); + for s in lengths { + assert_eq!(sp.at_length(s), walked_at_length(&sp, s), "s = {s} on {:?}", sp.anchors); + } + } + } + + /// Finding the segment for an arc length on a spine of thousands of segments takes a few + /// comparisons, not one for each segment before it. + #[test] + fn spine_lookup_compares_a_few_segment_ends() { + let pts: Vec = (0..=4096).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.0 } else { 1.0 })).collect(); + let sp = Spine::new(&PathData::single(SubPath::polyline(&pts, false))).unwrap(); + let n = sp.subpath.segment_count(); + assert_eq!(n, 4096); + for s in [sp.length() - 0.5, sp.length(), sp.length() / 2.0, 0.25] { + let mut compared = 0; + let i = segment_at(&sp.anchors, n, s, || compared += 1); + assert!(compared <= 13, "{compared} comparisons at {s}"); + assert_eq!(sp.at_length(s), walked_at_length(&sp, s)); + assert!(sp.anchors[i] <= s && (i + 1 == n || s <= sp.anchors[i + 1]), "segment {i} at {s}"); + } + } + #[test] fn easing_keeps_the_ends_and_stays_monotone() { use crate::live::{BlendEase, BlendEaseType as K}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..0c9c800549 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1781,6 +1781,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. - **Blend widgets on the canvas:** with one blend selected that has a spine, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow that spine; a blend made without a spine shows neither. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. +- **Positions along a blend spine:** blend steps, keys spread along a spine, the blend widgets and Objects on Path find the spine segment for each arc length with a binary search over the anchors' arc lengths, so a spine of 4,096 segments takes at most 13 comparisons per position instead of one per segment before it. Points and tangents are unchanged, including at anchors shared by zero-length segments. Evidence: `blend::tests::{spine_lookup_compares_a_few_segment_ends, spine_lookup_matches_walking_the_segments}`. - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`.