mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 15:48:33 +00:00
layer.create: every instance3d copy in one undo step
The op ran Session::edit once per selected layer, so each copy was a history step of its own: undoing removed them one at a time. Collect the eligible models first and create all the copies in a single edit — one undo (or redo) removes (or restores) them all, and each copy keeps a name of its own.
This commit is contained in:
1 parent
601a15bc1f
commit
b50e74f295
2 files changed
+114
-43
No files matched your search
@@ -22,6 +22,66 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let time = s.time();
|
||||
let mut created = vec![];
|
||||
let mut skipped = vec![];
|
||||
// All the selected models in one edit: one undo (or redo) removes (or restores) every
|
||||
// instance at once, as one command in the History panel.
|
||||
if op == "instance3d" {
|
||||
// After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take
|
||||
// no masks or effects, so a comp-sized solid goes at the top of the layer stack
|
||||
// with CC Composite referencing the model — a 2D copy of its render, through the
|
||||
// comp's camera, that 2D effects can be applied to. The model is switched off,
|
||||
// as After Effects does (back on to keep its shadow).
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let mut eligible = vec![];
|
||||
for lid in &lids {
|
||||
match comp.layer(*lid) {
|
||||
Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())),
|
||||
Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if !eligible.is_empty() {
|
||||
let ids = s.edit("Create 3D Layer Instance", None, |proj, st| {
|
||||
let mut ids = vec![];
|
||||
for (lid, src) in &eligible {
|
||||
let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name));
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let sid = proj.add_item(
|
||||
&name,
|
||||
src.label,
|
||||
Some(folder),
|
||||
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
|
||||
);
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
|
||||
// CC Composite referencing the 3D layer: the solid shows its render (the 2D
|
||||
// copy).
|
||||
let fx = effectcraft_effects::find("ec.channel.cccomposite").ok_or_else(|| bad("layer.create", "the CC Composite effect is missing"))?;
|
||||
let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]);
|
||||
if let Some(pr) = g.get_mut("layer") {
|
||||
pr.value = KV::Layer(Some(lid.0));
|
||||
}
|
||||
if let Some(fxg) = l.props.sub_mut("effects") {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
// The model is switched off (After Effects turns the referenced layer off);
|
||||
// the copies go at the top of the stack, selected.
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) {
|
||||
m.switches.video = false;
|
||||
}
|
||||
let id = l.id;
|
||||
c.layers.insert(0, l);
|
||||
ids.push(id);
|
||||
}
|
||||
st.selected_layers = ids.clone();
|
||||
Ok(ids)
|
||||
})?;
|
||||
created.extend(ids.iter().map(|i| i.0));
|
||||
}
|
||||
if created.is_empty() && !skipped.is_empty() {
|
||||
return Err(bad("layer.create", skipped.join("; ")));
|
||||
}
|
||||
return Ok(json!({"layers": created, "skipped": skipped}));
|
||||
}
|
||||
for lid in lids {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let Some(src) = comp.layer(lid).cloned() else { continue };
|
||||
@@ -154,49 +214,6 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
created.push(id.0);
|
||||
}
|
||||
"instance3d" => {
|
||||
// After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take
|
||||
// no masks or effects, so a comp-sized solid goes at the top of the layer stack
|
||||
// with CC Composite referencing the model — a 2D copy of its render, through the
|
||||
// comp's camera, that 2D effects can be applied to. The model is switched off,
|
||||
// as After Effects does (back on to keep its shadow).
|
||||
if !src.source.is_model() {
|
||||
skipped.push(format!("{}: not a 3D model layer", src.name));
|
||||
continue;
|
||||
}
|
||||
let id = s.edit("Create 3D Layer Instance", None, |proj, st| {
|
||||
let name = comp.unique_layer_name(&format!("{} Instance", src.name));
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let sid = proj.add_item(
|
||||
&name,
|
||||
src.label,
|
||||
Some(folder),
|
||||
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
|
||||
);
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
|
||||
// CC Composite referencing the 3D layer: the solid shows its render (the 2D
|
||||
// copy).
|
||||
let fx = effectcraft_effects::find("ec.channel.cccomposite").ok_or_else(|| bad("layer.create", "the CC Composite effect is missing"))?;
|
||||
let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]);
|
||||
if let Some(pr) = g.get_mut("layer") {
|
||||
pr.value = KV::Layer(Some(lid.0));
|
||||
}
|
||||
if let Some(fxg) = l.props.sub_mut("effects") {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
// The model is switched off (After Effects turns the referenced layer off);
|
||||
// the copy goes at the top of the stack, selected.
|
||||
if let Some(m) = c.layers.iter_mut().find(|x| x.id == lid) {
|
||||
m.switches.video = false;
|
||||
}
|
||||
let id = l.id;
|
||||
c.layers.insert(0, l);
|
||||
st.selected_layers = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
created.push(id.0);
|
||||
}
|
||||
other => return Err(bad("layer.create", format!("`{other}` is not available yet (supported: {OPS})"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,60 @@ fn instance3d_needs_a_3d_layer() {
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_creates_every_selected_copy_in_one_undo() {
|
||||
let mut s = session();
|
||||
let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap();
|
||||
let copies = r["layers"].as_array().unwrap();
|
||||
assert_eq!(copies.len(), 2);
|
||||
let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap());
|
||||
let comp = cid(&s);
|
||||
assert_ne!(ia, ib);
|
||||
assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own");
|
||||
// Both sources are off, both copies selected, above their sources in the stack.
|
||||
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
|
||||
let mut sel = s.state.selected_layers.clone();
|
||||
let mut want = vec![LayerId(ia), LayerId(ib)];
|
||||
sel.sort_by_key(|l| l.0);
|
||||
want.sort_by_key(|l| l.0);
|
||||
assert_eq!(sel, want);
|
||||
let c = s.active_comp().unwrap();
|
||||
let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap();
|
||||
assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources");
|
||||
// One undo removes both copies and switches both models back on; one redo returns them.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance");
|
||||
assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance");
|
||||
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_in_a_mixed_selection_only_takes_the_models() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap();
|
||||
let copies = r["layers"].as_array().unwrap();
|
||||
assert_eq!(copies.len(), 1, "only the model gets a copy: {r}");
|
||||
let copy = copies[0].as_u64().unwrap();
|
||||
let comp = cid(&s);
|
||||
assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off");
|
||||
assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched");
|
||||
let skipped = r["skipped"].as_array().unwrap();
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}");
|
||||
// The copy references the model, not the solid.
|
||||
let l = layer_in(&s, comp, copy);
|
||||
let g = l.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_shows_nothing_outside_the_source_layer_range() {
|
||||
let mut s = session();
|
||||
|
||||
Reference in new issue
Block a user