From b0be02fff41049603e422b82493525a039daaf1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:52:23 +0000 Subject: [PATCH] 1010: cap baked Orientation keys per property; leave one-node rigs under a mirrored parent as authored Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/engine/src/cmd/layer/parent.rs | 25 +++++++++++++---- crates/engine/src/tests/rig3d.rs | 39 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 64195d646..dd4c842a2 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -220,17 +220,19 @@ fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { } /// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend -/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out` +/// never grows past `budget` keys: what the whole property may still add, not what one segment may. fn bake_between( correction: effectcraft_geom::Mat4, authored: &effectcraft_project::Property, (ta, ea): (Tick, [f64; 3]), (tb, eb): (Tick, [f64; 3]), depth: u32, + budget: usize, out: &mut Vec, ) { use effectcraft_geom::{Mat4, Vec3}; - if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= BAKE_MAX_KEYS { + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget { return; } let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); @@ -248,9 +250,12 @@ fn bake_between( return; } let em = nearest_euler(euler_zyx(exact.0), blend); - bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, out); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out); + if out.len() >= budget { + return; + } out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); - bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, out); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out); } /// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the @@ -308,7 +313,10 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); let mut between = vec![]; if let Some(next) = segment { - bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, &mut between); + // What the whole property may still add: the keys it will have already (the ones written + // and the authored ones still to come) count against the cap. + let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i); + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between); // An eased segment, or one that gained keys, becomes a run of straight blends. if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { k.out_interp = Interp::Linear; @@ -391,6 +399,13 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); + // A one-node camera or light has no Scale to carry a mirror, and its frame (forward and + // down) would come out reversed: compensate none of it, so Position and Orientation stay + // as authored instead of half-following the parent. + let rig = layer.is_camera() || layer.is_light(); + if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) { + continue; + } if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index 059e74fbc..0fef2434c 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -333,6 +333,45 @@ fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { } } +/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and +/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is +/// compensated rather than reversing its frame (a half-compensated light would point backwards). +#[test] +fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let before = layer_in(&s, cid(&s), light); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0); + s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let after = layer_in(&s, cid(&s), light); + assert_eq!(after.parent, Some(LayerId(nul))); + assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position")); + assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation")); +} + +/// The cap on keys added by baking is for the whole Orientation property, not for each segment: +/// many strongly eased segments under a tilted parent stay within it. +#[test] +fn baked_orientation_keys_are_capped_across_the_whole_property() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + let count = 120; + for i in 0..count { + let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) }; + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(); + assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment"); +} + // ---------------------------------------------------------------- from 3D model /// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points.