742 lines
33 KiB
Rust
742 lines
33 KiB
Rust
//! 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<KV> {
|
|
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<bool> {
|
|
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<Option<f64>> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<LightKind> {
|
|
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<Value> {
|
|
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|Environment"))?
|
|
}
|
|
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<Value> {
|
|
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|Environment"))?,
|
|
),
|
|
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<View3D> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> = 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::<Vec<_>>(),
|
|
"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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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<Vec3> {
|
|
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<Value> {
|
|
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<LayerId> = 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<Value> {
|
|
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<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...",
|
|
["Layer", "New"],
|
|
Some("Cmd+Alt+Shift+L"),
|
|
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), 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?: Parallel|Spot|Point|Ambient|Environment, 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),
|
|
]
|
|
}
|