diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90..7d3e82cd 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -647,6 +647,28 @@ impl ParentFix { } } } + // Cameras and lights aim with Point of Interest: move it by the same parent-space + // change, so a two-node rig keeps its world frame (#1010, #1020). + if let Some(pr) = tr.get_mut("poi") { + pr.value = map_position(&pr.value); + for key in &mut pr.keys { + key.value = map_position(&key.value); + for tangent in [&mut key.spatial_in, &mut key.spatial_out] { + let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); + *tangent = [v.x, v.y, v.z]; + } + } + if let Some(factor) = position_speed + .filter(|_| pr.spatial) + .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) + { + for key in &mut pr.keys { + for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { + ease.speed *= factor; + } + } + } + } if let Some((orientation, factor)) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { pr.value = KV::Vec3(orientation); @@ -749,7 +771,8 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) } -/// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is +/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, +/// and Point of Interest for two-node cameras and lights). Position is /// re-expressed in the new parent's space (`None`: the composition), evaluated at the current /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { @@ -793,7 +816,10 @@ 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.is_camera() && !l.is_light() && l.parent != parent) { + // Cameras and lights compensate like 3D layers (Position, and Point of Interest for + // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no + // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. + 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); if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 8c06cf8a..fcf268b0 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -462,3 +462,41 @@ fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() } assert!(maximum_error < 1e-8, "3D uniform parent compensation must preserve eased Position at quarter and three-quarter times"); } + +/// Parenting a two-node camera to a default null keeps its world frame (eye, forward, down): +/// Position and Point of Interest are re-expressed in the null's space (#1010, #1020). +#[test] +fn parenting_camera_to_default_null_preserves_framing() { + use effectcraft_engine::render::three_d::camera::layer_frame; + let mut s = Session::default(); + command(&mut s, "comp.new", json!({"name":"Camera","width":640,"height":360,"duration":2,"frameRate":24})); + let cam = command(&mut s, "layer.newCamera", json!({"name":"Camera 1"}))["layer"].as_u64().unwrap(); + let null = command(&mut s, "layer.newNull", json!({"name":"Null 1"}))["layer"].as_u64().unwrap(); + let frame = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let c = s.project.comp(cid).unwrap(); + let ctx = EvalCtx::new(&s.project, cid, c, Tick::ZERO); + layer_frame(&ctx, c.layer(LayerId(cam)).unwrap()) + }; + let authored = |s: &Session, path: &str| { + let cid = s.active_comp_id().unwrap(); + s.project.comp(cid).unwrap().layer(LayerId(cam)).unwrap().props.prop(path).unwrap().value.as_vec3() + }; + let (eye0, fwd0, down0) = frame(&s); + let (pos0, poi0) = (authored(&s, "transform/position"), authored(&s, "transform/poi")); + let frame_error = |(a0, b0, c0): (Vec3, Vec3, Vec3), (a1, b1, c1): (Vec3, Vec3, Vec3)| { + [(a0 - a1).length(), (b0 - b1).length(), (c0 - c1).length()].into_iter().fold(0.0, f64::max) + }; + let undo = s.history.undo.len(); + parent(&mut s, cam, Some(null), true); + assert!(frame_error(frame(&s), (eye0, fwd0, down0)) < 1e-9, "the camera frame must not move"); + // Compensation rewrote the authored values, not just the parent pointer. + assert!((authored(&s, "transform/position")[0] - pos0[0]).abs() > 1.0, "position is re-expressed in the null's space"); + assert!((authored(&s, "transform/poi")[0] - poi0[0]).abs() > 1.0, "point of interest follows it"); + assert_eq!(s.history.undo.len(), undo + 1, "parent compensation is one undo action"); + // Unparenting restores the authored transform and the same frame. + parent(&mut s, cam, None, true); + assert!(frame_error(frame(&s), (eye0, fwd0, down0)) < 1e-9); + assert_eq!(authored(&s, "transform/position"), pos0, "unparenting restores position"); + assert_eq!(authored(&s, "transform/poi"), poi0, "unparenting restores point of interest"); +}