mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
Imported GLB/glTF models were invisible in the Composition viewer while
text showed. Only the Advanced 3D renderer draws model and primitive
layers, and new compositions (and New Comp from Selection) are Classic
3D by default, but adding a model layer left the comp's renderer alone,
so the model drew nothing on every backend. It wasn't the reporter's
GPU: on this machine the viewer showed nothing for a GLB in a new comp
with the GPU compositor (Vulkan) and with Mercury Software Only, and
the model in both once the comp used Advanced 3D.
As After Effects documents ("Advanced 3D gets automatically selected
when you add a 3D model to the composition"), layer.newModel /
layer.addItem of a model and layer.newPrimitive now switch a Classic
3D comp to Advanced 3D inside the same edit (one undo step, Geometry
Options added to its text and shape layers like Composition Settings
does) and toast that the comp changed renderer. Switching a comp that
has model layers back to Classic 3D toasts why they disappear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
425 lines
18 KiB
Rust
425 lines
18 KiB
Rust
//! 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<Renderer> {
|
|
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<Value> {
|
|
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<bool> {
|
|
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<Value> {
|
|
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<PrimitiveKind> {
|
|
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<usize> {
|
|
let mut n = 0;
|
|
let tri = |v: &Value| -> Option<u32> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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::<Vec<_>>()}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------- materials
|
|
|
|
fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
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<PropGroup> {
|
|
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<Value> {
|
|
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::<Vec<_>>()}))
|
|
}
|
|
|
|
/// 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<Value> {
|
|
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<Value> {
|
|
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<CommandSpec> {
|
|
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
|
|
),
|
|
]
|
|
}
|