Graph Editor: the speed graph of a curved motion path is smooth, not jagged (#456)

Spatial properties move along their path by arc length: a table of 96 chords per segment,
read back by linear interpolation of the curve parameter. Within each cell that mapping is
linear, so the speed (the Graph Editor's speed graph, a central difference of the
position) jumped at every cell boundary and was off by up to ~2 % on a curved path: a
sawtooth over a constant-speed move, and a slight wobble in the motion itself.

The table now holds Gauss-Legendre arc lengths, and `at_length` refines the cell's linear
guess with Newton steps on the true arc length (|B'(u)| as the derivative), so position and
speed follow the path exactly.

Test: linear keys along a strongly curved path give a constant speed (within 0.1 %) at
every sample; the old mapping was off by 2 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude
2026-10-10 20:42:34 +00:00
parent faffad3a6e
commit 1c9b5f1828
+55 -7
View File
@@ -233,26 +233,44 @@ struct SpatialSeg {
const ARC_N: usize = 96;
/// 5-point Gauss-Legendre nodes and weights on -1..1.
const GAUSS: [(f64, f64); 5] = [
(0.0, 0.568_888_888_888_888_9),
(-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
(0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
(-0.906_179_845_938_664, 0.236_926_885_056_189_1),
(0.906_179_845_938_664, 0.236_926_885_056_189_1),
];
impl SpatialSeg {
fn new(p: [[f64; 3]; 4]) -> SpatialSeg {
let mut table = Vec::with_capacity(ARC_N + 1);
table.push(0.0);
let mut prev = Self::point(&p, 0.0);
let mut acc = 0.0;
for i in 1..=ARC_N {
let q = Self::point(&p, i as f64 / ARC_N as f64);
acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt();
acc += Self::arc(&p, (i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
table.push(acc);
prev = q;
}
SpatialSeg { p, table }
}
fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] {
[bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)]
}
/// |dB/du|.
fn tangent_len(p: &[[f64; 3]; 4], u: f64) -> f64 {
(0..3).map(|k| bez_d(p[0][k], p[1][k], p[2][k], p[3][k], u).powi(2)).sum::<f64>().sqrt()
}
/// Arc length between `a` and `b` (Gauss-Legendre; exact enough on one table cell).
fn arc(p: &[[f64; 3]; 4], a: f64, b: f64) -> f64 {
let (m, h) = ((a + b) * 0.5, (b - a) * 0.5);
GAUSS.iter().map(|(x, w)| w * Self::tangent_len(p, m + h * x)).sum::<f64>() * h
}
fn length(&self) -> f64 {
*self.table.last().unwrap_or(&0.0)
}
/// The point at arc length `s`. The table cell's linear guess is refined with Newton steps
/// on the true arc length, so the position (and the speed graph, its derivative) is smooth
/// along curved paths instead of piecewise linear per cell (#456).
fn at_length(&self, s: f64) -> [f64; 3] {
let len = self.length();
if len <= 0.0 {
@@ -260,9 +278,21 @@ impl SpatialSeg {
}
let s = s.clamp(0.0, len);
let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N);
let (a, b) = (self.table[i - 1], self.table[i]);
let f = if b > a { (s - a) / (b - a) } else { 0.0 };
Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64)
let (Some(&a), Some(&b)) = (self.table.get(i - 1), self.table.get(i)) else { return self.p[0] };
let (u0, u1) = ((i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
let mut u = u0 + if b > a { (s - a) / (b - a) } else { 0.0 } * (u1 - u0);
for _ in 0..4 {
let d = Self::tangent_len(&self.p, u);
if d <= 1e-12 {
break;
}
let e = a + Self::arc(&self.p, u0, u) - s;
u = (u - e / d).clamp(u0, u1);
if e.abs() <= 1e-12 * len.max(1.0) {
break;
}
}
Self::point(&self.p, u)
}
}
@@ -764,6 +794,24 @@ mod tests {
assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}");
}
/// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is
/// flat, not a sawtooth from a piecewise-linear arc-length table.
#[test]
fn spatial_curved_path_has_smooth_constant_speed() {
let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]));
let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0]));
a.spatial_auto = false;
b.spatial_auto = false;
a.spatial_out = [50.0, -300.0, 0.0];
b.spatial_in = [80.0, 250.0, 0.0];
let keys = vec![a, b];
let len = spatial_segment_length(&keys, 0);
let speeds: Vec<f64> = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect();
for v in &speeds {
assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0);
}
}
#[test]
fn spatial_auto_bezier_passes_through_keys() {
let keys = vec![