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]`.