From fa7ffb6d09dd8d248cb37032048fa63650770352 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 19:46:47 +0200 Subject: [PATCH] #670: each pane of a 2- or 4-view layout chooses its own view Problem: the extra panes of View > Switch View Layout were fixed to Top, Front and Right, and the 3D View menu only changed the main view. Fix: every extra pane has a view selector (Active Camera, Default, Front, Left, Top, Back, Right, Bottom, Custom View 1-3). The choice is kept per comp in Views3D::panes (serde default, so older editor states load with Top/Front/Right) and set by the new command view.setPaneView {pane 0-3, view}. The Active Camera pane renders through the comp camera. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/three_d.rs | 24 ++++++++++ crates/engine/src/tests_3d.rs | 22 +++++++++ crates/render/src/three_d/camera.rs | 20 ++++++++ crates/ui-egui/src/panels/viewer.rs | 68 +++++++++++++++++++++++---- crates/ui-egui/tests/ui_3d.rs | 42 +++++++++++++++++ 5 files changed, 168 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index f9e2dd5e..e143690d 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result { Ok(switch_view(s, cid, v)) } +/// The view of one pane of the View Layout: pane 0 is the main view (the same as Switch 3D View), +/// panes 1 to 3 are the extra views of a 2- or 4-view layout. UI state, not part of the project. +fn set_pane_view(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let v = view_p(p, "view.setPaneView")?; + let pane = p.get("pane").and_then(Value::as_u64).filter(|n| *n <= 3).ok_or_else(|| bad("view.setPaneView", "pane: 0 (main view) to 3"))? as usize; + if pane == 0 { + switch_view(s, cid, v); + } else { + views(s, cid).set_pane(pane - 1, v); + } + let vs = views(s, cid); + Ok(json!({"pane": pane, "view": v.id(), "views": [vs.current.id(), vs.pane(0).id(), vs.pane(1).id(), vs.pane(2).id()]})) +} + macro_rules! view_cmd { ($f:ident, $v:expr) => { fn $f(s: &mut Session, p: &Value) -> Result { @@ -706,6 +721,15 @@ pub fn specs() -> Vec { has_comp, set_3d_view ), + cmd!( + "view.setPaneView", + "Set View of Pane", + [], + None, + "{pane: 0 (main) | 1 | 2 | 3, view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}", + has_comp, + set_pane_view + ), cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active), cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default), cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front), diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 955a8f16..cf9773f9 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -243,3 +243,25 @@ fn demo_has_3d_showcase() { let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count(); assert!(lit > 2_000, "{lit}"); } + +/// #670: the views of the panes of a multi-view layout are chosen per pane, kept per comp, and +/// editor states saved before panes existed load with the default Top / Front / Right. +#[test] +fn layout_panes_choose_their_view() { + use effectcraft_render::three_d::{View3D, Views3D}; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + assert_eq!(s.state.views3d.get(&cid).cloned().unwrap_or_default().pane(0), View3D::Top); + let r = s.execute("view.setPaneView", json!({"pane": 2, "view": "bottom"})).unwrap(); + assert_eq!(r["views"], json!(["activeCamera", "top", "bottom", "right"])); + assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera, "the main view is untouched"); + s.execute("view.setPaneView", json!({"pane": 0, "view": "custom1"})).unwrap(); + assert_eq!(s.state.views3d[&cid].current, View3D::Custom1); + assert!(s.execute("view.setPaneView", json!({"pane": 4, "view": "top"})).is_err()); + assert!(s.execute("view.setPaneView", json!({"pane": 1, "view": "nope"})).is_err()); + let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap(); + assert_eq!(back.views3d[&cid].pane(1), View3D::Bottom); + let old: Views3D = serde_json::from_value(json!({"current": "Front"})).unwrap(); + assert_eq!((old.current, old.pane(0), old.pane(1), old.pane(2)), (View3D::Front, View3D::Top, View3D::Front, View3D::Right)); +} diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index b0a5f472..8c0f9d23 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -457,9 +457,29 @@ pub struct Views3D { /// Edited view cameras (views not present use their defaults). #[serde(default)] pub cams: BTreeMap, + /// The views of the extra panes of a 2- or 4-view layout (pane 1 to 3, i.e. index 0 to 2); + /// panes not present show their default ([`Views3D::DEFAULT_PANES`]). + #[serde(default)] + pub panes: Vec, } impl Views3D { + /// Views the extra panes of a multi-view layout start with: Top, Front and Right. + pub const DEFAULT_PANES: [View3D; 3] = [View3D::Top, View3D::Front, View3D::Right]; + /// The view of extra pane `i` (0 = the first beside the main view). + pub fn pane(&self, i: usize) -> View3D { + self.panes.get(i).or_else(|| Self::DEFAULT_PANES.get(i)).copied().unwrap_or_default() + } + /// Choose the view of extra pane `i` (ignored past the third). + pub fn set_pane(&mut self, i: usize, v: View3D) { + if i >= Self::DEFAULT_PANES.len() { + return; + } + while self.panes.len() <= i { + self.panes.push(Self::DEFAULT_PANES[self.panes.len()]); + } + self.panes[i] = v; + } pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam { self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h)) } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f7ed56e5..0221d9aa 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -523,18 +523,22 @@ fn aux_views( return full; } let gap = 2.0; + let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); let (main, aux): (Rect, Vec<(Rect, View3D)>) = if n == 2 { let mid = full.center().x; - (Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), View3D::Top)]) + ( + Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), + vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), views.pane(0))], + ) } else { let c = full.center(); let q = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); ( q(c.x + gap / 2.0, c.y + gap / 2.0, full.max.x, full.max.y), vec![ - (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), View3D::Top), - (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), View3D::Front), - (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), View3D::Right), + (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), views.pane(0)), + (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), views.pane(1)), + (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), views.pane(2)), ], ) }; @@ -543,7 +547,6 @@ fn aux_views( p.rect_filled(full, 0.0, Color32::from_black_alpha(200)); let t = app.session.time(); let (cw, ch) = (comp.width as f32, comp.height as f32); - let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); let share = app.session.state.share_view_options; // The camera and light wireframes of each view (as in After Effects, where every view of the // layout shows them), evaluated on a snapshot of the project. @@ -551,12 +554,16 @@ fn aux_views( let snap_expr = app.session.expr.clone(); let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; let selected = app.session.state.selected_layers.clone(); + let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { p.rect_filled(r, 0.0, bg); let fit = ((r.width() - 20.0) / cw).min((r.height() - 20.0) / ch).max(0.01); let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; - let cam = views.cam(v, comp.width as f64, comp.height as f64).state(); + // The Active Camera pane renders through the comp's own camera; the others through their + // view camera. + let through_active = v == View3D::ActiveCamera; + let cam = if through_active { three_d::active_camera(&ectx) } else { views.cam(v, comp.width as f64, comp.height as f64).state() }; let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -568,7 +575,12 @@ fn aux_views( let tex = match cached { Some((k, tex)) if k == key => tex, _ => { - let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() }; + let opts = effectcraft_engine::render::RenderOpts { + scale, + view: (comp.has_3d() && !through_active).then_some(cam), + draft: true, + ..Default::default() + }; let img = app.session.render(cid, t, opts); let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); @@ -586,15 +598,55 @@ fn aux_views( } if comp.has_3d() { let map = ViewerMap { origin: cr.min, zoom: fit, comp: [cw, ch], area: r }; - draw_rigs(app, &p.with_clip_rect(r), &map, &ectx, &selected, cam, false, Some(v.id())); + draw_rigs(app, &p.with_clip_rect(r), &map, &ectx, &selected, cam, through_active, Some(v.id())); } let label = if comp.has_3d() { v.label() } else { crate::i18n::tr("Active Camera") }; p.text(r.left_bottom() + vec2(8.0, -8.0), Align2::LEFT_BOTTOM, label, Tokens::ui(11.0), Color32::from_white_alpha(200)); app.auto.add(&format!("viewer.view.{}", v.id()), r, label); + if comp.has_3d() + && let Some(chosen) = pane_selector(app, ui, i + 1, r, v) + { + picked = Some((i + 1, chosen)); + } + } + if let Some((pane, v)) = picked + && let Err(e) = app.session.execute("view.setPaneView", json!({"pane": pane, "view": v.id(), "comp": cid.0})) + { + app.ui.status = e.to_string(); } main } +/// The view selector in the top-left corner of pane `pane` (1 to 3) of a multi-view layout, a +/// popup of every 3D view like the toolbar's 3D View menu. Returns the view chosen this frame. +fn pane_selector(app: &mut EffectcraftApp, ui: &mut egui::Ui, pane: usize, area: Rect, current: View3D) -> Option { + let t = app.tokens; + let r = Rect::from_min_size(area.min + vec2(6.0, 6.0), vec2(124.0, 20.0)); + let pid = egui::Id::new(("viewer-pane-view", pane)); + let response = widgets::dropdown(ui, r, crate::i18n::tr(current.label()), &t, pid); + if response.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + app.auto.add_response(&format!("viewer.view.{pane}.type"), &response, "View of pane"); + if !widgets::popup_is_open(ui, pid) { + return None; + } + let chosen = widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, View3D::ALL.len(), 0, |ui| { + ui.set_min_width(124.0); + let mut chosen = None; + for (k, v) in View3D::ALL.iter().enumerate() { + let item = ui.selectable_label(*v == current, crate::i18n::tr(v.label())); + app.auto.add(&format!("viewer.view.{pane}.typeItem.{k}"), item.rect, v.label()); + if item.clicked() { + chosen = Some(*v); + } + } + chosen + }); + chosen.filter(|v| *v != current) +} + /// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel /// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle. fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect { diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index c9202876..876035a9 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -3,6 +3,7 @@ //! --test ui_3d -- --ignored`); the other test only runs the UI logic. use effectcraft_engine::Session; +use effectcraft_engine::render::three_d::View3D; use effectcraft_ui_egui::EffectcraftApp; use egui_kittest::Harness; use serde_json::json; @@ -223,3 +224,44 @@ fn layout_views_draw_the_camera_and_follow_its_position() { assert!(after.0 - before.0 > 10.0, "the camera moves right in the Top view: {before:?} -> {after:?}"); assert!((after.1 - before.1).abs() < 1.0, "{before:?} -> {after:?}"); } + +/// A 4-view layout over a comp with a 3D solid and a camera. +fn four_views() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.newCamera", json!({"position": [320, 180, -300], "poi": [320, 180, 0]})).unwrap(); + s.execute("view.layout", json!({"views": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + h +} + +/// #670: every pane of a multi-view layout has its own view selector; choosing a view changes +/// that pane (its camera and its wireframes) and no other, and it is remembered for the comp. +#[test] +fn each_layout_pane_chooses_its_own_view() { + let mut h = four_views(); + let present = |h: &Harness<'_, EffectcraftApp>, view: &str| h.state().auto.find(&format!("viewer.view.{view}")).is_some(); + let has_camera = |h: &Harness<'_, EffectcraftApp>, view: &str| h.state().auto.query(&format!("viewer.view.{view}.camera.")).len() == 1; + for p in 1..=3 { + assert!(h.state().auto.find(&format!("viewer.view.{p}.type")).is_some(), "pane {p} has a selector"); + } + assert!(present(&h, "front") && has_camera(&h, "front")); + // Pane 2 (the Front view) becomes the Left view. + let left = View3D::ALL.iter().position(|v| *v == View3D::Left).unwrap(); + click_id(&mut h, "viewer.view.2.type"); + click_id(&mut h, &format!("viewer.view.2.typeItem.{left}")); + settle(&mut h); + assert_eq!(h.state().auto.find("viewer.view.2.type").unwrap().label, "View of pane"); + assert!(present(&h, "left") && has_camera(&h, "left"), "pane 2 shows the Left view with its wireframe"); + assert!(!present(&h, "front"), "pane 2 no longer shows Front"); + assert!(present(&h, "top") && present(&h, "right"), "the other panes keep their views"); + let cid = h.state().session.active_comp_id().unwrap(); + assert_eq!(h.state().session.state.views3d[&cid].pane(1), View3D::Left); + // The Active Camera view of a pane does not draw the camera it looks through. + h.state_mut().session.execute("view.setPaneView", json!({"pane": 1, "view": "activeCamera"})).unwrap(); + settle(&mut h); + assert!(present(&h, "activeCamera") && !has_camera(&h, "activeCamera")); +}