//! 3D parenting preserves Position and static poses in uniform parent spaces. use effectcraft_engine::{ Session, geom::{Mat4, Vec3, vec3}, project::{BitDepth, LayerId}, raster::Image, render::{Backend, EvalCtx, NoFootage, RenderOpts, Renderer}, time::Tick, }; use serde_json::{Value, json}; use std::sync::Arc; fn command(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, p).unwrap() } fn set(s: &mut Session, layer: u64, path: &str, value: Value) { command(s, "prop.set", json!({"layer":layer,"path":path,"value":value})); } fn solid(s: &mut Session, name: &str) -> u64 { command(s, "layer.newSolid", json!({"name":name,"width":12,"height":10,"color":[0.8,0.3,0.1]}))["layer"].as_u64().unwrap() } fn fixture(three: bool, rotated: bool) -> (Session, u64, u64) { let mut s = Session::default(); command(&mut s, "comp.new", json!({"name":"Synthetic 3D parent","width":64,"height":48,"duration":2,"frameRate":24})); Arc::make_mut(&mut s.project).settings.bit_depth = BitDepth::Bpc32; let parent = solid(&mut s, "Parent"); let child = solid(&mut s, "Child"); command(&mut s, "layer.setSwitch", json!({"layers":[parent],"switch":"video","value":false})); if three { command(&mut s, "layer.setSwitch", json!({"layers":[parent,child],"switch":"threeD","value":true})); } set(&mut s, parent, "transform/position", json!([20.0, 14.0, if three { 8.0 } else { 0.0 }])); set(&mut s, child, "transform/position", json!([30.0, 25.0, if three { 3.0 } else { 0.0 }])); if rotated { set(&mut s, parent, "transform/orientation", json!([15.0, -18.0, 23.0])); set(&mut s, parent, "transform/rotationX", json!(7.0)); set(&mut s, parent, "transform/rotationY", json!(-11.0)); set(&mut s, parent, "transform/rotation", json!(9.0)); set(&mut s, parent, "transform/scale", json!([120.0, 120.0, 120.0])); set(&mut s, child, "transform/orientation", json!([-8.0, 12.0, -13.0])); set(&mut s, child, "transform/rotationX", json!(4.0)); set(&mut s, child, "transform/rotationY", json!(6.0)); set(&mut s, child, "transform/rotation", json!(-5.0)); } (s, parent, child) } fn world(s: &Session, child: u64, t: Tick) -> Mat4 { let cid = s.active_comp_id().unwrap(); let c = s.project.comp(cid).unwrap(); EvalCtx::new(&s.project, cid, c, t).world_matrix(c.layer(LayerId(child)).unwrap()) } fn points(m: Mat4) -> [Vec3; 5] { [vec3(6.0, 5.0, 0.0), vec3(0.0, 0.0, 0.0), vec3(12.0, 0.0, 0.0), vec3(0.0, 10.0, 0.0), vec3(12.0, 10.0, 0.0)].map(|p| m.apply(p)) } fn displacement(a: Mat4, b: Mat4) -> f64 { points(a).into_iter().zip(points(b)).map(|(a, b)| (a - b).length()).fold(0.0, f64::max) } fn cpu(s: &Session, t: Tick) -> Image { Renderer::new(&s.project, &NoFootage, RenderOpts { backend: Backend::Cpu, motion_blur: false, ..Default::default() }) .comp_frame_cpu(s.active_comp_id().unwrap(), t) } fn finite_visible(i: &Image) { assert_eq!([i.width, i.height], [64, 48]); assert!(i.data.iter().flatten().all(|v| v.is_finite())); assert!(i.data.iter().any(|p| p[3] > 0.9 && p[0] > 0.2), "synthetic child must be visibly rendered"); } fn diff(a: &Image, b: &Image) -> f32 { assert_eq!([a.width, a.height], [b.width, b.height]); a.data.iter().flatten().zip(b.data.iter().flatten()).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max) } fn parent(s: &mut Session, child: u64, to: Option, compensate: bool) { command(s, "layer.setParent", json!({"layers":[child],"parent":to,"compensate":compensate})); } #[test] fn parenting_2d_compensation_positive_control() { let (mut s, p, c) = fixture(false, false); let before = world(&s, c, Tick::ZERO); let image = cpu(&s, Tick::ZERO); finite_visible(&image); let old = s.project.clone(); let undo = s.history.undo.len(); parent(&mut s, c, Some(p), true); assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9); assert!(diff(&image, &cpu(&s, Tick::ZERO)) < 1e-6); assert_eq!(s.history.undo.len(), undo + 1); let attached = s.project.clone(); assert!(s.undo()); assert!(Arc::ptr_eq(&old, &s.project)); assert!(s.redo()); assert!(Arc::ptr_eq(&attached, &s.project)); parent(&mut s, c, None, true); assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9); } #[test] fn parenting_3d_explicit_uncompensated_positive_control() { let (mut s, p, c) = fixture(true, true); let old = world(&s, c, Tick::ZERO); let pm = world(&s, p, Tick::ZERO); let old_transform = s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap().clone(); parent(&mut s, c, Some(p), false); let after = world(&s, c, Tick::ZERO); assert!(displacement(pm * old, after) < 1e-9); assert!(displacement(old, after) > 1.0, "control must visibly follow its parent"); assert_eq!(&old_transform, s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap()); assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite())); } #[test] fn parenting_3d_default_parent_preserves_position_and_cpu_pixels() { let (mut s, p, c) = fixture(true, false); let before = world(&s, c, Tick::ZERO); let image = cpu(&s, Tick::ZERO); finite_visible(&image); // Omit compensate to exercise the same public default as timeline pick-whip. command(&mut s, "layer.setParent", json!({"layers":[c],"parent":p})); let out = cpu(&s, Tick::ZERO); assert!(out.data.iter().flatten().all(|v| v.is_finite())); let motion = displacement(before, world(&s, c, Tick::ZERO)); let pixels = diff(&image, &out); eprintln!("DEFAULT_PARENT motion={motion} pixels={pixels}"); assert!(motion < 1e-9 && pixels < 1e-6, "3D default parenting must preserve world position and rendered pixels"); } #[test] fn parenting_3d_rotated_uniform_parent_preserves_pose() { let (mut s, p, c) = fixture(true, true); let before = world(&s, c, Tick::ZERO); let image = cpu(&s, Tick::ZERO); finite_visible(&image); parent(&mut s, c, Some(p), true); let motion = displacement(before, world(&s, c, Tick::ZERO)); let out = cpu(&s, Tick::ZERO); let pixels = diff(&image, &out); eprintln!("ROTATED_PARENT motion={motion} pixels={pixels}"); assert!(motion < 1e-8 && pixels < 2e-5, "3D rotated uniform parent must preserve child pose"); } #[test] fn parenting_3d_unparent_preserves_position() { let (mut s, p, c) = fixture(true, false); parent(&mut s, c, Some(p), false); let before = world(&s, c, Tick::ZERO); let image = cpu(&s, Tick::ZERO); finite_visible(&image); parent(&mut s, c, None, true); let motion = displacement(before, world(&s, c, Tick::ZERO)); let pixels = diff(&image, &cpu(&s, Tick::ZERO)); eprintln!("UNPARENT motion={motion} pixels={pixels}"); assert!(motion < 1e-9 && pixels < 1e-6, "3D unparenting must preserve world position"); } #[test] fn parenting_3d_animated_position_preserves_authored_keys() { let (mut s, p, c) = fixture(true, false); for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value})); } let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64); let before = times.map(|t| world(&s, c, t)); let undo = s.history.undo.len(); let old = s.project.clone(); parent(&mut s, c, Some(p), true); let motion = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); eprintln!("ANIMATED_PARENT motion={motion}"); assert!(motion < 1e-8, "3D parenting must re-express ordinary Position keys"); assert_eq!(s.history.undo.len(), undo + 1); let attached = s.project.clone(); assert!(s.undo()); assert!(Arc::ptr_eq(&old, &s.project)); assert!(s.redo()); assert!(Arc::ptr_eq(&attached, &s.project)); } fn anchor_motion(a: Mat4, b: Mat4) -> f64 { (a.apply(vec3(6.0, 5.0, 0.0)) - b.apply(vec3(6.0, 5.0, 0.0))).length() } #[test] fn parenting_3d_rotated_parent_preserves_anchor() { let (mut s, p, c) = fixture(true, true); let before = world(&s, c, Tick::ZERO); let image = cpu(&s, Tick::ZERO); finite_visible(&image); parent(&mut s, c, Some(p), true); let after = world(&s, c, Tick::ZERO); let anchor = anchor_motion(before, after); eprintln!("ROTATED_ANCHOR anchor={anchor} corners={} pixels={}", displacement(before, after), diff(&image, &cpu(&s, Tick::ZERO))); assert!(anchor < 1e-8, "3D rotated parenting must retain the world-space anchor"); parent(&mut s, c, None, true); assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "3D rotated unparent must retain the world-space anchor"); } #[test] fn parenting_3d_separated_position_preserves_each_axis_and_undo() { let (mut s, p, c) = fixture(true, false); command(&mut s, "prop.separateDimensions", json!({"layer":c,"value":true})); for (name, keys) in [ ("positionX", vec![(0.0, 27.0), (1.0, 35.0)]), ("positionY", vec![(0.0, 22.0), (0.5, 27.0), (1.0, 29.0)]), ("positionZ", vec![(0.0, 2.0), (0.25, 3.0), (1.0, 5.0)]), ] { for (time, value) in keys { command(&mut s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"time":time,"value":value})); } } let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); let before = times.map(|t| world(&s, c, t)); let old = s.project.clone(); let undo = s.history.undo.len(); parent(&mut s, c, Some(p), true); let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); eprintln!("SEPARATED_PARENT motion={error}"); assert!(error < 1e-8, "3D parenting must preserve separated Position animation"); for name in ["positionX", "positionY", "positionZ"] { let a = &old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys; let b = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys; assert_eq!(a.iter().map(|k| k.time).collect::>(), b.iter().map(|k| k.time).collect::>(), "parent compensation does not retime axes"); } assert_eq!(s.history.undo.len(), undo + 1); let attached = s.project.clone(); assert!(s.undo()); assert!(Arc::ptr_eq(&old, &s.project)); assert!(s.redo()); assert!(Arc::ptr_eq(&attached, &s.project)); parent(&mut s, c, None, true); for (t, b) in times.into_iter().zip(before) { assert!(displacement(b, world(&s, c, t)) < 1e-8); } } #[test] fn parenting_3d_position_spatial_tangents_follow_relative_parent_space() { let (mut s, p, c) = fixture(true, true); for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value})); } // Generated manual spatial-curve data; temporal interpolation remains linear. let cid = s.active_comp_id().unwrap(); let pr = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap(); for key in &mut pr.keys { key.spatial_auto = false; } pr.keys[0].spatial_out = [3.0, 5.0, -1.0]; pr.keys[1].spatial_in = [-4.0, 2.0, 1.0]; let tangents = pr.keys.iter().map(|k| (k.spatial_in, k.spatial_out)).collect::>(); let relative = world(&s, p, Tick::ZERO).inverse().unwrap(); let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); let before = times.map(|t| world(&s, c, t)); parent(&mut s, c, Some(p), true); let error = times.into_iter().zip(before).map(|(t, b)| anchor_motion(b, world(&s, c, t))).fold(0.0, f64::max); let keys = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/position").unwrap().keys; let tangent_error = keys .iter() .zip(tangents) .flat_map(|(key, (tin, tout))| [(key.spatial_in, tin), (key.spatial_out, tout)]) .map(|(after, before)| (Vec3::from(after) - relative.apply_vec(Vec3::from(before))).length()) .fold(0.0, f64::max); eprintln!("SPATIAL_PARENT anchor={error} tangent={tangent_error}"); assert!(error < 1e-8 && tangent_error < 1e-8, "3D Position spatial tangents must remain relative vectors"); } #[test] fn parenting_3d_parent_source_pixel_aspect_is_not_inherited() { let (mut s, _, c) = fixture(true, false); let p = command(&mut s, "layer.newSolid", json!({"name":"Wide pixels parent","width":12,"height":10,"color":[0.1,0.2,0.3],"pixelAspect":2.0}))["layer"] .as_u64() .unwrap(); command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"threeD","value":true})); command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"video","value":false})); set(&mut s, p, "transform/position", json!([20.0, 14.0, 8.0])); let before = world(&s, c, Tick::ZERO); let image = cpu(&s, Tick::ZERO); finite_visible(&image); parent(&mut s, c, Some(p), true); let error = displacement(before, world(&s, c, Tick::ZERO)); let pixels = diff(&image, &cpu(&s, Tick::ZERO)); eprintln!("PAR_PARENT motion={error} pixels={pixels}"); assert!(error < 1e-8 && pixels < 1e-6, "3D children must not inherit parent source pixel aspect"); } fn rotation_properties(s: &Session, c: u64) -> Vec { ["rotationX", "rotationY", "rotation"] .map(|name| s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().clone()) .into() } fn axis_animation(s: &mut Session, c: u64) { for (name, values) in [("rotationX", [-21.0, 35.0]), ("rotationY", [9.0, -30.0]), ("rotation", [12.0, 28.0])] { for (time, value) in [(0.0, values[0]), (1.0, values[1])] { command(s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"time":time,"value":value})); } } } #[test] fn parenting_static_orientation_preserves_nonuniform_child_scale_and_axis_rotation_keys() { let (mut s, p, c) = fixture(true, true); set(&mut s, c, "transform/scale", json!([83.0, 127.0, 61.0])); axis_animation(&mut s, c); let rotations = rotation_properties(&s, c); let old = s.project.clone(); let undo = s.history.undo.len(); let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); let before = times.map(|t| world(&s, c, t)); let images = times.map(|t| cpu(&s, t)); for i in &images { finite_visible(i); } parent(&mut s, c, Some(p), true); let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); eprintln!("STATIC_AXIS_POSE corners={error} pixels={pixels}"); assert!(error < 1e-8 && pixels < 2e-5, "static Orientation must preserve pose with nonuniform child Scale and authored axis Rotation animation"); assert_eq!(rotation_properties(&s, c), rotations, "axis Rotation properties are byte-for-byte authored"); assert_eq!(s.history.undo.len(), undo + 1); let attached = s.project.clone(); assert!(s.undo()); assert!(Arc::ptr_eq(&s.project, &old)); assert!(s.redo()); assert!(Arc::ptr_eq(&s.project, &attached)); parent(&mut s, c, None, true); for (t, b) in times.into_iter().zip(before) { assert!(displacement(b, world(&s, c, t)) < 1e-8); } assert_eq!(rotation_properties(&s, c), rotations); } #[test] fn parenting_uniform_parent_preserves_animated_eased_scale() { use effectcraft_engine::keyframe::{Ease, Interp}; let (mut s, p, c) = fixture(true, true); for (time, value) in [(0.0, json!([80.0, 120.0, 95.0])), (1.0, json!([135.0, 75.0, 150.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/scale","time":time,"value":value})); } let cid = s.active_comp_id().unwrap(); let scale = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/scale").unwrap(); for key in &mut scale.keys { key.in_interp = Interp::Bezier; key.out_interp = Interp::Bezier; key.in_ease = vec![Ease { speed: 17.0, influence: 0.3 }, Ease { speed: -13.0, influence: 0.6 }, Ease { speed: 7.0, influence: 0.4 }]; key.out_ease = vec![Ease { speed: 11.0, influence: 0.4 }, Ease { speed: -19.0, influence: 0.2 }, Ease { speed: 13.0, influence: 0.5 }]; } let old_scale = scale.clone(); let rotations = rotation_properties(&s, c); let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); let before = times.map(|t| world(&s, c, t)); let images = times.map(|t| cpu(&s, t)); for image in &images { finite_visible(image); } parent(&mut s, c, Some(p), true); let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); eprintln!("EASED_SCALE_POSE corners={error} pixels={pixels}"); assert!(error < 1e-8 && pixels < 2e-5, "uniform parent compensation must preserve animated eased Scale poses"); assert_eq!(rotation_properties(&s, c), rotations); let scale = s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/scale").unwrap(); assert_eq!(scale.keys.iter().map(|k| k.time).collect::>(), old_scale.keys.iter().map(|k| k.time).collect::>()); for (old, new) in old_scale.keys.iter().zip(&scale.keys) { for (old, new) in old.in_ease.iter().chain(&old.out_ease).zip(new.in_ease.iter().chain(&new.out_ease)) { assert!((new.speed - old.speed / 1.2).abs() < 1e-9); assert_eq!(new.influence, old.influence); } } } #[test] fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() { let mut max_corners = 0.0f64; let mut max_pixels = 0.0f32; for factor in [120.0, -120.0] { for orientation in [[23.0, 90.0, -37.0], [-41.0, -90.0, 16.0]] { let (mut s, p, c) = fixture(true, true); set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0])); set(&mut s, p, "transform/rotationX", json!(0.0)); set(&mut s, p, "transform/rotationY", json!(0.0)); set(&mut s, p, "transform/rotation", json!(42.0)); set(&mut s, p, "transform/scale", json!([factor, factor, factor])); set(&mut s, c, "transform/orientation", json!(orientation)); set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0])); let before = world(&s, c, Tick::ZERO); let image = cpu(&s, Tick::ZERO); assert!(image.data.iter().flatten().all(|v| v.is_finite())); assert!(image.data.iter().any(|p| p[3] > 0.05), "gimbal control must render visible pixels"); let rotations = rotation_properties(&s, c); parent(&mut s, c, Some(p), true); let error = displacement(before, world(&s, c, Tick::ZERO)); let pixels = diff(&image, &cpu(&s, Tick::ZERO)); max_corners = max_corners.max(error); max_pixels = max_pixels.max(pixels); assert_eq!(rotation_properties(&s, c), rotations); } } eprintln!("SIGNED_GIMBAL_POSE corners={max_corners} pixels={max_pixels}"); assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles"); } #[test] fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } } else { set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); let scale = layer.props.prop("transform/scale").unwrap().clone(); let before = world(&s, c, Tick::ZERO); parent(&mut s, c, Some(p), true); let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); assert_eq!(layer.props.prop("transform/orientation").unwrap(), &orientation, "unsupported animated Euler/shear receives no blind Orientation rewrite"); assert_eq!(layer.props.prop("transform/scale").unwrap(), &scale, "unsupported animated Euler/shear receives no blind Scale rewrite"); assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "boundary retains already-proved Position behavior"); assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite())); } } /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { use effectcraft_engine::keyframe::{Ease, Interp}; let mut maximum_error = 0.0f64; for factor in [100.0, 200.0] { let (mut s, p, c) = fixture(true, false); set(&mut s, p, "transform/scale", json!([factor, factor, factor])); for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value})); } let cid = s.active_comp_id().unwrap(); let position = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap(); for key in &mut position.keys { key.in_interp = Interp::Bezier; key.out_interp = Interp::Bezier; key.in_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }]; key.out_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }]; key.auto_bezier = false; key.spatial_auto = false; key.spatial_in = [0.0; 3]; key.spatial_out = [0.0; 3]; } let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64); let before = times.map(|t| world(&s, c, t)); assert!(anchor_motion(before[0], before[1]) > 1.0, "the eased Position fixture must actually move at quarter time"); let old = s.project.clone(); let undo = s.history.undo.len(); // Omit compensate to exercise the public default without depending on the schema-only fix. s.execute_checked("layer.setParent", json!({"layers":[c],"parent":p})).unwrap(); let after = times.map(|t| world(&s, c, t)); assert!(after.iter().all(|m| m.0.iter().flatten().all(|v| v.is_finite()))); for i in [0, 3] { assert!(anchor_motion(before[i], after[i]) < 1e-8, "parent compensation must preserve the authored Position endpoints"); } for i in [1, 2] { let error = anchor_motion(before[i], after[i]); maximum_error = maximum_error.max(error); eprintln!("EASED_POSITION_PARENT factor={factor} time={} anchor_error={error}", times[i].seconds()); } assert_eq!(s.history.undo.len(), undo + 1, "parent compensation is one undo action"); let attached = s.project.clone(); assert!(s.undo()); assert!(Arc::ptr_eq(&old, &s.project), "Undo restores the exact authored project"); for (t, expected) in times.into_iter().zip(before) { assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Undo restores all sampled world anchors"); } assert!(s.redo()); assert!(Arc::ptr_eq(&attached, &s.project), "Redo restores the exact compensated project"); for (t, expected) in times.into_iter().zip(after) { assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Redo retains the same compensated samples"); } } assert!(maximum_error < 1e-8, "3D uniform parent compensation must preserve eased Position at quarter and three-quarter times"); }