diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c..f7ed56e5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -545,6 +545,12 @@ fn aux_views( 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. + let snap_project = app.session.project.clone(); + 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(); 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); @@ -578,6 +584,10 @@ fn aux_views( p.rect_stroke(Rect::from_center_size(cr.center(), cr.size() * k), 0.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Middle); } } + 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())); + } 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); @@ -1050,7 +1060,8 @@ 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 through_active = VIEW_CAM.with(|c| c.get()).is_none(); + draw_rigs(app, &painter, &map, &ectx, &selected, cam_state(&ectx), through_active, None); 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), @@ -1858,13 +1869,26 @@ fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) { /// 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]) { +/// Camera and light wireframes seen through `cam` (`through_active`: `cam` is the active camera, +/// which is then not drawn). `pane` names the automation ids: `None` for the main view +/// (`viewer.camera.`), `Some(view)` for a view of a 2- or 4-view layout +/// (`viewer.view..camera.`). +#[allow(clippy::too_many_arguments)] +fn draw_rigs( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + selected: &[LayerId], + cam: CameraState, + through_active: bool, + pane: Option<&str>, +) { 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 prefix = pane.map(|v| format!("viewer.view.{v}.")).unwrap_or_else(|| "viewer.".to_string()); 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)) { @@ -1901,7 +1925,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, } } 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); + app.auto.add(&format!("{prefix}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 }; @@ -1922,7 +1946,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, 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); + app.auto.add(&format!("{prefix}light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); } } } diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 0c65084b..c9202876 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -195,3 +195,31 @@ fn extended_viewer_renders_past_the_comp_frame() { h.step(); assert!(h.state().ui.viewer.extended.is_none()); } + +/// #875: the Top / Front / Right views of a 4-view layout draw the camera wireframe (they drew +/// none, so moving the camera showed nothing there), and it follows the camera's Position. +#[test] +fn layout_views_draw_the_camera_and_follow_its_position() { + 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(); + let cam = s.execute("layer.newCamera", json!({"position": [320, 180, -300], "poi": [320, 180, 0]})).unwrap()["layer"].as_u64().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); + let centre = |h: &Harness<'_, EffectcraftApp>, view: &str| { + let id = format!("viewer.view.{view}.camera.{cam}"); + let [x, y, w, hh] = h.state().auto.find(&id).unwrap_or_else(|| panic!("{id} is registered")).rect; + (x + w / 2.0, y + hh / 2.0) + }; + for v in ["top", "front", "right"] { + centre(&h, v); + } + let before = centre(&h, "top"); + h.state_mut().session.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [480, 180, -300]})).unwrap(); + settle(&mut h); + let after = centre(&h, "top"); + 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:?}"); +}