diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 8222ad34e..7208c1ff1 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -789,9 +789,10 @@ fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [ nearest_euler(euler_zyx(world.0), reference) } -/// Re-expresses Orientation (the value and every key) as `correction * Orientation`. A Z-only -/// correction shifts Z exactly, so the keys and the paths between them stay exact. Other -/// corrections keep each key's authored turns, so the Euler values stay continuous. +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key, so the paths between keys stay exact too. Any other correction is applied +/// only to a static value (`parent_fixes` leaves keyed Orientation alone then): Euler angles are +/// blended between keys, which no re-expression of the keys can reproduce. fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { if let Some(turn) = z_only_turn(correction) { // No turn (an identity correction) leaves every authored value as it is. @@ -810,18 +811,6 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf } let value = pr.value.as_vec3(); pr.value = KV::Vec3(reexpress_euler(correction, value, value)); - let mut prev: Option<([f64; 3], [f64; 3])> = None; - for key in &mut pr.keys { - let o = key.value.as_vec3(); - // Each key keeps the authored change from the previous key on top of its converted value. - let reference = match prev { - Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], - None => o, - }; - let n = reexpress_euler(correction, o, reference); - key.value = KV::Vec3(n); - prev = Some((o, n)); - } } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -923,6 +912,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } + // Keys can only follow a Z-only correction exactly; otherwise they stay authored. + if !orientation.keys.is_empty() && z_only_turn(correction).is_none() { + return None; + } if let Some(scale) = scale { let values_finite = std::iter::once(&scale.value) .chain(scale.keys.iter().map(|key| &key.value)) diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 87bb98699..ecae51cb1 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -282,22 +282,38 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() { assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); } -/// Keyed Orientation is re-expressed at each key under a rotated null, so the light keeps its -/// direction at every key. +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. #[test] -fn keyed_orientation_is_re_expressed_at_each_key_under_a_rotated_null() { +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { let mut s = session(); let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); } - let keys = [Tick::ZERO, Tick::from_seconds_f64(1.0)]; - let before = keys.map(|t| frame_at(&s, light, t)); + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys cannot follow exactly (Euler angles are blended between them), so +/// keyed Orientation stays as authored rather than changing the motion between the keys. +#[test] +fn keyed_orientation_under_a_tilted_null_stays_authored() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let keys_before = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); - for (t, b) in keys.into_iter().zip(before) { - assert!(frames_match(b, frame_at(&s, light, t)), "keyed Orientation keeps the light's direction at each key"); - } + let keys_after = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); + assert_eq!(keys_before, keys_after); } // ---------------------------------------------------------------- from 3D model