//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the //! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and //! shapes, and Layer ▸ Material commands. use effectcraft_keyframe::Value as KV; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Node, PrimitiveKind, PropGroup, Renderer}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::{color_p, index_p, insert_layer, insert_layer_at, place, position_p}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; // ---------------------------------------------------------------- geometry options /// Add Geometry Options (Bevel Style, Bevel Depth, Hole Bevel Depth, Extrusion Depth) to a text /// or shape layer that has none. pub(crate) fn add_geometry_options(next_id: &mut u64, l: &mut Layer) { if !matches!(l.source, LayerSource::Text | LayerSource::Shape) || l.props.sub("geometryOptions").is_some() { return; } let g = build::extrusion_geometry_options(&mut Ids(next_id)); // After Transform, like After Effects. let at = l.props.children.iter().position(|c| c.match_id() == "transform").map_or(l.props.children.len(), |i| i + 1); l.props.children.insert(at, g.into()); } /// Advanced 3D comps: give every text and shape layer Geometry Options. pub(crate) fn sync_geometry_options(proj: &mut effectcraft_project::Project, cid: ItemId) { if proj.comp(cid).is_none_or(|c| c.renderer != Renderer::Advanced3D) { return; } let mut next = proj.next_id; if let Some(c) = proj.comp_mut(cid) { for l in &mut c.layers { add_geometry_options(&mut next, l); } } proj.next_id = next; } fn renderer_p(p: &Value, cmd: &str) -> Result { let v = str_p(p, "renderer").or_else(|| str_p(p, "value")).ok_or_else(|| bad(cmd, "missing `renderer` (advanced3d|classic3d)"))?; match v.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { "advanced3d" | "advanced" => Ok(Renderer::Advanced3D), "classic3d" | "classic" => Ok(Renderer::Classic3D), _ => Err(bad(cmd, format!("renderer: advanced3d|classic3d, not `{v}`"))), } } fn comp_renderer(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; if p.get("renderer").is_none() && p.get("value").is_none() { let r = s.project.comp(cid).ok_or(EngineError::NoComp)?.renderer; return Ok(json!({"renderer": renderer_id(r)})); } let r = renderer_p(p, "comp.renderer")?; s.edit("Change Renderer", None, |proj, _| { proj.comp_mut(cid).ok_or(EngineError::NoComp)?.renderer = r; sync_geometry_options(proj, cid); Ok(()) })?; if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) { s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer"); } Ok(json!({"renderer": renderer_id(r)})) } fn renderer_id(r: Renderer) -> &'static str { match r { Renderer::Advanced3D => "advanced3d", Renderer::Classic3D => "classic3d", } } /// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a /// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether /// the renderer changed. fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let switched = c.renderer != Renderer::Advanced3D; c.renderer = Renderer::Advanced3D; sync_geometry_options(proj, cid); Ok(switched) } /// Tell the user when [`use_advanced_3d`] changed the comp's renderer. fn report_switch(s: &mut Session, cid: ItemId, switched: bool) { if switched { let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string(); s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models")); } } // ---------------------------------------------------------------- model layers /// Set a property's value at comp time `t` (adds a key when it is animated). fn set_at(l: &mut Layer, path: &str, t: Tick, v: KV) -> bool { let lt = l.layer_time(t); match l.props.prop_mut(path) { Some(pr) => { pr.set_value_at(lt, v); true } None => false, } } /// New 3D model layer from a model footage item (`item`) or a file (`path`, imported first). pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let item = match (p.get("item"), str_p(p, "path")) { (Some(Value::Number(n)), _) => ItemId(n.as_u64().unwrap_or(0)), (Some(Value::String(name)), _) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.newModel", format!("no item `{name}`")))?, (_, Some(path)) => { let r = s.execute("file.import", json!({"paths": [path]}))?; let id = r.get("items").and_then(|a| a.get(0)).and_then(Value::as_u64); let err = r.get("errors").and_then(|e| e.get(0)).and_then(Value::as_str).unwrap_or("import failed").to_string(); ItemId(id.ok_or_else(|| bad("layer.newModel", err))?) } _ => *s.state.project_selection.first().ok_or_else(|| bad("layer.newModel", "missing `item` or `path`"))?, }; let it = s.project.item(item).ok_or_else(|| bad("layer.newModel", "no such item"))?.clone(); let ItemKind::Footage(f) = &it.kind else { return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name))) }; if f.kind != FootageKind::Model { return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name))); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); // Fit the model to half the comp height, and list its animation clips. let model = s.footage.model(item, f); let (scale, clips) = match &model { Some(m) => { let ext = m.bounds().map_or(1.0, |(lo, hi)| (0..3).map(|k| hi[k] - lo[k]).fold(0.0f64, f64::max)); let scale = if ext > 1e-9 { comp.height as f64 * 0.5 / ext } else { 100.0 }; let clips = m.animations.iter().enumerate().map(|(i, a)| if a.name.is_empty() { format!("Animation {}", i + 1) } else { a.name.clone() }).collect(); (scale, clips) } None => (100.0, vec![]), }; let name = str_p(p, "name").unwrap_or(&it.name).to_string(); let fr = comp.frame_rate; let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO)); let index = index_p(p, "layer.newModel")?; let position = position_p(p, "layer.newModel")?; let (id, switched) = s.edit("New 3D Model Layer", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None); let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips); if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") { *slot = g.into(); } l.start_time = start; l.in_point = start; l.out_point = comp.duration.max(start + fr.frame_duration()); place(&mut l, position); let id = insert_layer_at(proj, st, cid, l, index)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; report_switch(s, cid, switched); Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } fn kind_p(p: &Value, cmd: &str) -> Result { let k = str_p(p, "kind").or_else(|| str_p(p, "type")).ok_or_else(|| bad(cmd, "missing `kind` (cube|sphere|plane|torus|cone|cylinder)"))?; PrimitiveKind::parse(k).ok_or_else(|| bad(cmd, format!("kind: cube|sphere|plane|torus|cone|cylinder, not `{k}`"))) } /// Parse Material Options parameters onto a layer at comp time `t`. fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result { let mut n = 0; let tri = |v: &Value| -> Option { match v { Value::Bool(b) => Some(*b as u32), Value::Number(x) => x.as_u64().map(|x| x.min(2) as u32), Value::String(s) => match s.to_ascii_lowercase().as_str() { "off" | "false" => Some(0), "on" | "true" => Some(1), "only" => Some(2), _ => None, }, _ => None, } }; for k in ["castsShadows", "acceptsShadows"] { if let Some(v) = p.get(k) { let e = tri(v).ok_or_else(|| bad(cmd, format!("{k}: off|on|only")))?; n += set_at(l, &format!("materialOptions/{k}"), t, KV::Enum(e)) as usize; } } for k in ["acceptsLights", "appearsInReflections"] { if let Some(v) = b_p(p, k) { n += set_at(l, &format!("materialOptions/{k}"), t, KV::Bool(v)) as usize; } } for k in ["lightTransmission", "ambient", "diffuse", "specularIntensity", "specularShininess", "metal", "metallic", "roughness"] { if let Some(v) = f_p(p, k) { n += set_at(l, &format!("materialOptions/{k}"), t, KV::Scalar(v)) as usize; } } for k in ["baseColor", "emissive"] { if let Some(c) = color_p(p, k) { n += set_at(l, &format!("materialOptions/{k}"), t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])) as usize; } } Ok(n) } fn new_primitive(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let kind = kind_p(p, "layer.newPrimitive")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string()); let t = super::time_p(s, p, Some(&comp)); let (id, switched) = s.edit(&format!("New {}", kind.label()), None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None); for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] { if let Some(v) = f_p(p, k) { set_at(&mut l, &format!("geometryOptions/{k}"), t, KV::Scalar(v.max(0.0))); } } if let Some(pos) = p.get("position").and_then(Value::as_array) { let g = |i: usize| pos.get(i).and_then(Value::as_f64).unwrap_or(0.0); set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)])); } apply_material(&mut l, p, t, "layer.newPrimitive")?; let id = insert_layer(proj, st, cid, l)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; report_switch(s, cid, switched); Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } // ---------------------------------------------------------------- environment layer fn environment(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; if ids.is_empty() { return Err(bad("layer.environment", "select a footage or composition layer")); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; for id in &ids { let l = comp.layer(*id).ok_or(EngineError::NoComp)?; if !l.source.is_av() || l.source.is_model() { return Err(bad("layer.environment", format!("`{}` can't be an environment layer (use a footage, comp or solid layer)", l.name))); } } let on = b_p(p, "on").unwrap_or_else(|| !comp.layer(ids[0]).is_some_and(|l| l.environment)); s.edit("Environment Layer", None, |proj, _| { for id in &ids { let l = layer_mut(proj, cid, *id)?; l.environment = on; if on { l.switches.three_d = true; } } Ok(()) })?; Ok(json!({"environment": on, "layers": ids.iter().map(|l| l.0).collect::>()})) } // ---------------------------------------------------------------- materials fn material_set(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; if ids.is_empty() { return Err(bad("material.set", "no `layers` given and no layer selected")); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = super::time_p(s, p, Some(&comp)); let n = s.edit("Material Options", None, |proj, _| { let mut n = 0; for id in &ids { n += apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?; } Ok(n) })?; if n == 0 { return Err(bad( "material.set", "no Material Options property matched (castsShadows, acceptsShadows, acceptsLights, baseColor, metallic, roughness, …)", )); } Ok(json!({"set": n})) } /// Fresh Material Options for a layer. fn default_material(next: &mut u64, l: &Layer) -> Option { let ids = &mut Ids(next); match l.source { LayerSource::Primitive { .. } => Some(build::model_material_options(ids, true)), LayerSource::Model { .. } => Some(build::model_material_options(ids, false)), _ if l.source.is_av() => Some(build::material_options(ids)), _ => None, } } fn replace_material(l: &mut Layer, g: PropGroup) { match l.props.children.iter_mut().find(|c| c.match_id() == "materialOptions") { Some(slot) => *slot = Node::Group(g), None => l.props.children.push(g.into()), } } fn material_reset(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; if ids.is_empty() { return Err(bad("material.reset", "no `layers` given and no layer selected")); } s.edit("Reset Material", None, |proj, _| { let mut next = proj.next_id; for id in &ids { let l = layer_mut(proj, cid, *id)?; if let Some(g) = default_material(&mut next, l) { replace_material(l, g); } } proj.next_id = next; Ok(()) })?; Ok(json!({"layers": ids.iter().map(|l| l.0).collect::>()})) } /// Duplicate and Assign Material: the first layer's Material Options are copied onto the other /// selected layers of the same kind. fn material_duplicate(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; if ids.len() < 2 { return Err(bad("material.duplicateAssign", "select the source layer first, then the layers to assign its material to")); } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let src = comp .layer(ids[0]) .and_then(|l| l.props.sub("materialOptions")) .cloned() .ok_or_else(|| bad("material.duplicateAssign", "the first layer has no material"))?; let n = s.edit("Duplicate and Assign Material", None, |proj, _| { let mut n = 0; for id in &ids[1..] { let l = layer_mut(proj, cid, *id)?; let Some(dst) = l.props.sub("materialOptions") else { continue }; // Property sets differ (models, primitives, cards): copy the values they share. let mut g = dst.clone(); for c in g.children.iter_mut() { if let Node::Prop(d) = c && let Some(sv) = src.get(&d.match_id) { d.value = sv.value.clone(); d.keys = sv.keys.clone(); d.expr = sv.expr.clone(); } } replace_material(l, g); n += 1; } Ok(n) })?; Ok(json!({"assigned": n})) } fn material_reveal(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "material.revealSource")?; let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; let LayerSource::Model { item } = l.source else { return Err(bad("material.revealSource", "the layer's material is its own (not from a model file)")) }; s.state.project_selection = vec![item]; Ok(json!({"item": item.0})) } fn has_model_or_layers(s: &Session) -> std::result::Result<(), String> { has_layers(s) } pub fn specs() -> Vec { vec![ cmd!( "layer.newModel", "3D Model Layer", [], None, "{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D", has_comp, new_model ), cmd!( "layer.new3dPrimitive", "3D Primitive", [], None, "{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D", has_comp, new_primitive ), cmd!( "layer.newPrimitive", "New 3D Primitive", [], None, "{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D", has_comp, new_primitive ), cmd!("comp.renderer", "Renderer", [], None, "{renderer?: advanced3d|classic3d (omit to read), value?}", has_comp, comp_renderer), cmd!("layer.environment", "Environment Layer", ["Layer"], None, "{layers?, on?}", has_layers, environment), cmd!( "material.set", "Material Options", [], None, "{layers?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections?, lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal?, baseColor?, metallic?, roughness?, emissive?, time?}", has_layers, material_set ), cmd!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], None, "{layer?}", has_layers, material_reveal), cmd!("material.reset", "Reset Material", ["Layer", "Material"], None, "{layers?}", has_model_or_layers, material_reset), cmd!( "material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], None, "{layers? (first = source)}", has_layers, material_duplicate ), ] }