From 3840d227ff7d842fe20e428aca59af7b2a53d20e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:44:44 +0200 Subject: [PATCH] #1081: Orient Along Path keeps the last heading at and after the end of the path Co-Authored-By: Claude Sonnet 5.5 --- crates/render/src/three_d/camera.rs | 27 ++++++++++++++++++----- crates/render/src/three_d/tests_orient.rs | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 8c0f9d23..5e1f983b 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -212,18 +212,35 @@ pub fn rig_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 { // ---------------------------------------------------------------- auto-orient /// Direction of travel of a layer's position (parent space) at the context time. +/// +/// Where the position is not moving (at and after the last keyframe, before the first, or while +/// holding) the last valid direction of travel is kept, so the layer does not snap back to its +/// unrotated pose at the end of a path. fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option { let tr = layer.transform()?; // Position (or its separated X/Y/Z dimensions) must be animated. - let moving = ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.keys.len() > 1 || p.has_expression())); + let dims = ["position", "positionX", "positionY", "positionZ"]; + let moving = dims.iter().any(|m| tr.get(m).is_some_and(|p| p.keys.len() > 1 || p.has_expression())); if !moving { return None; } let dt = effectcraft_time::Tick::from_seconds_f64(0.005); - let a = Vec3::from(ctx.at(ctx.time - dt).v3(layer, tr, "position", [0.0; 3])); - let b = Vec3::from(ctx.at(ctx.time + dt).v3(layer, tr, "position", [0.0; 3])); - let d = b - a; - (d.length() > 1e-9).then(|| d.normalize()) + let pos = |t| Vec3::from(ctx.at(t).v3(layer, tr, "position", [0.0; 3])); + let dir = |d: Vec3| (d.length() > 1e-9 && d.length().is_finite()).then(|| d.normalize()); + if let Some(d) = dir(pos(ctx.time + dt) - pos(ctx.time - dt)) { + return Some(d); + } + // No motion here: fall back on the nearest motion before this time (a backward difference at + // each earlier key, latest first), then on the first motion after it. + let mut keys: Vec = dims.iter().filter_map(|m| tr.get(m)).flat_map(|p| p.keys.iter().map(|k| layer.comp_time(k.time))).collect(); + + keys.sort(); + keys.dedup(); + keys.iter() + .rev() + .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)))) } /// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees. diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs index 79e91f9c..06f2599d 100644 --- a/crates/render/src/three_d/tests_orient.rs +++ b/crates/render/src/three_d/tests_orient.rs @@ -55,6 +55,29 @@ fn orient_along_path_2d_follows_motion() { assert!(x_axis.x.abs() < 1e-6 && (x_axis.y - 1.0).abs() < 1e-6, "{x_axis:?}"); } +/// #1081: a layer travelling left kept its heading until the last keyframe, then (no motion left +/// to measure) snapped back to 0 degrees and looked flipped. It must keep its last direction of +/// travel at the final key and after it, in 2D and 3D. +#[test] +fn orient_along_path_keeps_the_last_heading_at_and_after_the_end() { + 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 end = Tick::from_seconds_f64(1.0); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([150.0, 50.0, 0.0])), Keyframe::new(end, Value::Vec3([50.0, 50.0, 0.0]))]; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + for secs in [0.5, 0.999, 1.0, 1.2, 1.9] { + let ctx = 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 keeps pointing left. + 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:?}"); + } + } +} + #[test] fn layers_parented_to_a_camera_follow_its_rotation() { let (mut p, cid, comp) = setup();