mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
#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:
1 parent
f19f3a8a39
commit
fa7ffb6d09
5 files changed
+168
-8
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in new issue
Block a user