Merge pull request #6254 from adambelis/fix/blend-curved-spine-orientation

M2.1: blend Align-to-Path smooth on curved spines
This commit is contained in:
BFlat
2026-11-09 06:27:53 +08:00
committed by GitHub
2 changed files with 71 additions and 15 deletions
+69 -14
View File
@@ -19,7 +19,7 @@ use std::f64::consts::PI;
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv};
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
@@ -904,9 +904,12 @@ pub struct Spine {
cum: Vec<f64>,
anchors: Vec<f64>,
closed: bool,
path: PathData,
}
impl Spine {
/// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative
/// arc lengths, and the arc length at each anchor.
pub fn new(path: &PathData) -> Option<Self> {
let sp = path.subpaths.first()?;
let mut pts: Vec<Point> = Vec::new();
@@ -943,8 +946,9 @@ impl Spine {
if pts.len() < 2 {
return None;
}
Some(Self { pts, cum, anchors, closed: sp.closed })
Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() })
}
/// The spine's flattened length in arc length.
pub fn length(&self) -> f64 {
*self.cum.last().unwrap_or(&0.0)
}
@@ -1002,20 +1006,32 @@ impl Spine {
pub fn at(&self, f: f64) -> (Point, f64) {
self.at_length(f.clamp(0.0, 1.0) * self.length())
}
/// Point and tangent angle (radians) at arc length `s` (clamped to the spine).
/// Point and tangent angle (radians) at arc length `s` (clamped to the spine). The point and
/// tangent come from the original curve at that arc length, not from the flattened polyline: a
/// flattened chord's direction is piecewise constant, so steps oriented to a curved spine with
/// `Align to Path` jump between chords instead of following the curve smoothly.
pub fn at_length(&self, s: f64) -> (Point, f64) {
let last = self.pts.len().saturating_sub(2);
let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 };
let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
Ok(i) => i.min(last),
Err(i) => i.saturating_sub(1).min(last),
};
let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else {
return (self.pts.first().copied().unwrap_or_default(), 0.0);
};
let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0);
let d = *p1 - *p0;
(p0.lerp(*p1, u), d.y.atan2(d.x))
let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) };
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;
}
// Only a spine without segments gets here: the loop returns at the last one.
(self.pts.first().copied().unwrap_or_default(), 0.0)
}
}
@@ -1446,6 +1462,45 @@ mod tests {
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
}
#[test]
fn orientations_follow_a_curved_spine_smoothly() {
// A curved spine: from (0, 0) with handles (+50, +50) / (-50, -50) to (100, 0) with handles
// (+50, -50) / (-50, +50) — the classic up-bow arc.
let spine = PathData::single(SubPath::new(
vec![Anchor::smooth(Point::new(0.0, 0.0), Point::new(50.0, 50.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(50.0, -50.0))],
false,
));
let sp = Spine::new(&spine).unwrap();
// Many steps along the bow: each step's tangent comes from the curve at its arc length,
// not from a flattened chord — so the orientations must sweep the true angle, smoothly.
let len = sp.length();
let mut prev: Option<f64> = None;
for i in 1..=200 {
let (pt, ang) = sp.at_length(len * i as f64 / 201.0);
if let Some(p) = prev {
let step = (ang - p).abs().min(2.0 * std::f64::consts::PI - (ang - p).abs());
assert!(step < 0.1, "tangent step {} is too big at {} ({}, {})", step, i, pt.x, pt.y);
}
prev = Some(ang);
}
}
/// Where the spine starts at an anchor whose handle sits on it, the first step still faces along
/// the curve (towards the next handle), not along a direction of zero length.
#[test]
fn a_handle_on_its_anchor_still_gives_the_curve_direction() {
let spine = PathData::single(SubPath::new(
vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::smooth(Point::new(100.0, 0.0), Point::new(150.0, -50.0))],
false,
));
let sp = Spine::new(&spine).unwrap();
let (p, angle) = sp.at_length(0.0);
assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "{p:?}");
assert!((angle - std::f64::consts::FRAC_PI_4).abs() < 0.05, "faces the in handle at (50, 50): {angle}");
let (_, end) = sp.at_length(sp.length());
assert!(end.is_finite(), "{end}");
}
#[test]
fn easing_keeps_the_ends_and_stays_monotone() {
use crate::live::{BlendEase, BlendEaseType as K};
+2 -1
View File
@@ -572,7 +572,8 @@ mod tests {
place(&mut g, &symbols);
g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false);
let rec = g.extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0));
// The spine measures arc length along the curve, so within rounding of the exact values.
assert!(rec.spacing.is_some_and(|d| (d - 40.0).abs() < 1e-9) && (rec.start - 20.0).abs() < 1e-9, "{rec:?}");
place(&mut g, &symbols);
let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x;
assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3)));