mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
M7.1: Classic 3D renderer, cameras, lights, 3D views and camera-tool commands
- render/three_d: camera eval (one/two-node, DOF), default 50mm comp camera, orthographic Front/Left/Top/Back/Right/Bottom + perspective Custom Views, orbit/pan/dolly rig math; lights (parallel/spot/point/ambient, falloff, cone feather) and material shading; per-pixel depth-sorted compositor for 3D runs (exact intersections, perspective-correct sampling, blend modes, track mattes, motion blur), ray-cast planar shadows with transmission and diffusion, per-layer DOF blur. RenderOpts.view camera override. - project: camera iris/highlight props, aperture per comp width, light orientation/rotations, two-node auto-orient defaults. - engine: layer.newCamera/newLight with presets & settings, layer.cameraSettings/lightSettings (Layer Settings dispatches), view.set3DView + View > Switch 3D View items (F10/F11/F12), last/reset view, camera.orbit/pan/dolly, view.get3D; EditorState.views3d. - demo: "3D Showcase" comp (camera move, key spot + ambient fill, shadows, DOF, intersecting planes, gridded floor). - tests: projection, depth order, intersections, lighting, shadows, DOF, views, rig math; command undo/redo. Fix a few pre-existing clippy lints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dee16181fe
commit
76ac3f74f7
@@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{Justify, ShapePath, TextDoc, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
||||
pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
||||
match p.get(k)? {
|
||||
Value::String(s) => effectcraft_color::Rgba::from_hex(s).map(|c| [c.r, c.g, c.b]),
|
||||
Value::Array(a) => {
|
||||
@@ -24,7 +24,7 @@ fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
||||
}
|
||||
|
||||
/// Insert a new layer above the selection (or at the top) and select it.
|
||||
fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
|
||||
let id = layer.id;
|
||||
@@ -209,13 +209,6 @@ fn new_simple(s: &mut Session, p: &Value, src: LayerSource, name: &str, label: &
|
||||
fn new_null(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object")
|
||||
}
|
||||
fn new_camera(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
new_simple(s, p, LayerSource::Camera, "Camera 1", "New Camera")
|
||||
}
|
||||
fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = str_p(p, "kind").and_then(|k| LightKind::ALL.into_iter().find(|l| l.label().eq_ignore_ascii_case(k))).unwrap_or(LightKind::Point);
|
||||
new_simple(s, p, LayerSource::Light { kind }, "Light 1", "New Light")
|
||||
}
|
||||
|
||||
fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
@@ -820,6 +813,12 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.settings")?;
|
||||
// Cameras and lights open their own settings (Camera Settings / Light Settings).
|
||||
match s.project.comp(cid).and_then(|c| c.layer(lid)).map(|l| l.source.clone()) {
|
||||
Some(LayerSource::Camera) => return s.execute("layer.cameraSettings", with_layer(p, lid)),
|
||||
Some(LayerSource::Light { .. }) => return s.execute("layer.lightSettings", with_layer(p, lid)),
|
||||
_ => {}
|
||||
}
|
||||
let color = color_p(p, "color");
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32);
|
||||
@@ -849,12 +848,16 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn with_layer(p: &Value, lid: LayerId) -> Value {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
p["layer"] = json!(lid.0);
|
||||
p
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.newText", "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), "{text?, size?, font?, fill?, position? [x,y], justify?}", has_comp, new_text),
|
||||
cmd!("layer.newSolid", "Solid…", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
|
||||
cmd!("layer.newLight", "Light…", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), "{kind?: Parallel|Spot|Point|Ambient}", has_comp, new_light),
|
||||
cmd!("layer.newCamera", "Camera…", ["Layer", "New"], Some("Cmd+Alt+Shift+C"), "{name?}", has_comp, new_camera),
|
||||
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
|
||||
cmd!(
|
||||
"layer.newShape",
|
||||
|
||||
@@ -9,6 +9,7 @@ mod help;
|
||||
mod layer;
|
||||
mod prop;
|
||||
mod query;
|
||||
mod three_d;
|
||||
mod time;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
@@ -68,6 +69,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(edit::specs());
|
||||
v.extend(comp::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(three_d::specs());
|
||||
v.extend(prop::specs());
|
||||
v.extend(effect::specs());
|
||||
v.extend(time::specs());
|
||||
@@ -182,6 +184,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
|
||||
s.time()
|
||||
}
|
||||
|
||||
pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> {
|
||||
pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> {
|
||||
p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
//! 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"))?,
|
||||
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"))?)
|
||||
}
|
||||
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|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);
|
||||
|
||||
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() };
|
||||
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")
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
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"],
|
||||
None,
|
||||
"{layer?, name?, type?, preset?, zoom?, focalLength?, angleOfView?, dof?, focusDistance?, aperture?, fStop?, blurLevel?, position?, poi?}",
|
||||
has_comp,
|
||||
camera_settings
|
||||
),
|
||||
cmd!(
|
||||
"layer.lightSettings",
|
||||
"Light Settings…",
|
||||
["Layer"],
|
||||
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|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.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.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),
|
||||
]
|
||||
}
|
||||
+134
-1
@@ -1,6 +1,8 @@
|
||||
//! The demo project: an animated title sequence built entirely from procedural content (original
|
||||
//! work, MIT OR Apache-2.0): gradient background, orbiting shape rings with trim paths and a
|
||||
//! repeater burst, an animated title with a text animator and glow, a lower-third precomp.
|
||||
//! repeater burst, an animated title with a text animator and glow, a lower-third precomp; and a
|
||||
//! "3D Showcase" comp: a two-node camera move with depth of field over lit, shadow-casting 3D
|
||||
//! cards, intersecting planes and a gridded floor.
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_color::Label;
|
||||
@@ -10,6 +12,7 @@ use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, P
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
pub const MAIN_COMP: &str = "EffectCraft Intro";
|
||||
pub const SHOWCASE_3D: &str = "3D Showcase";
|
||||
|
||||
fn t(s: f64) -> Tick {
|
||||
Tick::from_seconds_f64(s)
|
||||
@@ -121,6 +124,134 @@ fn lower_third(p: &mut Project) -> Comp {
|
||||
c
|
||||
}
|
||||
|
||||
fn solid_sized(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color: [f32; 3], w: u32, h: u32) -> Layer {
|
||||
let sid = p.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None)
|
||||
}
|
||||
|
||||
/// A 3D card: a solid with a diagonal gradient and a thin border grid.
|
||||
fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &str, size: (u32, u32), pos: [f64; 3], rot_y: f64) -> Layer {
|
||||
let mut l = solid_sized(p, comp, folder, name, [1.0, 1.0, 1.0], size.0, size.1);
|
||||
let (w, h) = (size.0 as f64, size.1 as f64);
|
||||
effect(
|
||||
p,
|
||||
&mut l,
|
||||
"ec.generate.gradientramp",
|
||||
[w, h],
|
||||
&[("start", Value::Vec2([0.0, 0.0])), ("end", Value::Vec2([w, h])), ("startColor", Value::Color(hex(a))), ("endColor", Value::Color(hex(b)))],
|
||||
);
|
||||
effect(
|
||||
p,
|
||||
&mut l,
|
||||
"ec.generate.grid",
|
||||
[w, h],
|
||||
&[("width", Value::Scalar(w)), ("height", Value::Scalar(h)), ("border", Value::Scalar(10.0)), ("opacity", Value::Scalar(35.0))],
|
||||
);
|
||||
l.switches.three_d = true;
|
||||
set(&mut l, "transform/position", Value::Vec3(pos));
|
||||
set(&mut l, "transform/rotationY", Value::Scalar(rot_y));
|
||||
set(&mut l, "materialOptions/castsShadows", Value::Enum(1));
|
||||
set(&mut l, "materialOptions/specularIntensity", Value::Scalar(35.0));
|
||||
set(&mut l, "materialOptions/specularShininess", Value::Scalar(40.0));
|
||||
l
|
||||
}
|
||||
|
||||
/// The "3D Showcase" comp: camera move, key + fill lights, shadows, DOF, intersecting planes.
|
||||
fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
|
||||
let mut c = Comp::new(1920, 1080, FrameRate::FPS_29_97, t(10.0));
|
||||
c.background = [0.02, 0.02, 0.035];
|
||||
// Floor: a gridded plane laid flat (X rotation 90°) below the cards.
|
||||
let mut floor = solid_sized(p, &c, solids, "Floor", [0.16, 0.17, 0.22], 1000, 1000);
|
||||
effect(
|
||||
p,
|
||||
&mut floor,
|
||||
"ec.generate.grid",
|
||||
[1000.0, 1000.0],
|
||||
&[
|
||||
("width", Value::Scalar(100.0)),
|
||||
("height", Value::Scalar(100.0)),
|
||||
("border", Value::Scalar(3.0)),
|
||||
("color", Value::Color(hex("#6F7BB8"))),
|
||||
("opacity", Value::Scalar(45.0)),
|
||||
],
|
||||
);
|
||||
floor.switches.three_d = true;
|
||||
set(&mut floor, "transform/position", Value::Vec3([960.0, 800.0, 500.0]));
|
||||
set(&mut floor, "transform/scale", Value::Vec3([400.0, 400.0, 100.0]));
|
||||
set(&mut floor, "transform/rotationX", Value::Scalar(90.0));
|
||||
set(&mut floor, "materialOptions/specularIntensity", Value::Scalar(10.0));
|
||||
|
||||
// Cards at different depths and angles; the centre pair intersects.
|
||||
let left = card(p, &c, solids, "Card Blue", "#2E7BF0", "#7A4DFF", (520, 340), [520.0, 560.0, 120.0], 28.0);
|
||||
let right = card(p, &c, solids, "Card Coral", "#FF6A5C", "#FFB347", (520, 340), [1420.0, 560.0, 260.0], -32.0);
|
||||
let mut x1 = card(p, &c, solids, "Cross A", "#1FD1A5", "#2E7BF0", (460, 460), [960.0, 470.0, 520.0], 40.0);
|
||||
set(&mut x1, "materialOptions/lightTransmission", Value::Scalar(30.0));
|
||||
let x2 = card(p, &c, solids, "Cross B", "#F0E14A", "#FF6A5C", (460, 460), [960.0, 470.0, 520.0], -40.0);
|
||||
// Hero title in 3D space, gently turning.
|
||||
let mut title = text_layer(
|
||||
p,
|
||||
&c,
|
||||
"3D Title",
|
||||
TextDoc { text: "CLASSIC 3D".into(), size: 150.0, style: "Bold".into(), tracking: 60.0, justify: Justify::Center, ..Default::default() },
|
||||
[960.0, 560.0],
|
||||
);
|
||||
title.switches.three_d = true;
|
||||
set(&mut title, "transform/position", Value::Vec3([960.0, 600.0, -160.0]));
|
||||
anim(&mut title, "transform/rotationY", keys(&[(0.0, Value::Scalar(-18.0)), (10.0, Value::Scalar(18.0))]));
|
||||
set(&mut title, "materialOptions/castsShadows", Value::Enum(1));
|
||||
|
||||
// Camera: two-node, 35 mm, sweeping around the set with depth of field on the title.
|
||||
let mut cam = build::layer(p, &c, "Camera 1", LayerSource::Camera, (1920, 1080), None);
|
||||
let zoom = 1920.0 * 35.0 / 36.0;
|
||||
set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom));
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(40.0));
|
||||
set(&mut cam, "cameraOptions/blurLevel", Value::Scalar(100.0));
|
||||
set(&mut cam, "transform/poi", Value::Vec3([960.0, 560.0, 150.0]));
|
||||
anim(
|
||||
&mut cam,
|
||||
"transform/position",
|
||||
keys(&[(0.0, Value::Vec3([-200.0, 160.0, -1500.0])), (5.0, Value::Vec3([1100.0, 80.0, -1850.0])), (10.0, Value::Vec3([2150.0, 220.0, -1300.0]))]),
|
||||
);
|
||||
anim(&mut cam, "cameraOptions/focusDistance", keys(&[(0.0, Value::Scalar(1700.0)), (5.0, Value::Scalar(1900.0)), (10.0, Value::Scalar(1650.0))]));
|
||||
|
||||
// Key light: warm spot casting soft shadows. Fill: cool ambient.
|
||||
let mut key = build::layer(p, &c, "Key Light", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None);
|
||||
set(&mut key, "transform/position", Value::Vec3([500.0, -500.0, -700.0]));
|
||||
set(&mut key, "transform/poi", Value::Vec3([960.0, 600.0, 300.0]));
|
||||
set(&mut key, "lightOptions/intensity", Value::Scalar(150.0));
|
||||
set(&mut key, "lightOptions/color", Value::Color(hex("#FFE6C7")));
|
||||
set(&mut key, "lightOptions/coneAngle", Value::Scalar(110.0));
|
||||
set(&mut key, "lightOptions/coneFeather", Value::Scalar(60.0));
|
||||
set(&mut key, "lightOptions/castsShadows", Value::Bool(true));
|
||||
set(&mut key, "lightOptions/shadowDarkness", Value::Scalar(70.0));
|
||||
set(&mut key, "lightOptions/shadowDiffusion", Value::Scalar(12.0));
|
||||
let mut fill = build::layer(p, &c, "Fill Light", LayerSource::Light { kind: effectcraft_project::LightKind::Ambient }, (1920, 1080), None);
|
||||
set(&mut fill, "lightOptions/intensity", Value::Scalar(35.0));
|
||||
set(&mut fill, "lightOptions/color", Value::Color(hex("#B9C8FF")));
|
||||
|
||||
// 2D caption on top (2D layers break 3D groups, so it always draws over the 3D scene).
|
||||
let mut caption = text_layer(
|
||||
p,
|
||||
&c,
|
||||
"Caption",
|
||||
TextDoc {
|
||||
text: "CAMERAS · LIGHTS · SHADOWS · DEPTH OF FIELD".into(),
|
||||
size: 30.0,
|
||||
style: "Medium".into(),
|
||||
tracking: 220.0,
|
||||
fill: [0.78, 0.83, 1.0, 1.0],
|
||||
justify: Justify::Center,
|
||||
..Default::default()
|
||||
},
|
||||
[960.0, 1000.0],
|
||||
);
|
||||
anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))]));
|
||||
|
||||
c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor];
|
||||
c
|
||||
}
|
||||
|
||||
/// Build the demo project.
|
||||
pub fn demo_project() -> Project {
|
||||
let mut p = Project::default();
|
||||
@@ -318,6 +449,8 @@ pub fn demo_project() -> Project {
|
||||
}
|
||||
comp.work_area = (Tick::ZERO, t(10.0));
|
||||
p.add_item(MAIN_COMP, Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
let show = showcase_3d(&mut p, solids);
|
||||
p.add_item(SHOWCASE_3D, Label::Sandstone, None, ItemKind::Comp(show.into()));
|
||||
p.fix_next_id();
|
||||
p
|
||||
}
|
||||
|
||||
@@ -110,6 +110,9 @@ pub struct EditorState {
|
||||
pub snapping: bool,
|
||||
/// Last applied effect id (Effect ▸ last effect).
|
||||
pub last_effect: Option<String>,
|
||||
/// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras).
|
||||
#[serde(default)]
|
||||
pub views3d: std::collections::BTreeMap<ItemId, effectcraft_render::three_d::Views3D>,
|
||||
}
|
||||
|
||||
/// Events for frontends (drained each frame).
|
||||
@@ -299,6 +302,15 @@ impl Session {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// The 3D view camera of a comp's viewer (None = the active camera).
|
||||
pub fn view_camera(&self, comp: ItemId) -> Option<effectcraft_render::three_d::CameraState> {
|
||||
let c = self.project.comp(comp)?;
|
||||
if !c.has_3d() {
|
||||
return None;
|
||||
}
|
||||
self.state.views3d.get(&comp)?.override_camera(c.width as f64, c.height as f64)
|
||||
}
|
||||
|
||||
/// Render a comp frame with the session's footage + expression hosts.
|
||||
pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image {
|
||||
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
|
||||
@@ -324,6 +336,8 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_3d;
|
||||
|
||||
/// Font families available to text layers (bundled + scanned system fonts).
|
||||
pub fn text_families() -> Vec<String> {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp.
|
||||
|
||||
use effectcraft_project::{AutoOrient, LayerSource, LightKind};
|
||||
use effectcraft_render::three_d::View3D;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer_count(s: &Session) -> usize {
|
||||
s.active_comp().unwrap().layers.len()
|
||||
}
|
||||
|
||||
fn prop(s: &Session, layer: u64, path: &str) -> effectcraft_keyframe::Value {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap();
|
||||
l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).value.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_camera_with_preset_undo_redo() {
|
||||
let mut s = session();
|
||||
let r = s.execute("layer.newCamera", json!({"preset": "35mm"})).unwrap();
|
||||
let id = r["layer"].as_u64().unwrap();
|
||||
assert_eq!(layer_count(&s), 1);
|
||||
let zoom = prop(&s, id, "cameraOptions/zoom").as_f64();
|
||||
assert!((zoom - 640.0 * 35.0 / 36.0).abs() < 1e-9);
|
||||
assert_eq!(prop(&s, id, "cameraOptions/focusDistance").as_f64(), zoom);
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
assert_eq!(l.source, LayerSource::Camera);
|
||||
assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest);
|
||||
assert!(s.undo());
|
||||
assert_eq!(layer_count(&s), 0);
|
||||
assert!(s.redo());
|
||||
assert_eq!(layer_count(&s), 1);
|
||||
assert!(s.execute("layer.newCamera", json!({"preset": "13mm-fisheye"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_settings_one_node_keeps_view() {
|
||||
let mut s = session();
|
||||
let id = s.execute("layer.newCamera", json!({"poi": [100, 50, 0], "position": [320, 180, -400]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let fwd = |s: &Session| {
|
||||
let c = s.project.comp(cid).unwrap();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, c, s.time());
|
||||
effectcraft_render::three_d::active_camera(&ctx).forward()
|
||||
};
|
||||
let before = fwd(&s);
|
||||
s.execute("layer.cameraSettings", json!({"layer": id, "type": "oneNode", "dof": true, "aperture": 30})).unwrap();
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
assert_eq!(l.auto_orient, AutoOrient::Off);
|
||||
assert!((fwd(&s) - before).length() < 1e-9, "one-node camera keeps looking the same way");
|
||||
assert!(prop(&s, id, "cameraOptions/dof").as_bool());
|
||||
// Layer Settings (Cmd+Shift+Y) on a camera opens Camera Settings.
|
||||
s.execute("layer.settings", json!({"layer": id, "zoom": 500})).unwrap();
|
||||
assert_eq!(prop(&s, id, "cameraOptions/zoom").as_f64(), 500.0);
|
||||
s.undo();
|
||||
s.undo();
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest);
|
||||
assert!(!prop(&s, id, "cameraOptions/dof").as_bool());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_light_and_change_type() {
|
||||
let mut s = session();
|
||||
let id = s
|
||||
.execute(
|
||||
"layer.newLight",
|
||||
json!({"kind": "Spot", "intensity": 150, "color": [1, 0.5, 0.25], "coneAngle": 60, "castsShadows": true, "falloff": "Smooth"}),
|
||||
)
|
||||
.unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(prop(&s, id, "lightOptions/intensity").as_f64(), 150.0);
|
||||
assert_eq!(prop(&s, id, "lightOptions/coneAngle").as_f64(), 60.0);
|
||||
assert_eq!(prop(&s, id, "lightOptions/falloff").as_enum(), 1);
|
||||
assert!(prop(&s, id, "lightOptions/castsShadows").as_bool());
|
||||
s.execute("layer.lightSettings", json!({"layer": id, "kind": "Point"})).unwrap();
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
assert_eq!(l.source, LayerSource::Light { kind: LightKind::Point });
|
||||
assert!(l.props.prop("lightOptions/coneAngle").is_none());
|
||||
assert_eq!(prop(&s, id, "lightOptions/intensity").as_f64(), 150.0, "shared values carry over");
|
||||
s.undo();
|
||||
let l = s.active_comp().unwrap().layers[0].clone();
|
||||
assert_eq!(l.source, LayerSource::Light { kind: LightKind::Spot });
|
||||
assert!(s.execute("layer.newLight", json!({"kind": "Laser"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_switch_undo_redo() {
|
||||
let mut s = session();
|
||||
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers[0].is_3d());
|
||||
s.undo();
|
||||
assert!(!s.active_comp().unwrap().layers[0].is_3d());
|
||||
s.redo();
|
||||
assert!(s.active_comp().unwrap().layers[0].is_3d());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_switch_and_override_camera() {
|
||||
let mut s = session();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layer": id, "switch": "threeD", "value": true})).unwrap();
|
||||
assert!(s.view_camera(cid).is_none());
|
||||
s.execute("view.3d.top", json!({})).unwrap();
|
||||
let cam = s.view_camera(cid).unwrap();
|
||||
assert!(cam.ortho);
|
||||
assert!((cam.forward().y - 1.0).abs() < 1e-9);
|
||||
s.execute("view.set3DView", json!({"view": "Custom View 2"})).unwrap();
|
||||
assert!(!s.view_camera(cid).unwrap().ortho);
|
||||
s.execute("view.3d.last", json!({})).unwrap();
|
||||
assert_eq!(s.state.views3d[&cid].current, View3D::Top);
|
||||
// Orbit a custom view: edits the view camera, not the project.
|
||||
s.execute("view.3d.custom1", json!({})).unwrap();
|
||||
let rev = s.revision;
|
||||
let before = s.view_camera(cid).unwrap().eye;
|
||||
s.execute("camera.orbit", json!({"yaw": 20})).unwrap();
|
||||
assert!((s.view_camera(cid).unwrap().eye - before).length() > 1.0);
|
||||
assert_eq!(s.revision, rev);
|
||||
s.execute("view.reset3DView", json!({})).unwrap();
|
||||
assert!((s.view_camera(cid).unwrap().eye - before).length() < 1e-9);
|
||||
s.execute("view.3d.activeCamera", json!({})).unwrap();
|
||||
assert!(s.view_camera(cid).is_none());
|
||||
let q = s.execute("view.get3D", json!({})).unwrap();
|
||||
assert_eq!(q["view"], "activeCamera");
|
||||
assert!(s.execute("view.set3DView", json!({"view": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_tools_edit_active_camera_with_undo() {
|
||||
let mut s = session();
|
||||
// No camera layer: the tools explain what is needed.
|
||||
assert!(s.execute("camera.orbit", json!({"yaw": 10})).is_err());
|
||||
let id = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let p0 = prop(&s, id, "transform/position").as_vec3();
|
||||
let poi = prop(&s, id, "transform/poi").as_vec3();
|
||||
let dist = |a: [f64; 3], b: [f64; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
|
||||
// A drag = several merged steps → one undo.
|
||||
let undo_len = s.history.undo.len();
|
||||
for _ in 0..5 {
|
||||
s.execute("camera.orbit", json!({"yaw": 6, "pitch": -2, "merge": "drag-1"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo_len + 1);
|
||||
let p1 = prop(&s, id, "transform/position").as_vec3();
|
||||
assert!((dist(p1, poi) - dist(p0, poi)).abs() < 1e-6, "orbit keeps the distance to the POI");
|
||||
assert!(dist(p1, p0) > 10.0);
|
||||
s.execute("camera.pan", json!({"dx": 50, "dy": 0})).unwrap();
|
||||
let poi2 = prop(&s, id, "transform/poi").as_vec3();
|
||||
assert!(dist(poi2, poi) > 1.0, "pan moves the POI too");
|
||||
s.execute("camera.dolly", json!({"amount": 100})).unwrap();
|
||||
s.undo();
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert_eq!(prop(&s, id, "transform/position").as_vec3(), p0);
|
||||
// One-node cameras orbit by re-orienting.
|
||||
s.execute("layer.cameraSettings", json!({"layer": id, "type": "oneNode"})).unwrap();
|
||||
s.execute("camera.orbit", json!({"yaw": 30})).unwrap();
|
||||
let o = prop(&s, id, "transform/orientation").as_vec3();
|
||||
assert!(o[1].abs() > 1.0, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_has_3d_showcase() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let item = s.project.find_by_name(crate::demo::SHOWCASE_3D).unwrap();
|
||||
let cid = item.id;
|
||||
let c = s.project.comp(cid).unwrap();
|
||||
assert!(c.layers.iter().any(|l| l.is_camera()));
|
||||
assert!(c.layers.iter().any(|l| l.is_light()));
|
||||
assert!(c.layers.iter().filter(|l| l.switches.three_d).count() >= 4);
|
||||
let img =
|
||||
s.render(cid, effectcraft_time::Tick::from_seconds_f64(4.0), effectcraft_render::RenderOpts { scale: 0.125, motion_blur: false, ..Default::default() });
|
||||
let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count();
|
||||
assert!(lit > 2_000, "{lit}");
|
||||
}
|
||||
Reference in New Issue
Block a user