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:
Minher0 committed 2026-10-25 13:22:14 +00:00
1 parent 601a15bc1f
commit b50e74f295
2 files changed
+114 -43

No files matched your search

+60 -43
View File
@@ -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})"))),
}
}
+54
View File
@@ -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();