#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 <[email protected]>
This commit is contained in:
vangel76andClaude Sonnet 5.5 committed 2026-10-11 19:46:47 +02:00
1 parent f19f3a8a39
commit fa7ffb6d09
5 files changed
+168 -8

No files matched your search

+24
View File
@@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
@@ -706,6 +721,15 @@ pub fn specs() -> Vec<CommandSpec> {
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),
+22
View File
@@ -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));
}
+20
View File
@@ -457,9 +457,29 @@ pub struct Views3D {
/// Edited view cameras (views not present use their defaults).
#[serde(default)]
pub cams: BTreeMap<View3D, ViewCam>,
/// 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<View3D>,
}
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))
}
+60 -8
View File
@@ -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<View3D> {
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 {
+42
View File
@@ -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"));
}