From 37c07ba5b1902f7218079ad607af5b4cd6ee687c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:42:27 -0400 Subject: [PATCH] M7.3: Auto-Orient (along path, towards camera), Draft 3D, 3D switch off drops Z MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - layer.autoOrient {mode: off|alongPath|towardsCamera|towardsPointOfInterest} (Layer > Transform > Auto-Orient…, Cmd+Alt+O); render support for Orient Along Path (2D and 3D) and Orient Towards Camera (with recursion guard). - Layers parented to cameras/lights follow their full rotation. - Draft 3D turns off lights and shadows (and DOF). - Turning the 3D switch off discards Z values, Orientation and X/Y Rotation. - Tests for all of the above. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 32 ++++++- crates/engine/src/commands/three_d.rs | 44 ++++++++++ crates/engine/src/tests_3d.rs | 35 ++++++++ crates/render/src/eval.rs | 17 +++- crates/render/src/three_d/camera.rs | 102 +++++++++++++++++++--- crates/render/src/three_d/compose.rs | 9 +- crates/render/src/three_d/mod.rs | 2 + crates/render/src/three_d/tests_orient.rs | 88 +++++++++++++++++++ 8 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 crates/render/src/three_d/tests_orient.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 911ffb02..dba27cca 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -363,13 +363,43 @@ fn set_switch_value(l: &mut Layer, name: &str, v: bool) { "frameBlend" => sw.frame_blend = if v { FrameBlend::FrameMix } else { FrameBlend::Off }, "motionBlur" => sw.motion_blur = v, "adjustment" => sw.adjustment = v, - "threeD" | "3d" | "3D" => sw.three_d = v, + "threeD" | "3d" | "3D" => { + if sw.three_d && !v { + drop_3d_values(l); + } + l.switches.three_d = v; + } "guide" => sw.guide = v, "preserveTransparency" => l.preserve_transparency = v, _ => {} } } +/// Turning the 3D switch off discards Z values, Orientation and X/Y Rotation (as in AE). +fn drop_3d_values(l: &mut Layer) { + let Some(tr) = l.transform_mut() else { return }; + for (m, keep) in [("position", 0.0), ("anchor", 0.0), ("scale", 100.0)] { + if let Some(pr) = tr.get_mut(m) { + let flat = |v: &mut KV| { + if let KV::Vec3(a) = v { + a[2] = keep; + } + }; + flat(&mut pr.value); + for k in &mut pr.keys { + flat(&mut k.value); + } + } + } + for (m, zero) in [("orientation", KV::Vec3([0.0; 3])), ("rotationX", KV::Scalar(0.0)), ("rotationY", KV::Scalar(0.0))] { + if let Some(pr) = tr.get_mut(m) { + pr.keys.clear(); + pr.expr = None; + pr.value = zero; + } + } +} + fn rename(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.rename")?; let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index 1746b3d1..48fca323 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -494,8 +494,52 @@ fn dolly(s: &mut Session, p: &Value) -> Result { camera_tool(s, p, ToolOp::Dolly(f_p(p, "amount").or_else(|| f_p(p, "dz")).unwrap_or(0.0)), "camera.dolly", "Dolly Camera") } +/// Layer ▸ Transform ▸ Auto-Orient: off | alongPath | towardsCamera (3D layers) | +/// towardsPointOfInterest (cameras and spot/parallel lights). +fn auto_orient(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = super::layers_p(s, p)?; + if ids.is_empty() { + return Err(bad("layer.autoOrient", "select a layer first")); + } + let raw = str_p(p, "mode").ok_or_else(|| bad("layer.autoOrient", "missing `mode` (off|alongPath|towardsCamera|towardsPointOfInterest)"))?; + let k = raw.to_ascii_lowercase().replace([' ', '-', '_'], ""); + let mode = match k.as_str() { + "off" => AutoOrient::Off, + "alongpath" | "orientalongpath" => AutoOrient::AlongPath, + "towardscamera" | "orienttowardscamera" => AutoOrient::TowardsCamera, + "towardspointofinterest" | "towardspoi" | "orienttowardspointofinterest" => AutoOrient::TowardsPointOfInterest, + _ => return Err(bad("layer.autoOrient", format!("unknown mode `{raw}`"))), + }; + s.edit("Auto-Orient", None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + let rig = l.is_camera() || l.is_light(); + let ok = match mode { + AutoOrient::Off | AutoOrient::AlongPath => true, + AutoOrient::TowardsCamera => !rig, + AutoOrient::TowardsPointOfInterest => rig && l.transform().is_some_and(|t| t.get("poi").is_some()), + }; + if !ok { + return Err(bad("layer.autoOrient", format!("`{raw}` is not available for layer `{}`", l.name))); + } + l.auto_orient = mode; + } + Ok(()) + })?; + Ok(Value::Null) +} + pub fn specs() -> Vec { vec![ + cmd!( + "layer.autoOrient", + "Auto-Orient…", + ["Layer", "Transform"], + Some("Cmd+Alt+O"), + "{mode: off|alongPath|towardsCamera|towardsPointOfInterest, layers?}", + super::has_layers, + auto_orient + ), cmd!( "layer.newLight", "Light…", diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 4bbddbcd..11d73641 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -12,6 +12,20 @@ fn session() -> Session { s } +#[test] +fn auto_orient_command() { + let mut s = session(); + let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.autoOrient", json!({"layer": id, "mode": "towardsCamera"})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers[0].auto_orient, AutoOrient::TowardsCamera); + s.undo(); + assert_eq!(s.active_comp().unwrap().layers[0].auto_orient, AutoOrient::Off); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("layer.autoOrient", json!({"layer": cam, "mode": "towardsCamera"})).is_err()); + s.execute("layer.autoOrient", json!({"layer": cam, "mode": "off"})).unwrap(); + assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0])); +} + fn layer_count(s: &Session) -> usize { s.active_comp().unwrap().layers.len() } @@ -102,6 +116,27 @@ fn three_d_switch_undo_redo() { assert!(!s.active_comp().unwrap().layers[0].is_3d()); s.redo(); assert!(s.active_comp().unwrap().layers[0].is_3d()); + // Turning 3D off discards Z and the 3D-only rotations. + s.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [10, 20, 300]})).unwrap(); + s.execute("prop.set", json!({"layer": id, "path": "transform/rotationX", "value": 45})).unwrap(); + s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": false})).unwrap(); + assert_eq!(prop(&s, id, "transform/position").as_vec3(), [10.0, 20.0, 0.0]); + assert_eq!(prop(&s, id, "transform/rotationX").as_f64(), 0.0); + s.undo(); + assert_eq!(prop(&s, id, "transform/position").as_vec3(), [10.0, 20.0, 300.0]); +} + +#[test] +fn draft_3d_turns_off_lighting() { + let mut s = session(); + let cid = s.active_comp_id().unwrap(); + let id = s.execute("layer.newSolid", json!({"width": 640, "height": 360, "color": [1, 1, 1]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.newLight", json!({"kind": "Point", "intensity": 50, "position": [320, 180, -400]})).unwrap(); + let px = |s: &Session| s.render(cid, s.time(), effectcraft_render::RenderOpts::default()).get(320, 180)[0]; + assert!(px(&s) < 0.5); + s.execute("comp.setSwitch", json!({"switch": "draft3d", "value": true})).unwrap(); + assert!((px(&s) - 1.0).abs() < 1e-5); } #[test] diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index abe95ce8..4e158dc5 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -74,7 +74,8 @@ impl<'a> EvalCtx<'a> { let pos = self.v3(layer, tr, "position", [0.0; 3]); let rz = self.f(layer, tr, "rotation", 0.0); if layer.is_camera() || layer.is_light() { - return Mat4::translate(Vec3::from(pos)) * Mat4::rotate_z(rz); + // Children of cameras/lights follow their position and full rotation. + return Mat4::translate(Vec3::from(pos)) * crate::three_d::camera::rig_rotation(self, layer); } let anchor = self.v3(layer, tr, "anchor", [0.0; 3]); let mut scale = self.v3(layer, tr, "scale", [100.0; 3]); @@ -82,12 +83,22 @@ impl<'a> EvalCtx<'a> { scale[2] = 100.0; let a = Vec3::from([anchor[0], anchor[1], 0.0]); let p = Vec3::from([pos[0], pos[1], 0.0]); + let rz = rz + crate::three_d::camera::auto_orient_2d(self, layer); return Mat4::layer_3d(a, p, Vec3::from(scale), Vec3::ZERO, vec3(0.0, 0.0, rz)); } - let o = self.v3(layer, tr, "orientation", [0.0; 3]); let rx = self.f(layer, tr, "rotationX", 0.0); let ry = self.f(layer, tr, "rotationY", 0.0); - Mat4::layer_3d(Vec3::from(anchor), Vec3::from(pos), Vec3::from(scale), Vec3::from(o), vec3(rx, ry, rz)) + let p = Vec3::from(pos); + // Auto-orient (along path / towards camera) replaces Orientation. + let orient = crate::three_d::camera::auto_orient_3d(self, layer, p) + .unwrap_or_else(|| Mat4::orientation(Vec3::from(self.v3(layer, tr, "orientation", [0.0; 3])))); + Mat4::translate(p) + * orient + * Mat4::rotate_z(rz) + * Mat4::rotate_y(ry) + * Mat4::rotate_x(rx) + * Mat4::scale(Vec3::from(scale) / 100.0) + * Mat4::translate(-Vec3::from(anchor)) } /// Layer space → comp (world) space, including parents. diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index de453642..95c45d98 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -181,24 +181,102 @@ pub fn parent_world(ctx: &EvalCtx, layer: &Layer) -> Mat4 { } } +/// Rotation (local → parent) of a frame whose +z is `fwd` and whose +y is as close to +/// `down_hint` as possible. +pub fn basis_rotation(fwd: Vec3, down_hint: Vec3) -> Mat4 { + let m = basis_view(Vec3::ZERO, fwd, down_hint).0; + Mat4([[m[0][0], m[1][0], m[2][0], 0.0], [m[0][1], m[1][1], m[2][1], 0.0], [m[0][2], m[1][2], m[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]]) +} + +/// Local rotation (layer → parent) of a camera or light: auto-orient towards the Point of +/// Interest (two-node), then Orientation and X/Y/Z Rotation. +pub fn rig_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 { + let rot = layer_rotation(ctx, layer); + match layer.transform().filter(|_| is_two_node(layer)) { + Some(tr) => { + let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64); + let pos = Vec3::from(ctx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0])); + let poi = Vec3::from(ctx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0])); + basis_rotation(poi - pos, vec3(0.0, 1.0, 0.0)) * rot + } + None => rot, + } +} + +// ---------------------------------------------------------------- auto-orient + +/// Direction of travel of a layer's position (parent space) at the context time. +fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option { + let tr = layer.transform()?; + let p = tr.get("position")?; + if p.keys.len() < 2 && !p.has_expression() { + return None; + } + let dt = effectcraft_time::Tick::from_seconds_f64(0.005); + let a = Vec3::from(ctx.at(ctx.time - dt).v3(layer, tr, "position", [0.0; 3])); + let b = Vec3::from(ctx.at(ctx.time + dt).v3(layer, tr, "position", [0.0; 3])); + let d = b - a; + (d.length() > 1e-9).then(|| d.normalize()) +} + +/// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees. +pub fn auto_orient_2d(ctx: &EvalCtx, layer: &Layer) -> f64 { + if layer.auto_orient != AutoOrient::AlongPath { + return 0.0; + } + path_tangent(ctx, layer).map(|t| t.y.atan2(t.x).to_degrees()).unwrap_or(0.0) +} + +/// Whether `id` is `layer` or one of its parents. +fn in_parent_chain(ctx: &EvalCtx, layer: &Layer, id: effectcraft_project::LayerId) -> bool { + let mut cur = Some(layer); + let mut guard = 0; + while let Some(l) = cur { + if l.id == id || guard > 64 { + return true; + } + guard += 1; + cur = l.parent.and_then(|p| ctx.comp.layer(p)); + } + false +} + +/// Auto-orient for 3D layers: the rotation that replaces Orientation (Along Path: the layer's x +/// axis follows the motion path; Towards Camera: the layer faces the active camera). +pub fn auto_orient_3d(ctx: &EvalCtx, layer: &Layer, pos: Vec3) -> Option { + match layer.auto_orient { + AutoOrient::AlongPath => { + let t = path_tangent(ctx, layer)?; + // x = tangent, y ≈ down, z = x × y. + let mut y = vec3(0.0, 1.0, 0.0) - t * t.y; + if y.length() < 1e-9 { + y = vec3(0.0, 0.0, 1.0) - t * t.z; + } + let y = y.normalize(); + let z = t.cross(y); + Some(Mat4([[t.x, y.x, z.x, 0.0], [t.y, y.y, z.y, 0.0], [t.z, y.z, z.z, 0.0], [0.0, 0.0, 0.0, 1.0]])) + } + AutoOrient::TowardsCamera => { + // A camera parented (through any chain) to this layer would recurse. + if ctx.comp.active_camera(ctx.time).is_some_and(|c| in_parent_chain(ctx, c, layer.id)) { + return None; + } + let eye = active_camera(ctx).eye; + let e = parent_world(ctx, layer).inverse().unwrap_or(Mat4::IDENTITY).apply(eye); + let d = pos - e; + (d.length() > 1e-9).then(|| basis_rotation(d, vec3(0.0, 1.0, 0.0))) + } + _ => None, + } +} + /// (eye, forward, down) in world space of a camera or light layer. pub fn layer_frame(ctx: &EvalCtx, layer: &Layer) -> (Vec3, Vec3, Vec3) { let (w, h) = (ctx.comp.width as f64, ctx.comp.height as f64); let Some(tr) = layer.transform() else { return (vec3(w / 2.0, h / 2.0, -1000.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0)) }; let parent = parent_world(ctx, layer); let pos = Vec3::from(ctx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0])); - let rot = layer_rotation(ctx, layer); - // Local frame: auto-orient (two-node) towards the POI, then the layer's own rotations. - let base = if is_two_node(layer) { - let poi = Vec3::from(ctx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0])); - let v = basis_view(pos, poi - pos, vec3(0.0, 1.0, 0.0)); - // Rotation part of the view, transposed = camera → parent. - let m = &v.0; - Mat4([[m[0][0], m[1][0], m[2][0], 0.0], [m[0][1], m[1][1], m[2][1], 0.0], [m[0][2], m[1][2], m[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]]) - } else { - Mat4::IDENTITY - }; - let local = base * rot; + let local = rig_rotation(ctx, layer); let eye = parent.apply(pos); let fwd = parent.apply_vec(local.apply_vec(vec3(0.0, 0.0, 1.0))).normalize(); let down = parent.apply_vec(local.apply_vec(vec3(0.0, 1.0, 0.0))).normalize(); diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 9b04d900..0d00d669 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -240,7 +240,7 @@ fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Opt solo.preserve_transparency = false; solo.blend_mode = BlendMode::Normal; if let Some(it) = prepare(r, ctx, &solo, 0, out, true) { - let lights = lights_at(ctx); + let lights = scene_lights(ctx); composite(&mut img, &[it], &lights, &[], m.id.0 as u32); } } else if let Some(mb) = r.layer_buf(ctx, m) { @@ -263,6 +263,11 @@ fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Opt ) } +/// Lights used for shading: none in Draft 3D (which also turns off shadows and depth of field). +fn scene_lights(ctx: &EvalCtx) -> Vec { + if ctx.comp.draft_3d { vec![] } else { lights_at(ctx) } +} + /// Draw a run of consecutive 3D layers (bottom-to-top order) into `canvas`. pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) { // 3D adjustment layers act on everything below them in the stack (like 2D ones): split. @@ -276,7 +281,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut return; } let out = (canvas.width, canvas.height); - let lights = lights_at(ctx); + let lights = scene_lights(ctx); let mut items: Vec = run .par_iter() .enumerate() diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index bf596d60..8bef7360 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -11,6 +11,8 @@ pub mod light; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_orient; pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; pub use light::{LightState, Material, lights_at}; diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs new file mode 100644 index 00000000..f5841d08 --- /dev/null +++ b/crates/render/src/three_d/tests_orient.rs @@ -0,0 +1,88 @@ +//! Auto-orient (along path / towards camera) and camera parenting. + +use effectcraft_color::Label; +use effectcraft_geom::vec3; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{AutoOrient, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use super::active_camera; +use crate::EvalCtx; + +fn setup() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +fn solid(p: &mut Project, comp: &Comp, three_d: bool, pos: [f64; 3]) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 10, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, (10, 10), None); + l.switches.three_d = three_d; + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos); + l +} + +#[test] +fn orient_towards_camera_faces_the_eye() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, true, [600.0, -200.0, 300.0]); + l.auto_orient = AutoOrient::TowardsCamera; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + let ctx = EvalCtx::new(&p, cid, c, Tick::ZERO); + let w = ctx.world_matrix(&c.layers[0]); + let n = w.apply_vec(vec3(0.0, 0.0, 1.0)).normalize(); + let eye = active_camera(&ctx).eye; + let to_layer = (w.apply(vec3(5.0, 5.0, 0.0)) - eye).normalize(); + assert!((n - to_layer).length() < 1e-6, "{n:?} vs {to_layer:?}"); +} + +#[test] +fn orient_along_path_2d_follows_motion() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, false, [50.0, 10.0, 0.0]); + l.auto_orient = AutoOrient::AlongPath; + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([50.0, 10.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([50.0, 90.0, 0.0]))]; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + let ctx = EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(0.5)); + let x_axis = ctx.world_matrix(&c.layers[0]).apply_vec(vec3(1.0, 0.0, 0.0)); + // Moving straight down (+y): the layer's x axis points down the screen. + assert!(x_axis.x.abs() < 1e-6 && (x_axis.y - 1.0).abs() < 1e-6, "{x_axis:?}"); +} + +#[test] +fn layers_parented_to_a_camera_follow_its_rotation() { + let (mut p, cid, comp) = setup(); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + cam.auto_orient = AutoOrient::Off; + cam.props.prop_mut("transform/position").unwrap().value = Value::Vec3([0.0, 0.0, 0.0]); + cam.props.prop_mut("transform/orientation").unwrap().value = Value::Vec3([0.0, 90.0, 0.0]); + let mut child = solid(&mut p, &comp, true, [0.0, 0.0, 100.0]); + child.parent = Some(cam.id); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(cam); + c.layers.push(child); + let c = p.comp(cid).unwrap(); + let ctx = EvalCtx::new(&p, cid, c, Tick::ZERO); + // 100 px in front of a camera turned to +x is at x = 100 (anchor at the solid's centre). + let at = ctx.world_matrix(&c.layers[1]).apply(vec3(5.0, 5.0, 0.0)); + assert!((at - vec3(100.0, 0.0, 0.0)).length() < 1e-6, "{at:?}"); +} + +#[test] +fn camera_parented_to_towards_camera_layer_does_not_recurse() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, true, [100.0, 50.0, 0.0]); + l.auto_orient = AutoOrient::TowardsCamera; + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + cam.parent = Some(l.id); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(cam); + c.layers.push(l); + let _ = crate::render_frame(&p, cid, Tick::ZERO, 0.5); +}