mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Auto-orient: keep the last heading when an expression-driven Position stops (fixes #1650)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user