diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bc486a0c..aa2e4ffe 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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"} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 2c0cbb0f..736a8660 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2762188a..a0b73d67 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,6 +72,7 @@ pub enum Dialog { ViewOptions, CameraSettings, LightSettings, + NewPrimitive, KeyVelocity, KeyInterpolation, TimeStretch, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 36a85aef..764432e7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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, ¶ms)? { return Ok(Value::Null); } + // New 3D Primitive without parameters opens its dialog. + if crate::panels::primitive_dialog::route(app, id, ¶ms)? { + 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, ¶ms)? { return Ok(Value::Null); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc43..f4413d14 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3aa9a19c..f65b3970 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/primitive_dialog.rs b/crates/ui-egui/src/panels/primitive_dialog.rs new file mode 100644 index 00000000..a3483a97 --- /dev/null +++ b/crates/ui-egui/src/panels/primitive_dialog.rs @@ -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, + /// 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 { + 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 { + if id != "layer.newPrimitive" || !params.as_object().is_none_or(|m| m.is_empty()) { + return Ok(false); + } + open(app).map(|_| true) +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 7dc15334..23637c2b 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -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; diff --git a/crates/ui-egui/tests/it/ui_new_primitive.rs b/crates/ui-egui/tests/it/ui_new_primitive.rs new file mode 100644 index 00000000..fbb3a34e --- /dev/null +++ b/crates/ui-egui/tests/it/ui_new_primitive.rs @@ -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 { + 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"); + }); +}