effectcraft/crates/engine/src/commands/rig3d.rs

615 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Camera and light rigs: Layer ▸ Camera ▸ Create Stereo 3D Rig / Create Orbit Null / Create
//! Cameras from 3D Model, Layer ▸ Light ▸ Create Lights from 3D Model / Control Light with
//! Camera / Create Environment Light Background Layer.
use effectcraft_color::Label;
use effectcraft_effects::controls::{STEREO_CONFIGURATIONS, STEREO_CONTROLS, STEREO_CONVERGENCE};
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{AutoOrient, Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::adv::scene::model_to_layer;
use effectcraft_render::three_d::camera::{self, layer_frame};
use serde_json::{Value, json};
use super::layer::insert_layer;
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, resolve_layer};
use crate::{EngineError, Result, Session, cmd};
fn arr(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
/// Set a property's static value (clears its keyframes and expression).
fn set(l: &mut Layer, path: &str, v: KV) {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = v;
pr.keys.clear();
pr.expr = None;
}
}
fn set_expr(l: &mut Layer, path: &str, text: String) {
if let Some(pr) = l.props.prop_mut(path) {
pr.expr = Some(Expression { text, enabled: true });
}
}
/// What evaluation needs from the session (cloned so edits can evaluate their own project).
struct Env {
time: effectcraft_time::Tick,
expr: Option<std::sync::Arc<dyn effectcraft_render::ExprHost>>,
footage: std::sync::Arc<dyn effectcraft_render::FootageSource>,
}
impl Env {
fn of(s: &Session, cid: ItemId) -> Env {
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
}
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
}
}
/// The camera a rig command acts on: `layer`, else a selected camera, else the active camera.
fn camera_p(s: &Session, p: &Value, cid: ItemId, cmd: &str) -> Result<LayerId> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let id = match p.get("layer").or_else(|| p.get("camera")) {
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, "no such layer"))?,
None => s
.state
.selected_layers
.iter()
.copied()
.find(|id| comp.layer(*id).is_some_and(Layer::is_camera))
.or_else(|| comp.active_camera(s.time_of(cid)).map(|l| l.id))
.ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
if !comp.layer(id).is_some_and(Layer::is_camera) {
return Err(bad(cmd, "the layer is not a camera"));
}
Ok(id)
}
/// Distance from a camera to what it looks at: its point of interest (two-node), else its zoom.
fn focus_distance(ctx: &EvalCtx, cam: &Layer) -> f64 {
let st = camera::layer_camera(ctx, cam);
if camera::is_two_node(cam)
&& let Some(tr) = cam.transform()
{
let poi = Vec3::from(ctx.v3(cam, tr, "poi", [0.0; 3]));
let poi = camera::parent_world(ctx, cam).apply(poi);
return (poi - st.eye).length().max(1.0);
}
st.zoom
}
// ---------------------------------------------------------------- stereo 3D rig
/// Stereo rig settings (Stereo 3D Controls).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StereoParams {
/// 0 Stereo Pair (Left & Right), 1 Center & Right, 2 Center & Left.
pub configuration: u32,
/// Interaxial separation, % of the comp width.
pub scene_depth: f64,
pub convergence: bool,
/// 0 the camera's point of interest, 1 its zoom plane.
pub convergence_of: u32,
pub z_offset: f64,
}
/// Eye offsets along the camera's x axis, in units of the interaxial separation.
pub fn eye_offsets(configuration: u32) -> [f64; 2] {
match configuration {
1 => [0.0, 1.0],
2 => [-1.0, 0.0],
_ => [-0.5, 0.5],
}
}
/// (position, point of interest) of the left (`eye` 0) or right (1) camera, from the master
/// camera's eye, axes, focus distance and zoom.
pub fn eye_camera(sp: &StereoParams, eye: usize, comp_w: f64, master: (Vec3, Vec3, Vec3), focus: f64, zoom: f64) -> (Vec3, Vec3) {
let (e, fwd, right) = master;
let d = sp.scene_depth / 100.0 * comp_w;
let pos = e + right * (eye_offsets(sp.configuration)[eye.min(1)] * d);
let poi = if sp.convergence {
let dc = if sp.convergence_of == 1 { zoom } else { focus } + sp.z_offset;
e + fwd * dc.max(1.0)
} else {
pos + fwd * focus
};
(pos, poi)
}
/// Expression snippets that rebuild [`eye_camera`] from the master camera and the controls.
fn eye_exprs(src_comp: &str, master: &str, controls: &str, two_node: bool, eye: usize) -> (String, String, String) {
let q = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into());
let k = eye_offsets(0)[eye.min(1)];
let k1 = eye_offsets(1)[eye.min(1)];
let k2 = eye_offsets(2)[eye.min(1)];
let head = format!(
"var src = comp({c});\nvar m = src.layer({m});\nvar fx = src.layer({x}).effect(\"Stereo 3D Controls\");\n\
var d = fx(\"Stereo Scene Depth\").value / 100 * src.width;\nvar cfg = fx(\"Configuration\").value;\n\
var k = cfg == 2 ? {k1} : (cfg == 3 ? {k2} : {k});\n\
var E = m.toWorld([0, 0, 0]);\nvar F = normalize(m.toWorldVec([0, 0, 1]));\n\
var eye = add(E, mul(normalize(m.toWorldVec([1, 0, 0])), k * d));\n",
c = q(src_comp),
m = q(master),
x = q(controls),
);
let dm = if two_node { "Math.max(length(sub(m.transform.pointOfInterest, m.transform.position)), 1)" } else { "m.cameraOption.zoom" };
let pos = format!("{head}eye;");
let poi = format!(
"{head}var dm = {dm};\nvar dc = (fx(\"Convergence Of\").value == 2 ? m.cameraOption.zoom : dm) + fx(\"Convergence Z Offset\").value;\n\
fx(\"Enable Convergence\").value ? add(E, mul(F, Math.max(dc, 1))) : add(eye, mul(F, dm));"
);
let zoom = format!("comp({}).layer({}).cameraOption.zoom;", q(src_comp), q(master));
(pos, poi, zoom)
}
fn stereo_p(p: &Value, cmd: &str) -> Result<StereoParams> {
let pick = |k: &str, opts: &[&str], ids: &[&str]| -> Result<Option<u32>> {
match p.get(k) {
None => Ok(None),
Some(Value::Number(n)) => Ok(n.as_u64().map(|v| (v as u32).min(opts.len() as u32 - 1))),
Some(Value::String(s)) => {
let n = s.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "");
let i = ids
.iter()
.position(|v| v.to_ascii_lowercase() == n)
.or_else(|| opts.iter().position(|o| o.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "") == n));
i.map(|i| Some(i as u32)).ok_or_else(|| bad(cmd, format!("{k}: {}", ids.join("|"))))
}
_ => Err(bad(cmd, format!("{k}: {}", ids.join("|")))),
}
};
Ok(StereoParams {
configuration: pick("configuration", &STEREO_CONFIGURATIONS, &["stereoPair", "centerRight", "centerLeft"])?.unwrap_or(0),
scene_depth: f_p(p, "sceneDepth").unwrap_or(3.0).clamp(0.0, 100.0),
convergence: b_p(p, "convergence").unwrap_or(false),
convergence_of: pick("convergenceOf", &STEREO_CONVERGENCE, &["poi", "zoom"])?.unwrap_or(0),
z_offset: f_p(p, "zOffset").unwrap_or(0.0),
})
}
fn unique_item_name(proj: &Project, base: &str) -> String {
let mut name = base.to_string();
let mut k = 2;
while proj.items.values().any(|i| i.name == name) {
name = format!("{base} {k}");
k += 1;
}
name
}
/// An empty comp with the settings of `src`.
fn sibling_comp(src: &Comp) -> Comp {
let mut c = src.clone();
c.layers.clear();
c.markers.clear();
c
}
fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.stereoRig";
let cid = comp_id(s, p)?;
let sp = stereo_p(p, cmd)?;
let view3d = p.get("view3d").and_then(Value::as_u64).unwrap_or(5) as u32;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let src_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let t = s.time_of(cid);
let w = src.width as f64;
let existing = src.active_camera(t).map(|l| l.id);
let env = Env::of(s, cid);
let r = s.edit("Create Stereo 3D Rig", None, |proj, st| {
// The master camera: the comp's active camera, else a new one.
let master_id = match existing {
Some(id) => id,
None => {
let name = src.unique_layer_name("Master Camera");
let l = build::layer(proj, &src, &name, LayerSource::Camera, (src.width, src.height), None);
insert_layer(proj, st, cid, l)?
}
};
let controls_name = proj.comp(cid).map(|c| c.unique_layer_name("Stereo 3D Controls")).unwrap_or_default();
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let mut ctl = build::layer(proj, &src_now, &controls_name, LayerSource::Null, (src.width, src.height), None);
ctl.switches.video = false;
let spec = effectcraft_effects::find(STEREO_CONTROLS).ok_or_else(|| bad(cmd, "Stereo 3D Controls effect missing"))?;
let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut proj.next_id), spec.name, [100.0, 100.0]);
for (k, v) in [
("configuration", KV::Enum(sp.configuration)),
("sceneDepth", KV::Scalar(sp.scene_depth)),
("convergence", KV::Bool(sp.convergence)),
("convergenceOf", KV::Enum(sp.convergence_of)),
("zOffset", KV::Scalar(sp.z_offset)),
] {
if let Some(pr) = fx.get_mut(k) {
pr.value = v;
}
}
if let Some(g) = ctl.props.sub_mut("effects") {
g.children.push(fx.into());
}
let ctl_id = ctl.id;
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let at = src_now.layers.iter().position(|l| l.id == master_id).unwrap_or(0);
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(at, ctl);
// Master camera frame at the current time.
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let master = src_now.layer(master_id).cloned().ok_or(EngineError::NoComp)?;
let ctx = env.ctx(proj, cid, &src_now);
let (e, fwd, down) = layer_frame(&ctx, &master);
let right = down.cross(fwd).normalize();
let focus = focus_distance(&ctx, &master);
let zoom = camera::layer_camera(&ctx, &master).zoom;
let two = camera::is_two_node(&master);
// Left and right eye comps: the source comp collapsed, seen by an eye camera.
let mut eye_comps = vec![];
for (eye, label) in [(0usize, "Left"), (1, "Right")] {
let name = unique_item_name(proj, &format!("{src_name} {label} Eye"));
let ec = sibling_comp(&src_now);
let eid = proj.add_item(&name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(ec.clone().into()));
let mut nest = build::layer(proj, &ec, &src_name, LayerSource::Comp { item: cid }, (src.width, src.height), Some(src.duration));
nest.switches.three_d = true;
nest.switches.collapse = true;
let (pos, poi) = eye_camera(&sp, eye, w, (e, fwd, right), focus, zoom);
let mut cam = build::layer(proj, &ec, &format!("{label} Eye Camera"), LayerSource::Camera, (src.width, src.height), None);
set(&mut cam, "transform/position", KV::Vec3(arr(pos)));
set(&mut cam, "transform/poi", KV::Vec3(arr(poi)));
set(&mut cam, "cameraOptions/zoom", KV::Scalar(zoom));
set(&mut cam, "cameraOptions/focusDistance", KV::Scalar(focus));
let (pe, poe, ze) = eye_exprs(&src_name, &master.name, &controls_name, two, eye);
set_expr(&mut cam, "transform/position", pe);
set_expr(&mut cam, "transform/poi", poe);
set_expr(&mut cam, "cameraOptions/zoom", ze);
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
comp.layers = vec![cam, nest];
eye_comps.push(eid);
}
// The output comp: both eyes through 3D Glasses.
let out_name = unique_item_name(proj, &format!("{src_name} Stereo 3D"));
let oc = sibling_comp(&src_now);
let oid = proj.add_item(&out_name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(oc.clone().into()));
let left_name = proj.item(eye_comps[0]).map(|i| i.name.clone()).unwrap_or_default();
let right_name = proj.item(eye_comps[1]).map(|i| i.name.clone()).unwrap_or_default();
let mut left = build::layer(proj, &oc, &left_name, LayerSource::Comp { item: eye_comps[0] }, (src.width, src.height), Some(src.duration));
let mut right = build::layer(proj, &oc, &right_name, LayerSource::Comp { item: eye_comps[1] }, (src.width, src.height), Some(src.duration));
right.switches.video = false;
let glasses = effectcraft_effects::find("ec.perspective.3dglasses").ok_or_else(|| bad(cmd, "3D Glasses effect missing"))?;
let mut g = effectcraft_effects::instantiate(glasses, &mut Ids(&mut proj.next_id), glasses.name, [w, src.height as f64]);
for (k, v) in [("leftView", KV::Layer(Some(left.id.0))), ("rightView", KV::Layer(Some(right.id.0))), ("view3d", KV::Enum(view3d.min(8)))] {
if let Some(pr) = g.get_mut(k) {
pr.value = v;
}
}
if let Some(fxg) = left.props.sub_mut("effects") {
fxg.children.push(g.into());
}
let (lid, rid) = (left.id, right.id);
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
st.project_selection = vec![oid];
Ok(json!({
"controls": ctl_id.0,
"master": master_id.0,
"leftComp": eye_comps[0].0,
"rightComp": eye_comps[1].0,
"stereoComp": oid.0,
"leftLayer": lid.0,
"rightLayer": rid.0,
}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- orbit null
/// Create Orbit Null: a 3D null at the camera's point of interest becomes the camera's parent,
/// so rotating the null orbits the camera around what it looks at.
fn orbit_null(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.orbitNull";
let cid = comp_id(s, p)?;
let cam_id = camera_p(s, p, cid, cmd)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let cam = comp.layer(cam_id).cloned().ok_or(EngineError::NoComp)?;
if cam.parent.is_some() {
return Err(bad(cmd, format!("`{}` already has a parent", cam.name)));
}
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let (eye, fwd, _) = layer_frame(&ctx, &cam);
let target = eye + fwd * focus_distance(&ctx, &cam);
let name = comp.unique_layer_name(&format!("{} Orbit Null", cam.name));
let r = s.edit("Create Orbit Null", None, |proj, st| {
let mut null = build::layer(proj, &comp, &name, LayerSource::Null, (comp.width, comp.height), None);
null.switches.three_d = true;
null.switches.video = true;
set(&mut null, "transform/position", KV::Vec3(arr(target)));
// World → null space (a translated, unrotated, unscaled null: p − P + anchor).
let anchor = Vec3::from(null.props.prop("transform/anchor").map(|a| a.value.as_vec3()).unwrap_or([0.0; 3]));
let to_null = |v: [f64; 3]| arr(Vec3::from(v) - target + anchor);
let nid = null.id;
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|l| l.id == cam_id).unwrap_or(0);
c.layers.insert(at, null);
let l = c.layer_mut(cam_id).ok_or(EngineError::NoComp)?;
l.parent = Some(nid);
for path in ["transform/position", "transform/poi"] {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = KV::Vec3(to_null(pr.value.as_vec3()));
for k in &mut pr.keys {
k.value = KV::Vec3(to_null(k.value.as_vec3()));
}
}
}
st.selected_layers = vec![nid];
Ok(json!({"null": nid.0, "camera": cam_id.0, "position": arr(target)}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- control light with camera
/// Control Light with Camera: the light rides on the camera (parented at the camera's eye,
/// shining where it looks). Again (or `on: false`) releases it where it is.
fn control_light(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.controlWithCamera";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let lights: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(&comp, v)).collect(),
Some(v) => resolve_layer(&comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(Layer::is_light)).collect(),
};
if lights.is_empty() || lights.iter().any(|id| !comp.layer(*id).is_some_and(Layer::is_light)) {
return Err(bad(cmd, "select a light layer"));
}
let cam_id = match p.get("camera") {
Some(v) => resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(Layer::is_camera)).ok_or_else(|| bad(cmd, "no such camera"))?,
None => comp.active_camera(s.time_of(cid)).map(|l| l.id).ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
let on = b_p(p, "on").unwrap_or_else(|| comp.layer(lights[0]).is_none_or(|l| l.parent != Some(cam_id)));
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
// Released lights keep their current world placement.
let frames: Vec<(LayerId, Vec3, Vec3)> = lights
.iter()
.filter_map(|id| comp.layer(*id))
.map(|l| {
let (e, f, _) = layer_frame(&ctx, l);
(l.id, e, f)
})
.collect();
let (w, h) = (comp.width as f64, comp.height as f64);
s.edit(if on { "Control Light with Camera" } else { "Release Light from Camera" }, None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for (id, e, f) in &frames {
let l = c.layer_mut(*id).ok_or(EngineError::NoComp)?;
if on {
l.parent = Some(cam_id);
set(l, "transform/position", KV::Vec3([0.0; 3]));
set(l, "transform/poi", KV::Vec3([0.0, 0.0, 1000.0]));
set(l, "transform/orientation", KV::Vec3([0.0; 3]));
for k in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
set(l, k, KV::Scalar(0.0));
}
l.auto_orient = AutoOrient::Off;
} else if l.parent == Some(cam_id) {
l.parent = None;
set(l, "transform/position", KV::Vec3(arr(*e)));
set(l, "transform/poi", KV::Vec3(arr(*e + *f * w.max(h))));
if l.transform().is_some_and(|t| t.get("poi").is_some()) {
l.auto_orient = AutoOrient::TowardsPointOfInterest;
}
}
}
Ok(())
})?;
Ok(json!({"controlled": on, "camera": cam_id.0, "lights": lights.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
// ---------------------------------------------------------------- cameras and lights from models
/// Model layers a "from 3D Model" command reads: `layers`/`layer`, else the selected model layers.
fn model_layers(s: &Session, p: &Value, comp: &Comp, cmd: &str) -> Result<Vec<LayerId>> {
let ids: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(),
Some(v) => resolve_layer(comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))).collect(),
};
if ids.is_empty() || ids.iter().any(|id| !comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))) {
return Err(bad(cmd, "select a 3D model layer"));
}
Ok(ids)
}
/// A camera or light found in a model: (name, world eye, world forward, the model data).
enum FromModel {
Camera { name: String, eye: Vec3, fwd: Vec3, zoom: f64 },
Light { name: String, eye: Vec3, fwd: Vec3, light: effectcraft_model::ModelLight },
}
fn from_model(s: &mut Session, p: &Value, lights: bool) -> Result<Value> {
let cmd = if lights { "light.fromModel" } else { "camera.fromModel" };
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let ids = model_layers(s, p, &comp, cmd)?;
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let mut found = vec![];
for id in &ids {
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
let LayerSource::Model { item } = l.source else { continue };
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { continue };
let Some(m) = s.footage.model(item, f) else { return Err(bad(cmd, format!("can't read the model of `{}`", l.name))) };
let unit = l.props.sub("geometryOptions").map_or(1.0, |g| ctx.f(l, g, "unitScale", 1.0));
let to_world = ctx.world_matrix(l) * model_to_layer(unit);
for pl in m.placed(lights, None, 0.0) {
let w: Mat4 = to_world * pl.world;
let eye = w.apply(Vec3::ZERO);
let fwd = w.apply_vec(vec3(0.0, 0.0, -1.0)).normalize();
if lights {
let ml = m.lights[pl.index].clone();
found.push(FromModel::Light { name: ml.name.clone(), eye, fwd, light: ml });
} else {
let mc = &m.cameras[pl.index];
let zoom = match mc.projection {
effectcraft_model::CameraProjection::Perspective { yfov, .. } => comp.height as f64 * 0.5 / (yfov * 0.5).tan(),
// Orthographic cameras become perspective ones framing the same height.
effectcraft_model::CameraProjection::Orthographic { .. } => effectcraft_geom::default_camera_zoom(comp.width as f64),
};
found.push(FromModel::Camera { name: mc.name.clone(), eye, fwd, zoom });
}
}
}
if found.is_empty() {
return Err(bad(cmd, if lights { "the model has no lights (KHR_lights_punctual)" } else { "the model has no cameras" }));
}
let made = s.edit(if lights { "Create Lights from 3D Model" } else { "Create Cameras from 3D Model" }, None, |proj, st| {
let mut made = vec![];
for f in &found {
let c = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let l = match f {
FromModel::Camera { name, eye, fwd, zoom } => {
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Camera, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * *zoom)));
set(&mut l, "cameraOptions/zoom", KV::Scalar(*zoom));
set(&mut l, "cameraOptions/focusDistance", KV::Scalar(*zoom));
l
}
FromModel::Light { name, eye, fwd, light } => {
let kind = match light.kind {
effectcraft_model::ModelLightKind::Directional => LightKind::Parallel,
effectcraft_model::ModelLightKind::Point => LightKind::Point,
effectcraft_model::ModelLightKind::Spot { .. } => LightKind::Spot,
};
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Light { kind }, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * c.width.max(c.height) as f64)));
let col = light.color;
set(&mut l, "lightOptions/color", KV::Color([col[0], col[1], col[2], 1.0]));
// Intensity: 1 unit of the file (lux / candela) = 100%.
set(&mut l, "lightOptions/intensity", KV::Scalar((light.intensity * 100.0).clamp(0.0, 1000.0)));
if let effectcraft_model::ModelLightKind::Spot { inner, outer } = light.kind {
set(&mut l, "lightOptions/coneAngle", KV::Scalar((outer * 2.0).to_degrees().clamp(0.0, 180.0)));
set(&mut l, "lightOptions/coneFeather", KV::Scalar(((1.0 - inner / outer.max(1e-9)) * 100.0).clamp(0.0, 100.0)));
}
if let Some(r) = light.range {
set(&mut l, "lightOptions/falloff", KV::Enum(1));
set(&mut l, "lightOptions/falloffDistance", KV::Scalar(r * 100.0));
}
l
}
};
made.push(insert_layer(proj, st, cid, l)?.0);
}
Ok(made)
})?;
Ok(json!({"layers": made}))
}
// ---------------------------------------------------------------- environment background
/// Create Environment Light Background Layer: a layer showing the environment light's image as
/// the scene's backdrop (an equirectangular sky drawn through the camera).
fn environment_background(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.environmentBackground";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time_of(cid);
let is_env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
let light = match p.get("layer") {
Some(v) => resolve_layer(&comp, v).and_then(|id| comp.layer(id)).filter(|l| is_env(l)).ok_or_else(|| bad(cmd, "not an environment light"))?,
None => s
.state
.selected_layers
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| is_env(l))
.or_else(|| comp.layers.iter().find(|l| is_env(l)))
.ok_or_else(|| bad(cmd, "the composition has no environment light (Layer ▸ New ▸ Light ▸ Environment)"))?,
};
let src_layer = light
.props
.prop("lightOptions/source")
.and_then(|pr| pr.value_at(light.layer_time(t)).as_layer())
.and_then(|id| comp.layer(LayerId(id)))
.or_else(|| comp.layers.iter().find(|l| l.environment))
.ok_or_else(|| bad(cmd, "the environment light has no Source (or set an Environment Layer)"))?
.clone();
if !matches!(src_layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) {
return Err(bad(cmd, "the environment source must be a footage, comp or solid layer"));
}
let light_id = light.id;
let name = comp.unique_layer_name(&format!("{} Background", src_layer.name));
let size = effectcraft_render::source_size(&s.project, &src_layer);
let id = s.edit("Create Environment Light Background Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, src_layer.source.clone(), size, None);
l.switches.three_d = true;
l.environment_background = true;
// The backdrop sits at the bottom of the stack.
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let id = l.id;
c.layers.push(l);
st.selected_layers = vec![id];
Ok(id)
})?;
Ok(json!({"layer": id.0, "light": light_id.0, "source": src_layer.id.0}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"camera.stereoRig",
"Create Stereo 3D Rig",
["Layer", "Camera"],
None,
"{comp?, configuration?: stereoPair|centerRight|centerLeft, sceneDepth? (% of comp width, default 3), convergence?: bool, convergenceOf?: poi|zoom, zOffset?, view3d?: 3D Glasses view index (default 5 Balanced Colored Red Blue)}",
has_comp,
stereo_rig
),
cmd!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], None, "{comp?, layer?: camera (default selected/active)}", has_comp, orbit_null),
cmd!(
"camera.fromModel",
"Create Cameras from 3D Model",
["Layer", "Camera"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, false) }
),
cmd!(
"light.fromModel",
"Create Lights from 3D Model",
["Layer", "Light"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, true) }
),
cmd!(
"light.controlWithCamera",
"Control Light with Camera",
["Layer", "Light"],
None,
"{comp?, layers?: lights (default selected), camera? (default active), on?: bool (default toggle)}",
has_comp,
control_light
),
cmd!(
"light.environmentBackground",
"Create Environment Light Background Layer",
["Layer", "Light"],
None,
"{comp?, layer?: environment light (default selected / first)}",
has_comp,
environment_background
),
]
}