Auto-orient: keep the last heading when an expression-driven Position stops (fixes #1650)

This commit is contained in:
Marco Arnulfo
2026-10-16 16:06:15 +02:00
parent 8f02f52a44
commit 0502d2f82d
2 changed files with 111 additions and 0 deletions
+47
View File
@@ -241,6 +241,53 @@ fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option<Vec3> {
.filter(|k| **k <= ctx.time)
.find_map(|&k| dir(pos(k) - pos(k - dt)))
.or_else(|| keys.iter().filter(|k| **k > ctx.time).find_map(|&k| dir(pos(k + dt) - pos(k))))
.or_else(|| {
// An expression has no keys to fall back on: sample it instead.
let expr = dims.iter().any(|m| tr.get(m).is_some_and(|p| p.has_expression()));
expr.then(|| sampled_tangent(ctx, layer, &pos, dt, true).or_else(|| sampled_tangent(ctx, layer, &pos, dt, false))).flatten()
})
}
/// Samples spent on each phase of [`sampled_tangent`]: it runs per layer per frame.
const MOTION_SEARCH_STEPS: u32 = 12;
/// Direction of the motion nearest to the context time, before it (`earlier`) or after it, for a
/// position with no keyframes to fall back on (an expression). Steps 1, 2, 4, ... frames away
/// (within the layer's in/out points) until the position differs from the current one, then
/// bisects down to `dt` the moment the motion stopped (or started), so the heading kept is the
/// one at the end of the motion whichever frame is shown.
fn sampled_tangent(ctx: &EvalCtx, layer: &Layer, pos: &dyn Fn(effectcraft_time::Tick) -> Vec3, dt: effectcraft_time::Tick, earlier: bool) -> Option<Vec3> {
let here = pos(ctx.time);
let moved = |t| (pos(t) - here).length() > 1e-9;
let limit = if earlier { layer.in_point.min(ctx.time) } else { layer.out_point.max(ctx.time) };
let mut step = ctx.comp.frame_duration().max(dt);
let (mut still, mut away) = (ctx.time, None);
for _ in 0..MOTION_SEARCH_STEPS {
let t = if earlier { (ctx.time - step).max(limit) } else { (ctx.time + step).min(limit) };
if moved(t) {
away = Some(t);
break;
}
still = t;
if t == limit {
break;
}
step = step + step;
}
let mut away = away?;
for _ in 0..MOTION_SEARCH_STEPS {
if (still - away).abs() <= dt {
break;
}
let mid = away + (still - away).mul_ratio(1, 2);
if moved(mid) {
away = mid;
} else {
still = mid;
}
}
let d = if earlier { here - pos(away) } else { pos(away) - here };
(d.length() > 1e-9 && d.length().is_finite()).then(|| d.normalize())
}
/// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees.
+64
View File
@@ -8,6 +8,7 @@ use effectcraft_project::{AutoOrient, Comp, ItemId, ItemKind, Layer, LayerSource
use effectcraft_time::{FrameRate, Tick};
use super::active_camera;
use super::camera::auto_orient_2d;
use crate::EvalCtx;
fn setup() -> (Project, ItemId, Comp) {
@@ -78,6 +79,69 @@ fn orient_along_path_keeps_the_last_heading_at_and_after_the_end() {
}
}
/// #1650: the same end-of-path flip when Position has no keyframes of its own and is driven by
/// an expression (e.g. `pointOnPath()` fed by a keyframed slider on another layer): once the
/// motion stops there are no Position keys to fall back on, and the heading snapped to 0 degrees.
#[test]
fn orient_along_path_keeps_the_last_heading_when_position_is_an_expression() {
/// Stand-in for an expression: holds, moves 100 px left over 0.5..1 s, then holds again (like
/// a slider going from 0% to 100%).
struct LeftThenHold;
impl crate::ExprHost for LeftThenHold {
fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result<Value, String> {
if prop.match_id != "position" {
return Ok(value.clone());
}
let s = ((ctx.time.seconds() - 0.5) * 2.0).clamp(0.0, 1.0);
Ok(Value::Vec3([150.0 - 100.0 * s, 50.0, 0.0]))
}
}
for three_d in [false, true] {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, three_d, [150.0, 50.0, 0.0]);
l.auto_orient = AutoOrient::AlongPath;
let pos = l.props.prop_mut("transform/position").unwrap();
assert!(pos.keys.is_empty(), "the fixture must have no Position keyframes");
pos.expr = Some(effectcraft_project::Expression { text: "left then hold".into(), enabled: true });
p.comp_mut(cid).unwrap().layers.push(l);
let c = p.comp(cid).unwrap();
for secs in [0.2, 0.75, 1.0, 1.0 + 1.0 / 30.0, 1.2, 1.9] {
let ctx = EvalCtx { expr: Some(&LeftThenHold), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(secs)) };
let x_axis = ctx.world_matrix(&c.layers[0]).apply_vec(vec3(1.0, 0.0, 0.0));
// Moving left (-x): the layer's x axis points left before the motion starts and after it stops.
assert!((x_axis.x + 1.0).abs() < 1e-6 && x_axis.y.abs() < 1e-6 && x_axis.z.abs() < 1e-6, "3d={three_d} t={secs}: {x_axis:?}");
}
}
}
/// #1650 on a curved path: the heading kept once an expression stops moving is the one at the end
/// of the motion, the same on every later frame, not whatever earlier point a coarse search lands on.
#[test]
fn orient_along_a_curved_expression_path_keeps_the_end_heading() {
/// Quarter circle over 0..1 s: heading 0 degrees at the start, 90 degrees (down) at the end.
struct Arc;
impl crate::ExprHost for Arc {
fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result<Value, String> {
if prop.match_id != "position" {
return Ok(value.clone());
}
let a = ctx.time.seconds().clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2;
Ok(Value::Vec3([100.0 * a.sin(), 100.0 - 100.0 * a.cos(), 0.0]))
}
}
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, false, [0.0, 0.0, 0.0]);
l.auto_orient = AutoOrient::AlongPath;
l.props.prop_mut("transform/position").unwrap().expr = Some(effectcraft_project::Expression { text: "arc".into(), enabled: true });
p.comp_mut(cid).unwrap().layers.push(l);
let c = p.comp(cid).unwrap();
for frame in [31, 33, 37, 45, 59] {
let ctx = EvalCtx { expr: Some(&Arc), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(frame as f64 / 30.0)) };
let deg = auto_orient_2d(&ctx, &c.layers[0]);
assert!((deg - 90.0).abs() < 0.5, "frame {frame}: {deg}");
}
}
#[test]
fn layers_parented_to_a_camera_follow_its_rotation() {
let (mut p, cid, comp) = setup();