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.
This commit is contained in:
dotCooCoo
2026-11-13 06:46:06 -08:00
parent e8c7421a3d
commit 666084fb81
2 changed files with 100 additions and 17 deletions
+99 -17
View File
@@ -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<Vec<usize>> {
(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<Point> = (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};
+1
View File
@@ -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]`.