M7.3: Auto-Orient (along path, towards camera), Draft 3D, 3D switch off drops Z

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 22:42:27 -04:00
parent 16a5fa7a83
commit 37c07ba5b1
8 changed files with 311 additions and 18 deletions

View File

@ -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<Value> {
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();

View File

@ -494,8 +494,52 @@ fn dolly(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<CommandSpec> {
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…",

View File

@ -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]

View File

@ -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.

View File

@ -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<Vec3> {
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<Mat4> {
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();

View File

@ -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<LightState> {
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<Item> = run
.par_iter()
.enumerate()

View File

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

View File

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