From 16a5fa7a83c9e961f1c820c49dcf06958ab9735d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:01:37 -0400 Subject: [PATCH] =?UTF-8?q?M7.2:=203D=20viewer=20=E2=80=94=203D=20views,?= =?UTF-8?q?=20camera=20tools,=20camera/light=20wireframes,=20Camera/Light?= =?UTF-8?q?=20Settings=20dialogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Viewer renders through the comp's 3D view (RenderOpts.view; FrameKey hashes the view camera), shows the view name, and the bottom-bar 3D View dropdown (automation id viewer.view3d) switches Active Camera/Front/…/Custom View 3. - Orbit / Pan / Dolly tools drag the current view (camera layer in Active Camera, merged into one undo step; view cameras otherwise). - Handles, gizmo and picking go through the view camera; 3D layers move in the view plane at their depth. - Camera frustum + POI and light position/direction wireframes (viewer.camera., viewer.light.). - Camera Settings / Light Settings dialogs open from New Camera/Light, Camera/Light Settings and Layer Settings; OK runs the engine commands. - Timeline: POI only for two-node cameras/auto-oriented lights; "Z Rotation" label on 3D layers. - Headless egui_kittest tests (UI wiring + opt-in wgpu snapshots); fix the remaining workspace clippy lints. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft/src/main.rs | 8 +- crates/ui-egui/src/frames.rs | 2 + crates/ui-egui/src/lib.rs | 21 +- crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/panels/dialogs.rs | 10 +- crates/ui-egui/src/panels/dialogs_3d.rs | 378 ++++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/project.rs | 6 +- crates/ui-egui/src/panels/timeline.rs | 20 +- crates/ui-egui/src/panels/viewer.rs | 242 +++++++++++++-- crates/ui-egui/tests/ui_3d.rs | 118 ++++++++ 11 files changed, 762 insertions(+), 48 deletions(-) create mode 100644 crates/ui-egui/src/panels/dialogs_3d.rs create mode 100644 crates/ui-egui/tests/ui_3d.rs diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 6ee51c56d..c0894e8cb 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -54,10 +54,10 @@ fn main() -> eframe::Result { let _ = session.execute("file.openDemoProject", json!({})); } let media: Vec = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect(); - if !media.is_empty() { - if let Err(e) = session.execute("file.import", json!({"paths": media})) { - eprintln!("effectcraft: {e}"); - } + if !media.is_empty() + && let Err(e) = session.execute("file.import", json!({"paths": media})) + { + eprintln!("effectcraft: {e}"); } let mut app = EffectcraftApp::new(session); app.ui.start_screen = home; diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 3f20b2ec2..a60f74e8e 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -18,6 +18,8 @@ pub struct FrameKey { pub frame: i64, /// Render scale × 1000. pub scale: u32, + /// Hash of the 3D view camera (0 = the comp's active camera). + pub view: u64, } struct Cache { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da11723e8..a8e3f0851 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,8 @@ pub enum Dialog { CompSettings, SolidSettings, CommandPalette, + CameraSettings, + LightSettings, } /// Host hooks provided by the native app (file pickers etc.). @@ -175,7 +177,22 @@ impl EffectcraftApp { } pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey { - FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32 } + FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32, view: self.view_hash(comp) } + } + + /// Hash of the comp viewer's 3D view camera (0 for the active camera view). + pub fn view_hash(&self, comp: ItemId) -> u64 { + use std::hash::{Hash, Hasher}; + let Some(cam) = self.session.view_camera(comp) else { return 0 }; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for row in cam.view.0 { + for v in row { + v.to_bits().hash(&mut h); + } + } + cam.zoom.to_bits().hash(&mut h); + cam.ortho.hash(&mut h); + h.finish() | 1 } /// Request a frame render (no-op if cached/in flight). @@ -183,7 +200,7 @@ impl EffectcraftApp { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); - let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: None }; + let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: self.session.view_camera(comp) }; self.frames.request(&self.render_source(), key, comp, t, opts); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 321cee162..00e95c378 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -121,6 +121,10 @@ fn reveal(app: &mut EffectcraftApp, kind: &str) { /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { let now = ctx.input(|i| i.time); + // New Camera/Light and Camera/Light Settings without parameters open their dialogs. + if crate::panels::dialogs_3d::route(app, id, ¶ms)? { + return Ok(Value::Null); + } if let Some(rest) = id.strip_prefix("window.panel.") { let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?; app.show_panel(p); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 1599715f2..a189d496d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1,5 +1,5 @@ //! Modal dialogs: About (with community links), New Composition / Composition Settings, Solid -//! Settings and the command palette. +//! Settings and the command palette (Camera/Light Settings live in `dialogs_3d`). use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -34,6 +34,8 @@ pub struct DialogState { pub solid_size: [u32; 2], pub palette_query: String, pub palette_sel: usize, + pub camera: super::dialogs_3d::CameraDraft, + pub light: super::dialogs_3d::LightDraft, } pub fn open_new_comp(app: &mut EffectcraftApp) { @@ -72,7 +74,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { Ok(()) } -fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { +pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { // Dim the app. let screen = ctx.content_rect(); ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("modal-dim"))).rect_filled(screen, 0.0, Color32::from_black_alpha(120)); @@ -95,6 +97,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings), Dialog::SolidSettings => solid(app, ctx, &t), Dialog::CommandPalette => palette(app, ctx, &t), + Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t), + Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t), } } @@ -262,7 +266,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } } -fn ui_enter(ctx: &egui::Context) -> bool { +pub(crate) fn ui_enter(ctx: &egui::Context) -> bool { !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) } diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs new file mode 100644 index 000000000..87fc16610 --- /dev/null +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -0,0 +1,378 @@ +//! Camera Settings and Light Settings dialogs (Layer ▸ New ▸ Camera/Light, Layer ▸ Camera/Light +//! Settings, Layer Settings on a camera or light). OK runs `layer.newCamera` / `layer.newLight` +//! or `layer.cameraSettings` / `layer.lightSettings` with the dialog's values. + +use effectcraft_engine::project::{AutoOrient, LayerSource, LightKind}; +use effectcraft_engine::render::three_d::camera::{PRESETS, angle_of_view, default_aperture, focal_for_zoom, zoom_for_focal}; +use egui::{Color32, vec2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +#[derive(Clone, Debug, Default)] +pub struct CameraDraft { + /// Existing camera layer (Camera Settings) or None (New Camera). + pub layer: Option, + pub name: String, + pub two_node: bool, + pub zoom: f64, + pub dof: bool, + pub focus: f64, + pub lock_to_zoom: bool, + pub aperture: f64, + pub blur: f64, +} + +#[derive(Clone, Debug, Default)] +pub struct LightDraft { + pub layer: Option, + pub name: String, + pub kind: usize, + pub color: [f32; 3], + pub intensity: f64, + pub cone_angle: f64, + pub cone_feather: f64, + pub falloff: usize, + pub radius: f64, + pub falloff_distance: f64, + pub casts_shadows: bool, + pub shadow_darkness: f64, + pub shadow_diffusion: f64, +} + +const KINDS: [LightKind; 4] = [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient]; +const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; + +fn comp_w(app: &EffectcraftApp) -> f64 { + app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0) +} + +/// Open Camera Settings: for `layer` (existing) or a new camera. +pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), String> { + let comp = app.session.active_comp().ok_or("no composition is open")?.clone(); + let w = comp.width as f64; + let t = app.session.time(); + let d = match layer.and_then(|id| comp.layer(effectcraft_engine::project::LayerId(id))) { + Some(l) => { + let f = |p: &str, d: f64| l.props.prop(p).map(|pr| pr.value_at(l.layer_time(t)).as_f64()).unwrap_or(d); + let zoom = f("cameraOptions/zoom", 1000.0); + let focus = f("cameraOptions/focusDistance", zoom); + CameraDraft { + layer: Some(l.id.0), + name: l.name.clone(), + two_node: l.auto_orient == AutoOrient::TowardsPointOfInterest, + zoom, + dof: l.props.prop("cameraOptions/dof").map(|p| p.value_at(l.layer_time(t)).as_bool()).unwrap_or(false), + focus, + lock_to_zoom: (focus - zoom).abs() < 1e-6, + aperture: f("cameraOptions/aperture", default_aperture(w)), + blur: f("cameraOptions/blurLevel", 100.0), + } + } + None => { + let n = comp.layers.iter().filter(|l| l.is_camera()).count() + 1; + let zoom = zoom_for_focal(w, 50.0); + CameraDraft { + layer: None, + name: format!("Camera {n}"), + two_node: true, + zoom, + dof: false, + focus: zoom, + lock_to_zoom: true, + aperture: default_aperture(w), + blur: 100.0, + } + } + }; + app.dialog_state.camera = d; + app.dialog = Some(Dialog::CameraSettings); + Ok(()) +} + +/// Open Light Settings: for `layer` (existing) or a new light. +pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), String> { + let comp = app.session.active_comp().ok_or("no composition is open")?.clone(); + let t = app.session.time(); + let d = match layer.and_then(|id| comp.layer(effectcraft_engine::project::LayerId(id))) { + Some(l) => { + let LayerSource::Light { kind } = l.source else { return Err("not a light".into()) }; + let v = |p: &str| l.props.prop(&format!("lightOptions/{p}")).map(|pr| pr.value_at(l.layer_time(t))); + let f = |p: &str, d: f64| v(p).map(|x| x.as_f64()).unwrap_or(d); + let c = v("color").map(|x| x.as_color()).unwrap_or([1.0; 4]); + LightDraft { + layer: Some(l.id.0), + name: l.name.clone(), + kind: KINDS.iter().position(|k| *k == kind).unwrap_or(1), + color: [c[0], c[1], c[2]], + intensity: f("intensity", 100.0), + cone_angle: f("coneAngle", 90.0), + cone_feather: f("coneFeather", 50.0), + falloff: v("falloff").map(|x| x.as_enum() as usize).unwrap_or(0).min(2), + radius: f("radius", 500.0), + falloff_distance: f("falloffDistance", 500.0), + casts_shadows: v("castsShadows").map(|x| x.as_bool()).unwrap_or(false), + shadow_darkness: f("shadowDarkness", 100.0), + shadow_diffusion: f("shadowDiffusion", 0.0), + } + } + None => { + let n = comp.layers.iter().filter(|l| l.is_light()).count() + 1; + LightDraft { + layer: None, + name: format!("Light {n}"), + kind: 1, + color: [1.0, 1.0, 1.0], + intensity: 100.0, + cone_angle: 90.0, + cone_feather: 50.0, + falloff: 0, + radius: 500.0, + falloff_distance: 500.0, + casts_shadows: false, + shadow_darkness: 100.0, + shadow_diffusion: 0.0, + } + } + }; + app.dialog_state.light = d; + app.dialog = Some(Dialog::LightSettings); + Ok(()) +} + +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)); + r.0 = b.rect; + if b.clicked() { + *ok = true; + } + let c = ui.button("Cancel"); + r.1 = c.rect; + if c.clicked() { + *close = true; + } + }); + r +} + +pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let w = comp_w(app); + let mut d = app.dialog_state.camera.clone(); + let (mut ok, mut close) = (false, false); + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); + super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { + ui.label("Type"); + egui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { + ui.selectable_value(&mut d.two_node, true, "Two-Node Camera"); + ui.selectable_value(&mut d.two_node, false, "One-Node Camera"); + }); + ui.end_row(); + ui.label("Name"); + ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0)); + ui.end_row(); + ui.label("Preset"); + let focal = focal_for_zoom(w, d.zoom); + let cur = PRESETS.iter().find(|(_, f)| (f - focal).abs() < 1e-6).map(|(n, _)| *n).unwrap_or("Custom"); + egui::ComboBox::from_id_salt("cam-preset").selected_text(cur).show_ui(ui, |ui| { + for (n, f) in PRESETS { + if ui.selectable_label(cur == n, n).clicked() { + d.zoom = zoom_for_focal(w, f); + } + } + }); + ui.end_row(); + ui.label("Zoom"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px")); + let mut aov = angle_of_view(w, d.zoom); + if ui.add(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°")).changed() { + d.zoom = w * 0.5 / (aov.to_radians() * 0.5).tan(); + } + let mut fl = focal_for_zoom(w, d.zoom); + if ui.add(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm")).changed() { + d.zoom = zoom_for_focal(w, fl); + } + }); + ui.end_row(); + ui.label("Depth of Field"); + ui.checkbox(&mut d.dof, "Enable Depth of Field"); + ui.end_row(); + ui.label("Focus Distance"); + ui.horizontal(|ui| { + ui.add_enabled(!d.lock_to_zoom, egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")); + ui.checkbox(&mut d.lock_to_zoom, "Lock to Zoom"); + }); + ui.end_row(); + ui.label("Aperture"); + ui.add(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px")); + ui.end_row(); + ui.label("Blur Level"); + ui.add(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %")); + ui.end_row(); + }); + ui.add_space(14.0); + rects = buttons(ui, t, &mut ok, &mut close); + }); + if d.lock_to_zoom { + d.focus = d.zoom; + } + app.auto.add("dialog.camera.ok", rects.0, "OK"); + app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + app.dialog_state.camera = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + if let Err(e) = app.session.execute(id, p) { + app.ui.status = e.to_string(); + } + close = true; + } + if close { + app.dialog = None; + } +} + +pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.light.clone(); + let (mut ok, mut close) = (false, false); + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); + super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { + ui.label("Name"); + ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + ui.end_row(); + ui.label("Light Type"); + egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| { + for (i, k) in KINDS.iter().enumerate() { + ui.selectable_value(&mut d.kind, i, k.label()); + } + }); + ui.end_row(); + ui.label("Color"); + ui.color_edit_button_rgb(&mut d.color); + ui.end_row(); + ui.label("Intensity"); + ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")); + ui.end_row(); + let kind = KINDS[d.kind.min(3)]; + if kind == LightKind::Spot { + ui.label("Cone Angle"); + ui.add(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°")); + ui.end_row(); + ui.label("Cone Feather"); + ui.add(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %")); + ui.end_row(); + } + if kind != LightKind::Ambient { + ui.label("Falloff"); + egui::ComboBox::from_id_salt("light-falloff").selected_text(FALLOFFS[d.falloff.min(2)]).show_ui(ui, |ui| { + for (i, f) in FALLOFFS.iter().enumerate() { + ui.selectable_value(&mut d.falloff, i, *f); + } + }); + ui.end_row(); + if d.falloff > 0 { + ui.label("Radius"); + ui.add(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px")); + ui.end_row(); + ui.label("Falloff Distance"); + ui.add(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px")); + ui.end_row(); + } + ui.label("Shadows"); + ui.checkbox(&mut d.casts_shadows, "Casts Shadows"); + ui.end_row(); + ui.label("Shadow Darkness"); + ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %")); + ui.end_row(); + ui.label("Shadow Diffusion"); + ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px")); + ui.end_row(); + } + }); + ui.add_space(14.0); + ui.label(egui::RichText::new("Note: Shadows are only cast from layers with Cast Shadows enabled.").font(Tokens::ui(11.0)).color(t.text_dim)); + ui.add_space(8.0); + rects = buttons(ui, t, &mut ok, &mut close); + }); + app.auto.add("dialog.light.ok", rects.0, "OK"); + app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + app.dialog_state.light = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let kind = KINDS[d.kind.min(3)]; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if kind != LightKind::Ambient { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + if let Err(e) = app.session.execute(id, p) { + app.ui.status = e.to_string(); + } + close = true; + } + if close { + app.dialog = None; + } +} + +/// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. +/// Returns true when a dialog was opened. +pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { + if !params.as_object().is_none_or(|m| m.is_empty() || m.keys().all(|k| k == "layer")) { + return Ok(false); + } + let selected = || -> Option<(u64, bool, bool)> { + let c = app.session.active_comp()?; + let id = params.get("layer").and_then(Value::as_u64).or_else(|| app.session.state.selected_layers.first().map(|l| l.0))?; + let l = c.layer(effectcraft_engine::project::LayerId(id))?; + Some((id, l.is_camera(), l.is_light())) + }; + match id { + "layer.newCamera" if params.get("layer").is_none() => open_camera(app, None).map(|_| true), + "layer.newLight" if params.get("layer").is_none() => open_light(app, None).map(|_| true), + "layer.cameraSettings" | "layer.lightSettings" | "layer.settings" => match selected() { + Some((l, true, _)) => open_camera(app, Some(l)).map(|_| true), + Some((l, _, true)) => open_light(app, Some(l)).map(|_| true), + _ => Ok(false), + }, + _ => Ok(false), + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9d4e3848b..63f83d2b5 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,6 +1,7 @@ //! Panel bodies. pub mod dialogs; +pub mod dialogs_3d; pub mod effect_controls; pub mod effects_presets; pub mod info; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index bba588ad4..939eaed0d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -167,10 +167,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.double_clicked() { match &it.kind { ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))), - ItemKind::Folder => { - if !app.ui.project_open_folders.remove(&id.0) { - app.ui.project_open_folders.insert(id.0); - } + ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => { + app.ui.project_open_folders.insert(id.0); } _ => {} } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a07e1f669..b63f53071 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -42,7 +42,6 @@ struct Cols { label: f32, num: f32, name: f32, - name_w: f32, switches: f32, mode: f32, trkmat: f32, @@ -61,7 +60,7 @@ fn cols(x0: f32, width: f32, show_modes: bool) -> Cols { let mode = switches + SW * 8.0 + 6.0; let trkmat = mode + if show_modes { 92.0 } else { 0.0 }; let parent = trkmat + if show_modes { 112.0 } else { 0.0 }; - Cols { av, label, num, name, name_w, switches, mode, trkmat, parent, end: parent + 116.0 } + Cols { av, label, num, name, switches, mode, trkmat, parent, end: parent + 116.0 } } /// Timeline horizontal mapping. @@ -141,6 +140,10 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool { if p.two_d_only && layer.is_3d() { return false; } + // One-node cameras (and lights with auto-orient off) have no Point of Interest. + if p.match_id == "poi" && (layer.is_camera() || layer.is_light()) && layer.auto_orient != effectcraft_engine::project::AutoOrient::TowardsPointOfInterest { + return false; + } true } @@ -785,10 +788,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - if body.clicked() || body.drag_started() { - if !is_sel { - actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)}))); - } + if (body.clicked() || body.drag_started()) && !is_sel { + actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)}))); } layer_context_menu(&body, layer, &mut actions); let drag_key = format!("bar-{}", layer.id.0); @@ -944,7 +945,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } let name_x = indent + 12.0; - let pname = if prop.has_expression() { format!("{} =", prop.name) } else { prop.name.clone() }; + // 3D layers show Rotation as "Z Rotation" next to X/Y Rotation. + let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid); + let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() }; + let pname = if prop.has_expression() { format!("{base} =") } else { base }; lp.text( pos2(name_x, cy), Align2::LEFT_CENTER, @@ -1049,7 +1053,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("keys.select".into(), json!({"keys": []}))); } if let (true, Some(origin), Some(cur)) = - (empty.dragged(), empty.interact_pointer_pos().map(|_| ctx.input(|i| i.pointer.press_origin())).flatten(), empty.interact_pointer_pos()) + (empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos()) { let br = Rect::from_two_pos(origin, cur); gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12)); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index b1ae92af1..8757fd960 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1,10 +1,13 @@ //! The Composition viewer: pasteboard + comp frame, magnification and resolution, transparency //! grid, guides, layer bounding boxes with handles, anchor points, motion paths and masks, and -//! direct manipulation (move, scale, rotate, pan behind, shape/type creation, hand and zoom). +//! direct manipulation (move, scale, rotate, pan behind, shape/type creation, hand and zoom), +//! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and +//! camera/light wireframes. use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::project::{GroupKind, Layer, LayerId}; use effectcraft_engine::render::EvalCtx; +use effectcraft_engine::render::three_d::{self, CameraState, View3D}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -55,21 +58,71 @@ pub fn last_fit(ctx: &egui::Context) -> f32 { /// What a drag in the viewer is doing. #[derive(Clone, Debug)] enum Gesture { - Move { layers: Vec<(LayerId, [f64; 3], Mat3)>, start: [f64; 2] }, - Scale { layer: LayerId, anchor_screen: Pos2, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3, uniform: bool }, - Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64 }, - Anchor { layer: LayerId, start_anchor: [f64; 3], start_pos: [f64; 3], start: [f64; 2], inv: Mat3, l2p: Mat3 }, - Pan { start_pan: [f32; 2] }, - Create { tool: Tool, start: [f64; 2] }, - Marquee { start: Pos2 }, + /// Per layer: start position and the parent-space change per comp pixel of drag in x and y. + Move { + layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])>, + start: [f64; 2], + }, + Camera { + tool: Tool, + }, + Scale { + layer: LayerId, + start_scale: [f64; 3], + start_local: [f64; 2], + inv: Mat3, + uniform: bool, + }, + Rotate { + layer: LayerId, + center: Pos2, + start_angle: f64, + start_rot: f64, + }, + Anchor { + layer: LayerId, + start_anchor: [f64; 3], + start_pos: [f64; 3], + start: [f64; 2], + inv: Mat3, + l2p: Mat3, + }, + Pan { + start_pan: [f32; 2], + }, + Create { + tool: Tool, + start: [f64; 2], + }, + Marquee { + start: Pos2, + }, } const HANDLE: f32 = 7.0; +thread_local! { + /// The 3D view camera of the viewer being drawn (None = the comp's active camera). + static VIEW_CAM: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// The camera the viewer shows (view camera or the comp's active camera). +fn cam_state(ctx: &EvalCtx) -> CameraState { + VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx)) +} + +/// Layer → comp (viewer) matrix through the current 3D view. +fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) { + match VIEW_CAM.with(|c| c.get()) { + Some(cam) if layer.is_3d() => (three_d::layer_to_view(ctx, layer, &cam), 0.0), + _ => ctx.layer_to_comp(layer), + } +} + /// Layer → comp matrix and its bounds quad (in comp pixels). fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { let b = effectcraft_engine::render::content_bounds(ctx, layer)?; - let (m, _) = ctx.layer_to_comp(layer); + let (m, _) = l2c(ctx, layer); let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| { let q = m.apply(gv2(p[0], p[1])); [q.x, q.y] @@ -133,6 +186,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO)); let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let p = ui.painter().clone(); + VIEW_CAM.with(|c| c.set(app.session.view_camera(cid))); // Navigator bar (open comps as breadcrumbs). let nav = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); @@ -279,7 +333,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.viewer.show_masks && let Some(masks) = l.masks() { - let (m, _) = ectx.layer_to_comp(l); + let (m, _) = l2c(&ectx, l); for g in masks.groups() { let GroupKind::Mask { color, .. } = g.kind else { continue }; let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue }; @@ -338,8 +392,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // 3D axis gizmo. if l.is_3d() { let w = ectx.world_matrix(l); - let (cam, _, _) = ectx.camera(); - let pc = cam * w; + let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w; let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]); let o = pc.apply(a3); let len = 60.0 / zoom as f64; @@ -356,6 +409,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + // Cameras and lights (wireframes) and the 3D view name. + if comp.has_3d() { + draw_rigs(app, &painter, &map, &ectx, &selected); + let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + let label = match (view, comp.active_camera(time)) { + (View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name), + (v, _) => v.label().to_string(), + }; + painter.text(area.left_top() + vec2(12.0, 10.0), Align2::LEFT_TOP, label, Tokens::ui(12.0), t.text_dim); + } + // Interaction. let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag()); let gid = egui::Id::new("viewer-gesture"); @@ -368,6 +432,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tool::Hand => egui::CursorIcon::Grab, Tool::Zoom => egui::CursorIcon::ZoomIn, Tool::Type | Tool::TypeVertical => egui::CursorIcon::Text, + Tool::Orbit => egui::CursorIcon::Alias, + Tool::PanCamera => egui::CursorIcon::Move, + Tool::Dolly => egui::CursorIcon::ResizeVertical, t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair, _ => { if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) { @@ -415,7 +482,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| { let layer = comp.layer(l)?; let tr = layer.transform()?; - let (l2c, _) = ectx.layer_to_comp(layer); + let (l2c, _) = l2c(&ectx, layer); let l2p = ectx.local_matrix(layer); let l2p = Mat3([[l2p.0[0][0], l2p.0[0][1], 0.0], [l2p.0[1][0], l2p.0[1][1], 0.0], [0.0, 0.0, 1.0]]); Some(Gesture::Anchor { @@ -428,20 +495,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }) }), t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), + Tool::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }), Tool::Selection => { if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); layer.and_then(|layer| { let tr = layer.transform()?; - let (l2c, _) = ectx.layer_to_comp(&layer); + let (l2c, _) = l2c(&ectx, &layer); let inv = l2c.inverse()?; let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]); - let ac = l2c.apply(gv2(a[0], a[1])); let lp = inv.apply(gv2(cpt[0], cpt[1])); let _ = hi; Some(Gesture::Scale { layer: lid, - anchor_screen: map.to_screen([ac.x, ac.y]), start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), start_local: [lp.x - a[0], lp.y - a[1]], inv, @@ -449,14 +515,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { - let layers: Vec<(LayerId, [f64; 3], Mat3)> = app + let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app .session .state .selected_layers .iter() .filter_map(|id| comp.layer(*id)) .filter(|l| !l.switches.locked) - .filter_map(|l| Some((l.id, ectx.v3(l, l.transform()?, "position", [0.0; 3]), parent_inverse(&ectx, l)))) + .filter_map(|l| { + let p0 = ectx.v3(l, l.transform()?, "position", [0.0; 3]); + let (ax, ay) = drag_axes(&ectx, l); + Some((l.id, p0, ax, ay)) + }) .collect(); let _ = l; (!layers.is_empty()).then_some(Gesture::Move { layers, start: cpt }) @@ -485,9 +555,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if mods.shift { if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 } } - for (lid, p0, inv) in layers { - let dd = inv.apply_vec(gv2(d[0], d[1])); - let v = json!([p0[0] + dd.x, p0[1] + dd.y, p0[2]]); + for (lid, p0, ax, ay) in layers { + let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]); let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } @@ -498,7 +567,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cur = [lp.x - a[0], lp.y - a[1]]; let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 }; let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 }; - let (fx, fy) = if mods.shift || uniform && start_local[0].abs() > 1e-6 && start_local[1].abs() > 1e-6 && mods.shift { + let _ = uniform; + let (fx, fy) = if mods.shift { let f = (fx + fy) / 2.0; (f, f) } else { @@ -528,7 +598,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { time, expr: app.session.expr.as_deref(), }; - if let Some(m) = e2.layer_to_comp(&l).0.inverse() { + if let Some(m) = l2c(&e2, &l).0.inverse() { *i2 = m; } } @@ -555,6 +625,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/anchor", "value": na, "merge": merge})); let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/position", "value": np, "merge": merge})); } + Gesture::Camera { tool } => { + let d = resp.drag_delta(); + if d != egui::Vec2::ZERO { + let (dx, dy) = ((d.x / zoom) as f64, (d.y / zoom) as f64); + let (id, params) = match tool { + Tool::Orbit => ("camera.orbit", json!({"yaw": -d.x as f64 * 0.4, "pitch": d.y as f64 * 0.4, "merge": merge})), + Tool::PanCamera => ("camera.pan", json!({"dx": dx, "dy": dy, "merge": merge})), + _ => ("camera.dolly", json!({"amount": -dy * 2.0, "merge": merge})), + }; + if let Err(e) = app.session.execute(id, params) { + app.ui.status = e.to_string(); + } + } + } Gesture::Create { start, .. } => { let a = map.to_screen(start); let r = Rect::from_two_pos(a, pos); @@ -650,6 +734,99 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bottom_bar(app, ui, bar, zoom, fit, time, &comp); } +/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map +/// through the parent transform; 3D layers move in the view plane at their depth. +fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) { + if !l.is_3d() { + let inv = parent_inverse(ctx, l); + let a = inv.apply_vec(gv2(1.0, 0.0)); + let b = inv.apply_vec(gv2(0.0, 1.0)); + return ([a.x, a.y, 0.0], [b.x, b.y, 0.0]); + } + let cam = cam_state(ctx); + let anchor = l.transform().map(|tr| ctx.v3(l, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + let wp = ctx.world_matrix(l).apply(anchor.into()); + let k = if cam.ortho { 1.0 / cam.zoom.max(1e-9) } else { cam.depth(wp).max(1.0) / cam.zoom.max(1e-9) }; + let pinv = match l.parent.and_then(|p| ctx.comp.layer(p)) { + Some(p) => ctx.world_matrix(p).inverse().unwrap_or_default(), + None => effectcraft_engine::geom::Mat4::IDENTITY, + }; + let a = pinv.apply_vec(cam.right() * k); + let b = pinv.apply_vec(cam.down() * k); + ([a.x, a.y, a.z], [b.x, b.y, b.z]) +} + +/// Wireframes of cameras (frustum, point of interest) and lights (position, direction) as seen +/// through the current 3D view. +fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, selected: &[LayerId]) { + use effectcraft_engine::geom::Vec3; + use effectcraft_engine::project::{LayerSource, LightKind}; + let cam = cam_state(ectx); + let (w, h) = (ectx.comp.width as f64, ectx.comp.height as f64); + let active = ectx.comp.active_camera(ectx.time).map(|c| c.id); + let through_active = VIEW_CAM.with(|c| c.get()).is_none(); + let proj = |p: Vec3| cam.project(w, h, p).map(|v| map.to_screen([v.x, v.y])); + let seg = |a: Vec3, b: Vec3, stroke: Stroke| { + if let (Some(x), Some(y)) = (proj(a), proj(b)) { + painter.line_segment([x, y], stroke); + } + }; + for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) { + let sel = selected.contains(&l.id); + let col = Tokens::label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 }); + let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col); + let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l); + let poi = |l: &Layer| -> Option { + let pr = l.transform()?.get("poi").filter(|_| three_d::camera::is_two_node(l))?; + Some(three_d::camera::parent_world(ectx, l).apply(ectx.value(l, pr).as_vec3().into())) + }; + if l.is_camera() { + if through_active && Some(l.id) == active { + continue; + } + let zoom = l.props.prop("cameraOptions/zoom").map(|p| ectx.value(l, p).as_f64()).unwrap_or(1000.0); + let right = down.cross(fwd); + // Frustum to a plane at a quarter of the zoom distance. + let k = 0.25; + let c = eye + fwd * (zoom * k); + let corners = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].map(|(sx, sy)| c + right * (sx * w * 0.5 * k) + down * (sy * h * 0.5 * k)); + for i in 0..4 { + seg(eye, corners[i], stroke); + seg(corners[i], corners[(i + 1) % 4], stroke); + } + if let Some(poi) = poi(l) { + seg(eye, poi, Stroke::new(1.0, col.gamma_multiply(0.6))); + if let Some(s) = proj(poi) { + painter.circle_stroke(s, 4.0, stroke); + } + } + if let Some(s) = proj(eye) { + app.auto.add(&format!("viewer.camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name); + } + } else { + let LayerSource::Light { kind } = l.source else { continue }; + if kind == LightKind::Ambient { + continue; + } + let Some(s) = proj(eye) else { continue }; + painter.circle_stroke(s, 6.0, stroke); + for i in 0..8 { + let a = i as f32 * std::f32::consts::FRAC_PI_4; + let d = vec2(a.cos(), a.sin()); + painter.line_segment([s + d * 8.0, s + d * 12.0], stroke); + } + if matches!(kind, LightKind::Spot | LightKind::Parallel) { + let target = poi(l).unwrap_or(eye + fwd * 300.0); + seg(eye, target, Stroke::new(1.0, col.gamma_multiply(0.7))); + if let Some(q) = proj(target) { + painter.circle_stroke(q, 3.0, stroke); + } + } + app.auto.add(&format!("viewer.light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); + } + } +} + /// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { let hit = ectx @@ -705,7 +882,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], time: app.session.time(), expr: None, }; - let inv = ectx.layer_to_comp(&l).0.inverse().unwrap_or(Mat3::IDENTITY); + let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); let p1 = inv.apply(gv2(cx + w / 2.0, cy + h / 2.0)); let _ = app.session.execute( @@ -847,7 +1024,7 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, app.ui.viewer.res = Resolution::ALL[i]; } x = r.max.x + 6.0; - if tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest") {} + let _ = tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest"); if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") { app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid; } @@ -858,9 +1035,20 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, if comp.has_3d() { let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(96.0, 20.0)); let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); + app.auto.add("viewer.renderer3d", r, "3D Renderer"); x = r.max.x + 4.0; - let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(110.0, 20.0)); - let _ = widgets::dropdown(ui, r, "Active Camera", &t, egui::Id::new("vw-cam")); + let cid = app.session.active_comp_id(); + let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0)); + if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() { + widgets::open_popup(ui, egui::Id::new("vw-cam-pop")); + } + app.auto.add("viewer.view3d", r, "3D View"); + let opts: Vec = View3D::ALL.iter().map(|v| v.label().to_string()).collect(); + let at = r.left_top() - vec2(0.0, 22.0 * opts.len() as f32 + 12.0); + if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-cam-pop"), at, &opts, View3D::ALL.iter().position(|v| *v == cur)) { + let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()})); + } } // Right: render time. let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs new file mode 100644 index 000000000..9cd7e04fe --- /dev/null +++ b/crates/ui-egui/tests/ui_3d.rs @@ -0,0 +1,118 @@ +//! Headless UI checks for the 3D viewer (egui_kittest). The `snapshot` test renders the window +//! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui +//! --test ui_3d -- --ignored`); the other test only runs the UI logic. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + EffectcraftApp::new(s) +} + +/// Step the UI until background frame renders have landed. +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + for _ in 0..4 { + h.step(); + } +} + +#[test] +fn viewer_registers_3d_controls() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + settle(&mut h); + h.step(); + let ids: Vec = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect(); + for want in ["viewer.view3d", "viewer.renderer3d"] { + assert!(ids.iter().any(|i| i == want), "missing {want}"); + } + assert!(ids.iter().any(|i| i.starts_with("viewer.light.")), "light wireframe registered"); + // In a custom view the active camera is drawn as a wireframe too. + h.state_mut().session.execute("view.3d.custom1", json!({})).unwrap(); + settle(&mut h); + h.step(); + let ids: Vec = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect(); + assert!(ids.iter().any(|i| i.starts_with("viewer.camera.")), "camera wireframe registered"); +} + +#[test] +fn camera_and_light_dialogs_create_layers() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.step(); + let ctx = h.ctx.clone(); + let n0 = h.state().session.active_comp().unwrap().layers.len(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newCamera", json!({})).unwrap(); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::CameraSettings)); + h.step(); + let ok = h.state().auto.elements.iter().chain(h.state().auto.previous.iter()).find(|e| e.id == "dialog.camera.ok").map(|e| e.rect); + assert!(ok.is_some(), "camera dialog OK registered"); + // OK through the dialog state (Enter). + h.key_press(egui::Key::Enter); + h.step(); + assert!(h.state().dialog.is_none()); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), n0 + 1); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newLight", json!({})).unwrap(); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LightSettings)); + h.step(); + h.key_press(egui::Key::Enter); + h.step(); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), n0 + 2); + // Layer Settings on the new light reopens Light Settings for it. + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.settings", json!({})).unwrap(); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LightSettings)); +} + +#[test] +#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"] +fn snapshot() { + let dir = std::env::var("EC_SNAPSHOT_DIR").ok(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| app()); + let shots: [(&str, Option<&str>); 4] = + [("active", None), ("custom1", Some("view.3d.custom1")), ("top", Some("view.3d.top")), ("left", Some("view.3d.left"))]; + for (name, cmd) in shots { + if let Some(c) = cmd { + h.state_mut().session.execute(c, json!({})).unwrap(); + } + settle(&mut h); + let img = h.render().expect("render"); + if let Some(d) = &dir { + img.save(format!("{d}/ui3d_{name}.png")).unwrap(); + } + } + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newCamera", json!({})).unwrap(); + h.run_steps(3); + let img = h.render().expect("render"); + if let Some(d) = &dir { + img.save(format!("{d}/ui3d_camera_dialog.png")).unwrap(); + } + h.state_mut().dialog = None; + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newLight", json!({})).unwrap(); + h.run_steps(3); + let img = h.render().expect("render"); + if let Some(d) = &dir { + img.save(format!("{d}/ui3d_light_dialog.png")).unwrap(); + } + // A 3D layer's rotation properties (R) and the selected layer's gizmo in the active camera. + h.state_mut().dialog = None; + h.state_mut().session.execute("view.3d.activeCamera", json!({})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": ["Card Blue"]})).unwrap(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.rotation", json!({})).unwrap(); + settle(&mut h); + let img = h.render().expect("render"); + if let Some(d) = &dir { + img.save(format!("{d}/ui3d_reveal_rotation.png")).unwrap(); + } +}