#1010: leave keyed Orientation authored under a tilted parent

Euler angles are blended between keys, so re-expressing only the key values cannot keep the motion
between them under a tilted parent. Keyed Orientation is now re-expressed only for a Z-only
correction, which shifts Z exactly at every time; static Orientation is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kristian Myram
2026-10-13 02:52:08 +02:00
co-authored by Claude Sonnet 5.5
parent 707161bc63
commit 95f2f3ec55
2 changed files with 32 additions and 23 deletions
+8 -15
View File
@@ -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))
+24 -8
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(keys_before, keys_after);
}
// ---------------------------------------------------------------- from 3D model