//! 3D: cameras (Layer ▸ New ▸ Camera, Camera Settings), lights (Layer ▸ New ▸ Light, Light //! Settings), the viewer's 3D views (View ▸ Switch 3D View, Reset 3D View) and the camera tools //! (Orbit, Pan, Dolly) acting on the current 3D view. use effectcraft_geom::{Mat4, Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::build; use effectcraft_project::{AutoOrient, Comp, ItemId, Layer, LayerId, LayerSource, LightKind}; use effectcraft_render::EvalCtx; use effectcraft_render::three_d::camera::{self, PRESETS, Rig, View3D, ViewCam, Views3D, orientation_for, zoom_for_focal}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::{color_p, insert_layer}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p}; use crate::{EngineError, Result, Session, cmd, query}; fn v3_p(p: &Value, k: &str) -> Option<[f64; 3]> { let a = p.get(k)?.as_array()?; let g = |i: usize| a.get(i).and_then(Value::as_f64); Some([g(0)?, g(1)?, g(2).unwrap_or(0.0)]) } /// Set a property at comp time `t` (adds a key when it is animated). fn set_at(l: &mut Layer, path: &str, t: Tick, v: KV) { let lt = l.layer_time(t); if let Some(pr) = l.props.prop_mut(path) { pr.set_value_at(lt, v); } } fn get_at(l: &Layer, path: &str, t: Tick) -> Option { l.props.prop(path).map(|pr| pr.value_at(l.layer_time(t))) } // ---------------------------------------------------------------- cameras /// Parse a camera type: `oneNode`/`twoNode` (or "One-Node Camera"…). Some(true) = two-node. fn two_node_p(p: &Value) -> Option { let t = str_p(p, "type")?.to_ascii_lowercase().replace(['-', ' ', '_'], ""); if t.starts_with("one") { Some(false) } else if t.starts_with("two") { Some(true) } else { None } } /// Zoom from `zoom`, `preset` ("50mm"), `focalLength` (mm) or `angleOfView` (degrees). fn zoom_p(p: &Value, comp_w: f64) -> Result> { if let Some(z) = f_p(p, "zoom") { return Ok(Some(z.max(0.1))); } if let Some(pr) = p.get("preset") { let focal = match pr { Value::Number(n) => n.as_f64(), Value::String(s) => { let k = s.trim().to_ascii_lowercase(); PRESETS.iter().find(|(n, _)| n.eq_ignore_ascii_case(&k)).map(|(_, f)| *f).or_else(|| k.trim_end_matches("mm").trim().parse().ok()) } _ => None, } .ok_or_else(|| bad("layer.newCamera", format!("unknown preset {pr}; use one of 15mm, 20mm, 24mm, 28mm, 35mm, 50mm, 80mm, 135mm, 200mm")))?; return Ok(Some(zoom_for_focal(comp_w, focal))); } if let Some(f) = f_p(p, "focalLength") { return Ok(Some(zoom_for_focal(comp_w, f))); } if let Some(a) = f_p(p, "angleOfView") { return Ok(Some(comp_w * 0.5 / (a.clamp(0.1, 179.0).to_radians() * 0.5).tan())); } Ok(None) } /// Apply Camera Settings parameters to a camera layer at comp time `t`. fn apply_camera(l: &mut Layer, comp: &Comp, p: &Value, t: Tick, cmd: &str) -> Result<()> { let w = comp.width as f64; if let Some(z) = zoom_p(p, w)? { set_at(l, "cameraOptions/zoom", t, KV::Scalar(z)); // "Lock to Zoom" (default): the focus distance follows the zoom unless one is given. if b_p(p, "lockToZoom").unwrap_or(true) && f_p(p, "focusDistance").is_none() { set_at(l, "cameraOptions/focusDistance", t, KV::Scalar(z)); } } if let Some(v) = b_p(p, "dof").or_else(|| b_p(p, "depthOfField")) { set_at(l, "cameraOptions/dof", t, KV::Bool(v)); } for (k, path) in [("focusDistance", "cameraOptions/focusDistance"), ("aperture", "cameraOptions/aperture"), ("blurLevel", "cameraOptions/blurLevel")] { if let Some(v) = f_p(p, k) { set_at(l, path, t, KV::Scalar(v.max(0.0))); } } if let Some(fs) = f_p(p, "fStop") { // F-Stop = focal length / aperture, apertures in mm scaled like the presets (25.3 mm at 1920 px). let z = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(1000.0); let focal = camera::focal_for_zoom(w, z); set_at(l, "cameraOptions/aperture", t, KV::Scalar(focal / fs.max(0.01) * w / 1920.0)); } if let Some(pos) = v3_p(p, "position") { set_at(l, "transform/position", t, KV::Vec3(pos)); } if let Some(poi) = v3_p(p, "poi").or_else(|| v3_p(p, "pointOfInterest")) { set_at(l, "transform/poi", t, KV::Vec3(poi)); } if let Some(two) = two_node_p(p) { let was = l.auto_orient == AutoOrient::TowardsPointOfInterest; if two != was { let pos = Vec3::from(get_at(l, "transform/position", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3])); let zoom = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(1000.0); if two { // Keep the view: put the POI where the camera looks, clear the orientation. let o = get_at(l, "transform/orientation", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3]); let fwd = Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0)); let poi = pos + fwd * zoom; set_at(l, "transform/poi", t, KV::Vec3([poi.x, poi.y, poi.z])); set_at(l, "transform/orientation", t, KV::Vec3([0.0; 3])); l.auto_orient = AutoOrient::TowardsPointOfInterest; } else { let poi = Vec3::from(get_at(l, "transform/poi", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3])); set_at(l, "transform/orientation", t, KV::Vec3(orientation_for(poi - pos))); l.auto_orient = AutoOrient::Off; } } } else if p.get("type").is_some() { return Err(bad(cmd, "type: oneNode|twoNode")); } Ok(()) } fn new_camera(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").unwrap_or("Camera 1").to_string(); let t = time_p(s, p, Some(&comp)); let id = s.edit("New Camera", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Camera, (comp.width, comp.height), None); apply_camera(&mut l, &comp, p, t, "layer.newCamera")?; insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) } /// The camera or light layer a settings command targets. fn target(s: &Session, p: &Value, cmd: &str, want_camera: bool) -> Result<(ItemId, LayerId)> { let (cid, lid) = layer_p(s, p, cmd)?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; let ok = if want_camera { l.is_camera() } else { l.is_light() }; if !ok { return Err(bad(cmd, format!("layer `{}` is not a {}", l.name, if want_camera { "camera" } else { "light" }))); } Ok((cid, lid)) } fn camera_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = target(s, p, "layer.cameraSettings", true)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = time_p(s, p, Some(&comp)); let name = str_p(p, "name").map(str::to_string); s.edit("Camera Settings", None, |proj, _| { let l = layer_mut(proj, cid, lid)?; if let Some(n) = &name { l.name = n.clone(); } apply_camera(l, &comp, p, t, "layer.cameraSettings") })?; Ok(Value::Null) } // ---------------------------------------------------------------- lights fn light_kind_p(p: &Value, k: &str) -> Option { let v = str_p(p, k)?; LightKind::ALL.into_iter().find(|l| l.label().eq_ignore_ascii_case(v.trim())) } fn apply_light(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<()> { if let Some(c) = color_p(p, "color") { set_at(l, "lightOptions/color", t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])); } for k in ["intensity", "coneAngle", "coneFeather", "radius", "falloffDistance", "shadowDarkness", "shadowDiffusion"] { if let Some(v) = f_p(p, k) { set_at(l, &format!("lightOptions/{k}"), t, KV::Scalar(v)); } } if let Some(v) = b_p(p, "castsShadows") { set_at(l, "lightOptions/castsShadows", t, KV::Bool(v)); } if let Some(f) = p.get("falloff") { let i = match f { Value::Number(n) => n.as_u64().map(|v| v as u32), Value::String(s) => { let k = s.to_ascii_lowercase(); if k.starts_with("none") { Some(0) } else if k.starts_with("smooth") { Some(1) } else if k.starts_with("inverse") { Some(2) } else { None } } _ => None, } .filter(|i| *i <= 2) .ok_or_else(|| bad(cmd, "falloff: None|Smooth|Inverse Square Clamped"))?; set_at(l, "lightOptions/falloff", t, KV::Enum(i)); } if let Some(pos) = v3_p(p, "position") { set_at(l, "transform/position", t, KV::Vec3(pos)); } if let Some(poi) = v3_p(p, "poi").or_else(|| v3_p(p, "pointOfInterest")) { set_at(l, "transform/poi", t, KV::Vec3(poi)); } Ok(()) } fn new_light(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let kind = match p.get("kind").or_else(|| p.get("type")) { Some(_) => light_kind_p(p, "kind").or_else(|| light_kind_p(p, "type")).ok_or_else(|| bad("layer.newLight", "kind: Parallel|Spot|Point|Ambient"))?, None => LightKind::Spot, }; let name = str_p(p, "name").unwrap_or("Light 1").to_string(); let t = time_p(s, p, Some(&comp)); let id = s.edit("New Light", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None); apply_light(&mut l, p, t, "layer.newLight")?; insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) } fn light_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = target(s, p, "layer.lightSettings", false)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = time_p(s, p, Some(&comp)); let name = str_p(p, "name").map(str::to_string); let kind = match p.get("kind").or_else(|| p.get("type")) { Some(_) => { Some(light_kind_p(p, "kind").or_else(|| light_kind_p(p, "type")).ok_or_else(|| bad("layer.lightSettings", "kind: Parallel|Spot|Point|Ambient"))?) } None => None, }; s.edit("Light Settings", None, |proj, _| { let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut l = old.clone(); if let Some(k) = kind && old.source != (LayerSource::Light { kind: k }) { // Light Type change: rebuild the light's groups, carrying over shared values. let fresh = build::layer(proj, &comp, &old.name, LayerSource::Light { kind: k }, (comp.width, comp.height), None); l.source = fresh.source.clone(); l.auto_orient = fresh.auto_orient; l.props = fresh.props; for path in [ "transform/position", "transform/poi", "transform/orientation", "transform/rotationX", "transform/rotationY", "transform/rotation", "lightOptions/intensity", "lightOptions/color", "lightOptions/coneAngle", "lightOptions/coneFeather", "lightOptions/falloff", "lightOptions/radius", "lightOptions/falloffDistance", "lightOptions/castsShadows", "lightOptions/shadowDarkness", "lightOptions/shadowDiffusion", ] { if let (Some(src), Some(dst)) = (old.props.prop(path), l.props.prop_mut(path)) { dst.value = src.value.clone(); dst.keys = src.keys.clone(); dst.expr = src.expr.clone(); } } } if let Some(n) = &name { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; *layer_mut(proj, cid, lid)? = l; Ok(()) })?; Ok(Value::Null) } // ---------------------------------------------------------------- 3D views fn views(s: &mut Session, cid: ItemId) -> &mut Views3D { s.state.views3d.entry(cid).or_default() } fn view_p(p: &Value, cmd: &str) -> Result { let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?; View3D::from_id(v).ok_or_else(|| bad(cmd, format!("unknown view `{v}`"))) } fn switch_view(s: &mut Session, cid: ItemId, v: View3D) -> Value { let vs = views(s, cid); if vs.current != v { vs.last = vs.current; vs.current = v; } json!({"view": v.id()}) } fn set_3d_view(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let v = view_p(p, "view.set3DView")?; Ok(switch_view(s, cid, v)) } macro_rules! view_cmd { ($f:ident, $v:expr) => { fn $f(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; Ok(switch_view(s, cid, $v)) } }; } view_cmd!(view_active, View3D::ActiveCamera); view_cmd!(view_front, View3D::Front); view_cmd!(view_left, View3D::Left); view_cmd!(view_top, View3D::Top); view_cmd!(view_back, View3D::Back); view_cmd!(view_right, View3D::Right); view_cmd!(view_bottom, View3D::Bottom); view_cmd!(view_custom1, View3D::Custom1); view_cmd!(view_custom2, View3D::Custom2); view_cmd!(view_custom3, View3D::Custom3); view_cmd!(view_default, View3D::Default); fn last_view(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let last = views(s, cid).last; Ok(switch_view(s, cid, last)) } fn reset_view(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let cur = views(s, cid).current; if cur != View3D::ActiveCamera { views(s, cid).cams.remove(&cur); return Ok(json!({"view": cur.id()})); } // Active camera: back to the default framing (50 mm-style distance, looking at the centre). let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); let cam = comp.active_camera(t).map(|l| l.id).ok_or_else(|| bad("view.reset3DView", "the comp has no camera layer"))?; let (w, h) = (comp.width as f64, comp.height as f64); s.edit("Reset 3D View", None, |proj, _| { let l = layer_mut(proj, cid, cam)?; let zoom = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(effectcraft_geom::default_camera_zoom(w)); set_at(l, "transform/position", t, KV::Vec3([w / 2.0, h / 2.0, -zoom])); set_at(l, "transform/poi", t, KV::Vec3([w / 2.0, h / 2.0, 0.0])); set_at(l, "transform/orientation", t, KV::Vec3([0.0; 3])); Ok(()) })?; Ok(json!({"view": "activeCamera"})) } fn set_view_camera(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let v = view_p(p, "view.set3DViewCamera")?; if v == View3D::ActiveCamera { return Err(bad("view.set3DViewCamera", "edit the camera layer for the active camera view")); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let (w, h) = (comp.width as f64, comp.height as f64); let vs = views(s, cid); let mut c = vs.cam(v, w, h); if let Some(e) = v3_p(p, "eye") { c.eye = e; } if let Some(e) = v3_p(p, "poi") { c.poi = e; } if let Some(z) = f_p(p, "zoom") { c.zoom = z.max(1e-3); } vs.cams.insert(v, c); Ok(json!({"view": v.id()})) } fn get_3d(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp .layers .iter() .filter_map(|l| match l.source { LayerSource::Light { kind } => Some(json!({"id": l.id.0, "name": l.name, "kind": kind.label()})), _ => None, }) .collect(); let active = comp.active_camera(t).map(|l| json!({"id": l.id.0, "name": l.name, "twoNode": camera::is_two_node(l)})); Ok(json!({ "view": vs.current.id(), "label": vs.current.label(), "views": View3D::ALL.iter().map(|v| v.id()).collect::>(), "camera": {"eye": [cam.eye.x, cam.eye.y, cam.eye.z], "forward": [f.x, f.y, f.z], "zoom": cam.zoom, "ortho": cam.ortho}, "activeCameraLayer": active, "lights": lights, })) } // ---------------------------------------------------------------- camera tools enum ToolOp { Orbit(f64, f64), Pan(f64, f64), Dolly(f64), } fn camera_tool(s: &mut Session, p: &Value, op: ToolOp, cmd: &str, label: &str) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let (w, h) = (comp.width as f64, comp.height as f64); let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); let apply = |r: &mut Rig, layer: bool| match op { ToolOp::Orbit(yaw, pitch) => r.orbit(yaw, pitch), ToolOp::Pan(dx, dy) => r.pan(dx, dy), ToolOp::Dolly(a) => r.dolly(a, layer), }; if cur != View3D::ActiveCamera { let vs = views(s, cid); let vc = vs.cam(cur, w, h); let mut r = Rig::from_view(&vc); apply(&mut r, false); let nc = ViewCam { eye: camera::arr(r.eye), poi: camera::arr(r.poi), zoom: r.zoom, ..vc }; vs.cams.insert(cur, nc); return Ok(json!({"view": cur.id(), "eye": nc.eye, "poi": nc.poi, "zoom": nc.zoom})); } let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); let cam = match p.get("layer") { Some(v) => super::resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(|l| l.is_camera())), None => comp.active_camera(t).map(|l| l.id), } .ok_or_else(|| bad(cmd, "the comp has no active camera layer (Layer ▸ New ▸ Camera), or switch to a custom 3D view"))?; let merge = merge_p(p).map(str::to_string); let r = s.edit(label, merge.as_deref(), |proj, _| { let l = layer_mut(proj, cid, cam)?; let pos = Vec3::from(get_at(l, "transform/position", t).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, -1000.0])); let zoom = get_at(l, "cameraOptions/zoom", t).map(|v| v.as_f64()).unwrap_or(1000.0); let two = camera::is_two_node(l); let o = get_at(l, "transform/orientation", t).map(|v| v.as_vec3()).unwrap_or([0.0; 3]); let rx = get_at(l, "transform/rotationX", t).map(|v| v.as_f64()).unwrap_or(0.0); let ry = get_at(l, "transform/rotationY", t).map(|v| v.as_f64()).unwrap_or(0.0); let poi = if two { Vec3::from(get_at(l, "transform/poi", t).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0])) } else { let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).apply_vec(vec3(0.0, 0.0, 1.0)); pos + fwd * zoom }; let mut rig = Rig { eye: pos, poi, zoom, ortho: false }; apply(&mut rig, true); set_at(l, "transform/position", t, KV::Vec3(camera::arr(rig.eye))); if two { set_at(l, "transform/poi", t, KV::Vec3(camera::arr(rig.poi))); } else { set_at(l, "transform/orientation", t, KV::Vec3(orientation_for(rig.poi - rig.eye))); set_at(l, "transform/rotationX", t, KV::Scalar(0.0)); set_at(l, "transform/rotationY", t, KV::Scalar(0.0)); } Ok(json!({"layer": cam.0, "position": camera::arr(rig.eye), "poi": camera::arr(rig.poi)})) })?; Ok(r) } fn orbit(s: &mut Session, p: &Value) -> Result { camera_tool( s, p, ToolOp::Orbit(f_p(p, "yaw").or_else(|| f_p(p, "dx")).unwrap_or(0.0), f_p(p, "pitch").or_else(|| f_p(p, "dy")).unwrap_or(0.0)), "camera.orbit", "Orbit Camera", ) } fn pan(s: &mut Session, p: &Value) -> Result { camera_tool(s, p, ToolOp::Pan(f_p(p, "dx").unwrap_or(0.0), f_p(p, "dy").unwrap_or(0.0)), "camera.pan", "Pan Camera") } 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) } // ---------------------------------------------------------------- look at / camera from view /// World-space points spanning the layers' content at comp time `t` (content-box corners /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); match effectcraft_render::content_bounds(&ctx, l) { Some([x0, y0, x1, y1]) => { for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] { out.push(m.apply(vec3(x, y, 0.0))); } } None => out.push(m.apply(Vec3::ZERO)), } } out } fn look_at(s: &mut Session, p: &Value, all: bool) -> Result { let cmd = if all { "view.lookAtAll" } else { "view.lookAtSelected" }; let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); let ids: Vec = if all { comp.layers.iter().filter(|l| l.is_active_at(t) && !l.is_camera() && !l.is_light()).map(|l| l.id).collect() } else { let (_, sel) = super::layers_p(s, p)?; sel }; if ids.is_empty() { return Err(bad(cmd, if all { "the composition has no visible layers" } else { "select layers first" })); } let pts = layer_points(s, cid, &ids, t); let (mut lo, mut hi) = (vec3(f64::MAX, f64::MAX, f64::MAX), vec3(f64::MIN, f64::MIN, f64::MIN)); for q in &pts { lo = vec3(lo.x.min(q.x), lo.y.min(q.y), lo.z.min(q.z)); hi = vec3(hi.x.max(q.x), hi.y.max(q.y), hi.z.max(q.z)); } let c = (lo + hi) * 0.5; let r = ((hi - lo).length() * 0.5).max(1.0); let (w, h) = (comp.width as f64, comp.height as f64); let fit = w.min(h); let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); if !comp.has_3d() { // 2D: frame the layers in the viewer (zoom and pan to their comp-space bounds). let rect = [lo.x, lo.y, hi.x, hi.y]; super::frontend(s, "view.lookAt", &json!({"rect": rect}))?; return Ok(json!({"rect": rect})); } if cur != View3D::ActiveCamera { let vs = views(s, cid); let mut vc = vs.cam(cur, w, h); let fwd = (Vec3::from(vc.poi) - Vec3::from(vc.eye)).normalize(); if vc.ortho { vc.zoom = fit / (2.2 * r); vc.eye = camera::arr(c - fwd * 10_000.0); } else { vc.eye = camera::arr(c - fwd * (vc.zoom * r * 2.2 / fit)); } vc.poi = camera::arr(c); vs.cams.insert(cur, vc); return Ok(json!({"view": cur.id(), "eye": vc.eye, "poi": vc.poi, "zoom": vc.zoom})); } let cam = comp.active_camera(t).map(|l| l.id).ok_or_else(|| bad(cmd, "the comp has no camera layer: switch to a 3D view (View ▸ Switch 3D View)"))?; s.edit(if all { "Look at All Layers" } else { "Look at Selected Layers" }, None, |proj, _| { let l = layer_mut(proj, cid, cam)?; let lt = l.layer_time(t); let pos = Vec3::from(get_at(l, "transform/position", lt).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, -1000.0])); let zoom = get_at(l, "cameraOptions/zoom", lt).map(|v| v.as_f64()).unwrap_or(1000.0); let two = camera::is_two_node(l); let fwd = if two { let poi = Vec3::from(get_at(l, "transform/poi", lt).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0])); (poi - pos).normalize() } else { let o = get_at(l, "transform/orientation", lt).map(|v| v.as_vec3()).unwrap_or([0.0; 3]); Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0)).normalize() }; let eye = c - fwd * (zoom * r * 2.2 / fit); set_at(l, "transform/position", lt, KV::Vec3(camera::arr(eye))); if two { set_at(l, "transform/poi", lt, KV::Vec3(camera::arr(c))); } Ok(json!({"layer": cam.0, "position": camera::arr(eye), "poi": camera::arr(c)})) }) } /// Layer ▸ Camera ▸ Create Camera from 3D View: a two-node camera matching the current 3D view. fn camera_from_view(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let (w, h) = (comp.width as f64, comp.height as f64); let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); if cur == View3D::ActiveCamera { return Err(bad("camera.fromView", "switch to a 3D view first (View ▸ Switch 3D View)")); } let vc = s.state.views3d.get(&cid).cloned().unwrap_or_default().cam(cur, w, h); let poi = Vec3::from(vc.poi); let fwd = (poi - Vec3::from(vc.eye)).normalize(); let zoom = if vc.ortho { effectcraft_geom::default_camera_zoom(w) } else { vc.zoom }; let eye = if vc.ortho { poi - fwd * zoom } else { Vec3::from(vc.eye) }; let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); let name = comp.unique_layer_name("Camera 1"); let params = json!({"position": camera::arr(eye), "poi": camera::arr(poi), "zoom": zoom}); let id = s.edit("Create Camera from 3D View", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Camera, (comp.width, comp.height), None); apply_camera(&mut l, &comp, ¶ms, t, "camera.fromView")?; insert_layer(proj, st, cid, l) })?; switch_view(s, cid, View3D::ActiveCamera); Ok(json!({"layer": id.0, "from": cur.id()})) } 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...", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), "{kind?: Parallel|Spot|Point|Ambient (default Spot), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", has_comp, new_light ), cmd!( "layer.newCamera", "Camera...", ["Layer", "New"], Some("Cmd+Alt+Shift+C"), "{name?, type?: oneNode|twoNode (default twoNode), preset?: 15mm|20mm|24mm|28mm|35mm|50mm|80mm|135mm|200mm, zoom?, focalLength? mm, angleOfView? deg, dof?, focusDistance?, aperture?, fStop?, blurLevel?, lockToZoom?, position? [x,y,z], poi? [x,y,z]}", has_comp, new_camera ), cmd!( "layer.cameraSettings", "Camera Settings...", ["Layer", "Camera"], None, "{layer?, name?, type?, preset?, zoom?, focalLength?, angleOfView?, dof?, focusDistance?, aperture?, fStop?, blurLevel?, position?, poi?}", has_comp, camera_settings ), cmd!( "layer.lightSettings", "Light Settings...", [], None, "{layer?, name?, kind?, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", has_comp, light_settings ), cmd!( "view.set3DView", "Switch 3D View", [], None, "{view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}", has_comp, set_3d_view ), cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active), cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default), cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front), cmd!("view.3d.left", "Left", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_left), cmd!("view.3d.top", "Top", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_top), cmd!("view.3d.back", "Back", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_back), cmd!("view.3d.right", "Right", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_right), cmd!("view.3d.bottom", "Bottom", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_bottom), cmd!("view.3d.custom1", "Custom View 1", ["View", "Switch 3D View"], Some("F11"), "{comp?}", has_comp, view_custom1), cmd!("view.3d.custom2", "Custom View 2", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom2), cmd!("view.3d.custom3", "Custom View 3", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_custom3), cmd!("view.3d.last", "Switch to Last 3D View", ["View"], None, "{comp?}", has_comp, last_view), cmd!("view.lookAtSelected", "Look at Selected Layers", ["View"], Some("Cmd+Alt+Shift+\\"), "{comp?, layers?}", super::has_layers, |s, p| look_at( s, p, false )), cmd!("view.lookAtAll", "Look at All Layers", ["View"], None, "{comp?}", has_comp, |s, p| look_at(s, p, true)), cmd!("camera.fromView", "Create Camera from 3D View", ["Layer", "Camera"], None, "{comp?}", has_comp, camera_from_view), cmd!("view.reset3DView", "Reset 3D View", ["View"], None, "{comp?}", has_comp, reset_view), cmd!("view.set3DViewCamera", "Set 3D View Camera", [], None, "{view, eye? [x,y,z], poi? [x,y,z], zoom?}", has_comp, set_view_camera), cmd!( "camera.orbit", "Orbit Camera", [], None, "{yaw|dx deg, pitch|dy deg, layer?, merge?} — current 3D view (camera layer in Active Camera)", has_comp, orbit ), cmd!("camera.pan", "Pan Camera", [], None, "{dx, dy (comp px), layer?, merge?}", has_comp, pan), cmd!("camera.dolly", "Dolly Camera", [], None, "{amount (px, + = forward), layer?, merge?}", has_comp, dolly), query!("view.get3D", "3D View State", "{comp?} → current 3D view, view camera, active camera layer, lights", get_3d), ] }