Merge pull request #1038 from vbasky/fix/1010-camera-parent

This commit is contained in:
Brandon Thomas 2026-10-13 04:27:25 -04:00
commit 69f416e3b0
2 changed files with 66 additions and 2 deletions

View File

@ -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<LayerId>) -> Vec<ParentFix> {
@ -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| {

View File

@ -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");
}