mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 10:47:27 +00:00
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:
+69
-14
@@ -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};
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user