Merge #2050: 3D: add a New 3D Primitive dialog to Layer > New (@tylerjenningsw)

This commit is contained in:
aiongg
2026-10-23 23:33:19 +00:00
9 changed files with 372 additions and 0 deletions
+1
View File
@@ -733,6 +733,7 @@ Layer
Shape Layer | layer.newShape
Adjustment Layer | layer.newAdjustment
Content-Aware Fill Layer... | layer.newContentAwareFill
3D Primitive... | layer.newPrimitive
Cube | layer.new3dPrimitive {"kind":"cube"}
Sphere | layer.new3dPrimitive {"kind":"sphere"}
Plane | layer.new3dPrimitive {"kind":"plane"}
+10
View File
@@ -588,6 +588,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "形状图层"),
("layer.newAdjustment", "Adjustment Layer", "调整图层"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "内容识别填充图层..."),
("layer.newPrimitive", "3D Primitive...", "3D 基本体..."),
("layer.new3dPrimitive", "Cube", "立方体"),
("layer.new3dPrimitive", "Sphere", "球体"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -1222,6 +1223,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "形狀圖層"),
("layer.newAdjustment", "Adjustment Layer", "調整圖層"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "內容感知填滿圖層..."),
("layer.newPrimitive", "3D Primitive...", "3D 基本體..."),
("layer.new3dPrimitive", "Cube", "立方體"),
("layer.new3dPrimitive", "Sphere", "球體"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -1853,6 +1855,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "シェイプレイヤー"),
("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."),
("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."),
("layer.new3dPrimitive", "Cube", "立方体"),
("layer.new3dPrimitive", "Sphere", "球体"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -2487,6 +2490,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Шар фігури"),
("layer.newAdjustment", "Adjustment Layer", "Коригувальний шар"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Шар заливки з урахуванням вмісту..."),
("layer.newPrimitive", "3D Primitive...", "3D-примітив..."),
("layer.new3dPrimitive", "Cube", "Куб"),
("layer.new3dPrimitive", "Sphere", "Сфера"),
("layer.new3dPrimitive", "Plane", "Площина"),
@@ -3122,6 +3126,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Capa de forma"),
("layer.newAdjustment", "Adjustment Layer", "Capa de ajuste"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Capa de relleno según el contenido..."),
("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."),
("layer.new3dPrimitive", "Cube", "Cubo"),
("layer.new3dPrimitive", "Sphere", "Esfera"),
("layer.new3dPrimitive", "Plane", "Plano"),
@@ -3755,6 +3760,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Camada de forma"),
("layer.newAdjustment", "Adjustment Layer", "Camada de ajuste"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Camada de preenchimento sensível ao conteúdo..."),
("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."),
("layer.new3dPrimitive", "Cube", "Cubo"),
("layer.new3dPrimitive", "Sphere", "Esfera"),
("layer.new3dPrimitive", "Plane", "Plano"),
@@ -4395,6 +4401,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Слой-фигура"),
("layer.newAdjustment", "Adjustment Layer", "Корректирующий слой"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Слой заливки с учетом содержимого..."),
("layer.newPrimitive", "3D Primitive...", "3D-примитив..."),
("layer.new3dPrimitive", "Cube", "Куб"),
("layer.new3dPrimitive", "Sphere", "Сфера"),
("layer.new3dPrimitive", "Plane", "Плоскость"),
@@ -5028,6 +5035,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Calque de forme"),
("layer.newAdjustment", "Adjustment Layer", "Calque de réglage"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Calque de remplissage d’après le contenu..."),
("layer.newPrimitive", "3D Primitive...", "Primitive 3D..."),
("layer.new3dPrimitive", "Cube", "Cube"),
("layer.new3dPrimitive", "Sphere", "Sphère"),
("layer.new3dPrimitive", "Plane", "Plan"),
@@ -5661,6 +5669,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Formebene"),
("layer.newAdjustment", "Adjustment Layer", "Einstellungsebene"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Ebene für inhaltsbasierte Füllung..."),
("layer.newPrimitive", "3D Primitive...", "3D-Grundkörper..."),
("layer.new3dPrimitive", "Cube", "Würfel"),
("layer.new3dPrimitive", "Sphere", "Kugel"),
("layer.new3dPrimitive", "Plane", "Fläche"),
@@ -6295,6 +6304,7 @@ const CZECH: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "Vrstva tvaru"),
("layer.newAdjustment", "Adjustment Layer", "Vrstva úprav"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Vrstva výplně podle obsahu..."),
("layer.newPrimitive", "3D Primitive...", "3D primitivum..."),
("layer.new3dPrimitive", "Cube", "Krychle"),
("layer.new3dPrimitive", "Sphere", "Koule"),
("layer.new3dPrimitive", "Plane", "Rovina"),
+1
View File
@@ -72,6 +72,7 @@ pub enum Dialog {
ViewOptions,
CameraSettings,
LightSettings,
NewPrimitive,
KeyVelocity,
KeyInterpolation,
TimeStretch,
+4
View File
@@ -399,6 +399,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);
}
// New 3D Primitive without parameters opens its dialog.
if crate::panels::primitive_dialog::route(app, id, &params)? {
return Ok(Value::Null);
}
// Layer Settings on a solid, adjustment layer or null opens its settings dialog.
if crate::panels::dialogs::route_layer_settings(app, id, &params)? {
return Ok(Value::Null);
+2
View File
@@ -47,6 +47,7 @@ pub struct DialogState {
pub info: (String, String),
pub camera: super::dialogs_3d::CameraDraft,
pub light: super::dialogs_3d::LightDraft,
pub primitive: super::primitive_dialog::PrimitiveDraft,
pub velocity: super::key_dialogs::VelocityDraft,
pub interp: super::key_dialogs::InterpDraft,
pub stretch: super::key_dialogs::StretchDraft,
@@ -248,6 +249,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),
Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t),
Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t),
Dialog::NewPrimitive => super::primitive_dialog::dialog(app, ctx, &t),
Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t),
Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t),
Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t),
+1
View File
@@ -35,6 +35,7 @@ pub mod paint_panels;
pub mod panel_kit;
pub mod path_vr_panels;
pub mod precomp;
pub mod primitive_dialog;
pub mod project;
pub mod properties;
pub mod puppet_tool;
@@ -0,0 +1,125 @@
//! New 3D Primitive dialog (Layer ▸ New ▸ 3D Primitive…): the primitive's type, name and
//! dimensions. OK runs `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D;
//! the layer's Geometry Options edit it afterwards.
use std::collections::BTreeMap;
use crate::i18n::tr;
use effectcraft_engine::project::build::{Ids, primitive_geometry_options};
use effectcraft_engine::project::{ItemId, ParamUi, PrimitiveKind, Property};
use egui::{Color32, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
#[derive(Clone, Debug, Default)]
pub struct PrimitiveDraft {
pub kind: PrimitiveKind,
pub name: String,
/// Whether the name was typed; until then it follows the type.
pub named: bool,
/// Dimensions typed, by Geometry Options match id, kept when the type changes (a sphere's
/// radius carries over to a cone); the rest are the type's defaults.
pub dims: BTreeMap<String, f64>,
/// The project (its epoch) and comp the dialog was opened for.
pub target: Option<(u64, ItemId)>,
}
fn target(app: &EffectcraftApp) -> Option<(u64, ItemId)> {
Some((app.session.script_ui.project_epoch, app.session.active_comp_id()?))
}
/// The dimensions a primitive type has: its Geometry Options as the engine builds them.
fn dimensions(kind: PrimitiveKind) -> Vec<Property> {
let mut next = 0;
primitive_geometry_options(&mut Ids(&mut next), kind).props().cloned().collect()
}
/// Open the dialog for a new primitive in the active comp.
pub fn open(app: &mut EffectcraftApp) -> Result<(), String> {
let target = Some(target(app).ok_or("no composition is open")?);
let kind = PrimitiveKind::Cube;
app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new(), target };
app.dialog = Some(Dialog::NewPrimitive);
Ok(())
}
pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut d = app.dialog_state.primitive.clone();
// A project opened or another comp made active since (by a script or the control channel):
// the primitive would land somewhere the dialog was not opened for.
if target(app) != d.target {
app.ui.status = "New 3D Primitive closed: the composition changed".into();
app.dialog = None;
return;
}
let (mut ok, mut close) = (false, false);
super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| {
egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
ui.label(tr("Type"));
let combo = egui::ComboBox::from_id_salt("primitive-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
for (i, k) in PrimitiveKind::ALL.into_iter().enumerate() {
let item = ui.selectable_value(&mut d.kind, k, tr(k.label()));
app.auto.add(&format!("dialog.primitive.typeItem.{i}"), item.rect, k.label());
}
});
app.auto.add_response("dialog.primitive.type", &combo.response, "Type");
if !d.named {
d.name = d.kind.label().to_string();
}
ui.end_row();
ui.label(tr("Name"));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0));
d.named |= r.changed();
app.auto.add_response("dialog.primitive.name", &r, "Name");
ui.end_row();
for p in dimensions(d.kind) {
let ParamUi::Slider { min, max, decimals, .. } = p.ui else { continue };
let mut v = d.dims.get(&p.match_id).copied().unwrap_or(p.value.as_f64());
ui.label(tr(&p.name));
// Counts (segments, rings) are whole numbers; sizes are pixels.
let suffix = if decimals == 0 { "" } else { " px" };
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut v).range(min..=max).max_decimals(decimals as usize).suffix(suffix)));
app.auto.add_response(&format!("dialog.primitive.{}", p.match_id), &r, &p.name);
if r.changed() {
d.dims.insert(p.match_id, v);
}
ui.end_row();
}
});
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
app.auto.add("dialog.primitive.ok", b.rect, "OK");
ok |= b.clicked();
let c = ui.button(tr("Cancel"));
app.auto.add("dialog.primitive.cancel", c.rect, "Cancel");
close |= c.clicked();
});
});
app.dialog_state.primitive = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
let mut p = json!({"kind": d.kind.label().to_ascii_lowercase(), "name": d.name});
for dim in dimensions(d.kind) {
if let Some(v) = d.dims.get(&dim.match_id) {
p[dim.match_id.as_str()] = json!(v);
}
}
if let Err(e) = app.session.execute("layer.newPrimitive", p) {
app.ui.status = e.to_string();
}
close = true;
}
if close {
app.dialog = None;
}
}
/// Menu routing: New 3D Primitive without parameters opens the dialog. Returns true when it did.
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != "layer.newPrimitive" || !params.as_object().is_none_or(|m| m.is_empty()) {
return Ok(false);
}
open(app).map(|_| true)
}
+1
View File
@@ -39,6 +39,7 @@ mod ui_locked_color;
mod ui_media_browser_cache;
mod ui_media_browser_copy_path;
mod ui_menus;
mod ui_new_primitive;
mod ui_object_matte;
mod ui_panel_focus;
mod ui_panel_windows;
+227
View File
@@ -0,0 +1,227 @@
//! Layer ▸ New ▸ 3D Primitive… (#1734): the dialog picks a type, name and dimensions and runs
//! `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D in the same step.
use effectcraft_engine::Session;
use effectcraft_engine::project::{Layer, LayerSource, PrimitiveKind, Renderer};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.step();
h
}
/// An automation element's rect in the last frame drawn (a dialog settles its place in its
/// first frames).
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<[f32; 4]> {
let auto = &h.state().auto;
auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).map(|e| e.rect)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let [x, y, w, hh] = rect(h, id).unwrap_or_else(|| panic!("{id} is registered"));
let p = egui::pos2(x + w / 2.0, y + hh / 2.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
}
h.run_steps(2);
}
/// Types `text` over a field's contents and leaves it with Tab.
fn type_into(h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str) {
click(h, id);
assert!(h.ctx.egui_wants_keyboard_input(), "{id} has focus");
key(h, Key::A, Modifiers::COMMAND);
h.input_mut().events.push(Event::Text(text.into()));
h.step();
key(h, Key::Tab, Modifiers::NONE);
}
/// Layer ▸ New ▸ 3D Primitive…, as the menu runs it.
fn open(h: &mut Harness<'_, EffectcraftApp>) {
let (path, entry) =
effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.label == "3D Primitive...").expect("Layer ▸ New lists 3D Primitive...");
assert_eq!(path, ["Layer", "New"]);
assert_eq!(entry.command, "layer.newPrimitive");
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, &entry.command, entry.params_or_empty()).unwrap();
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::NewPrimitive));
}
fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) {
click(h, "dialog.primitive.type");
let i = PrimitiveKind::ALL.iter().position(|k| *k == kind).unwrap();
click(h, &format!("dialog.primitive.typeItem.{i}"));
}
fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec<Layer> {
h.state().session.active_comp().unwrap().layers.clone()
}
fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer {
h.state().session.active_comp().unwrap().renderer
}
fn geometry(l: &Layer, id: &str) -> f64 {
l.props.prop(&format!("geometryOptions/{id}")).unwrap_or_else(|| panic!("{id}")).value.as_f64()
}
/// A torus named "Ring" with a 250 px radius, the rest left at their defaults.
fn create_ring(h: &mut Harness<'_, EffectcraftApp>) {
open(h);
choose(h, PrimitiveKind::Torus);
type_into(h, "dialog.primitive.name", "Ring");
type_into(h, "dialog.primitive.radius", "250");
click(h, "dialog.primitive.ok");
assert_eq!(h.state().dialog, None);
}
fn assert_ring(l: &Layer) {
assert_eq!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Torus });
assert_eq!(l.name, "Ring");
assert_eq!(geometry(l, "radius"), 250.0);
assert_eq!(geometry(l, "tubeRadius"), 50.0);
}
#[test]
fn creates_a_primitive_in_a_classic_3d_comp_in_one_undoable_step() {
let mut h = harness();
assert_eq!(renderer(&h), Renderer::Classic3D);
let steps = h.state().session.history.undo.len();
create_ring(&mut h);
let ls = layers(&h);
assert_eq!(ls.len(), 1);
assert_ring(&ls[0]);
assert_eq!(renderer(&h), Renderer::Advanced3D, "the comp switches to Advanced 3D");
assert_eq!(h.state().session.history.undo.len(), steps + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(layers(&h).is_empty());
assert_eq!(renderer(&h), Renderer::Classic3D, "undo restores the renderer too");
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
assert_ring(&layers(&h)[0]);
assert_eq!(renderer(&h), Renderer::Advanced3D);
}
#[test]
fn cancel_creates_nothing() {
let mut h = harness();
let steps = h.state().session.history.undo.len();
open(&mut h);
choose(&mut h, PrimitiveKind::Sphere);
type_into(&mut h, "dialog.primitive.radius", "80");
click(&mut h, "dialog.primitive.cancel");
assert_eq!(h.state().dialog, None);
assert!(layers(&h).is_empty());
assert_eq!(renderer(&h), Renderer::Classic3D);
assert_eq!(h.state().session.history.undo.len(), steps);
}
#[test]
fn each_type_offers_the_dimensions_its_layer_has() {
let mut h = harness();
for kind in PrimitiveKind::ALL {
open(&mut h);
choose(&mut h, kind);
click(&mut h, "dialog.primitive.ok");
let l = layers(&h).into_iter().find(|l| l.source == LayerSource::Primitive { kind }).unwrap();
assert_eq!(l.name, kind.label(), "the name follows the type until typed");
let group = l.props.group("geometryOptions").unwrap();
let ids: Vec<&str> = group.props().map(|p| p.match_id.as_str()).collect();
open(&mut h);
choose(&mut h, kind);
let controls = ["type", "name", "ok", "cancel"];
let shown: Vec<&str> = (h.state().auto.elements.iter())
.filter_map(|e| e.id.strip_prefix("dialog.primitive."))
.filter(|id| !controls.contains(id) && !id.starts_with("typeItem."))
.collect();
assert_eq!(shown, ids, "{} dimensions", kind.label());
click(&mut h, "dialog.primitive.cancel");
}
}
#[test]
fn a_created_primitive_survives_save_and_reopen() {
let mut h = harness();
create_ring(&mut h);
let path = std::env::temp_dir().join(format!("ec-ui-new-primitive-{}.ecproj", std::process::id()));
h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let mut o = Session::default();
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
let (_, comp) = o.project.comps().next().unwrap();
assert_eq!(comp.renderer, Renderer::Advanced3D);
assert_ring(&comp.layers[0]);
}
#[test]
fn untouched_dimensions_take_each_types_defaults_and_typed_ones_carry_over() {
let mut h = harness();
open(&mut h);
choose(&mut h, PrimitiveKind::Plane);
click(&mut h, "dialog.primitive.ok");
let plane = layers(&h).remove(0);
assert_eq!(geometry(&plane, "width"), 400.0, "a plane's default, not the cube's 300");
open(&mut h);
type_into(&mut h, "dialog.primitive.width", "120");
choose(&mut h, PrimitiveKind::Plane);
click(&mut h, "dialog.primitive.ok");
let plane = layers(&h).into_iter().find(|l| l.id != plane.id).unwrap();
assert_eq!(geometry(&plane, "width"), 120.0, "the typed width carries over");
assert_eq!(geometry(&plane, "height"), 400.0);
}
/// A dialog left open while something else changes the editing context closes without creating
/// anything rather than adding the primitive where it was not opened.
fn closes_when(change: impl FnOnce(&mut Session)) {
let mut h = harness();
let a = h.state().session.active_comp_id().unwrap();
open(&mut h);
choose(&mut h, PrimitiveKind::Torus);
change(&mut h.state_mut().session);
let b = h.state().session.active_comp_id().unwrap();
let steps = h.state().session.history.undo.len();
h.run_steps(2);
assert_eq!(h.state().dialog, None, "the dialog closes");
assert!(rect(&h, "dialog.primitive.ok").is_none());
let s = &h.state().session;
for c in [a, b] {
if let Some(c) = s.project.comp(c) {
assert!(c.layers.is_empty(), "nothing is created");
assert_eq!(c.renderer, Renderer::Classic3D);
}
}
assert_eq!(s.history.undo.len(), steps);
assert!(h.state().ui.status.contains("changed"), "{}", h.state().ui.status);
}
#[test]
fn another_comp_made_active_closes_the_dialog() {
closes_when(|s| {
s.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 2})).unwrap();
});
}
#[test]
fn a_replaced_project_closes_the_dialog_even_when_the_comp_id_is_reused() {
closes_when(|s| {
let before = s.active_comp_id().unwrap();
s.execute("file.newProject", json!({})).unwrap();
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap();
assert_eq!(s.active_comp_id(), Some(before), "the new project reuses the comp's id");
});
}