From baf4716246077d8f78303657d011d5fdfdd9ac18 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:14:12 -0400 Subject: [PATCH] M13.5: extended viewer (custom 3D views / Draft 3D render the pasteboard around the comp frame; view.extendedViewer) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/view.rs | 13 ++++++ crates/engine/src/menus.rs | 1 + crates/render/src/lib.rs | 57 ++++++++++++++++++++--- crates/render/src/tests_roi.rs | 30 ++++++++++++ crates/render/src/three_d/compose.rs | 10 ++-- crates/ui-egui/src/icons.rs | 8 ++++ crates/ui-egui/src/lib.rs | 14 +++++- crates/ui-egui/src/panels/viewer.rs | 41 +++++++++++++++- crates/ui-egui/src/panels/viewer_tools.rs | 8 +++- crates/ui-egui/src/state.rs | 5 ++ crates/ui-egui/tests/ui_3d.rs | 48 +++++++++++++++++++ 11 files changed, 219 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index b3d86565..4cb001f6 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -89,6 +89,18 @@ fn share_view_options(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.share_view_options)) } +/// Settings ▸ 3D ▸ Extended Viewer: 3D views (custom views, or Draft 3D) show the pasteboard +/// around the comp frame with the layers that reach onto it. +fn extended_viewer(s: &mut Session, p: &Value) -> Result { + let v = super::b_p(p, "value").unwrap_or(!s.prefs.three_d.extended_viewer); + if v != s.prefs.three_d.extended_viewer { + s.prefs.three_d.extended_viewer = v; + s.prefs_changed(); + s.save_prefs(); + } + Ok(json!(v)) +} + pub fn specs() -> Vec { vec![ cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide), @@ -97,6 +109,7 @@ pub fn specs() -> Vec { cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides), cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout), cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options), + cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer), cmd!("view.setRegionOfInterest", "Region of Interest", [], None, "{rect?: [x, y, w, h] | null}", always, region_of_interest), ] } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 9ab366db..fce44a90 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -301,6 +301,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "layer.mask.hideLocked" => Some(s.state.hide_locked_masks), "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), "view.shareViewOptions" => Some(s.state.share_view_options), + "view.extendedViewer" => Some(s.prefs.three_d.extended_viewer), "view.snapping" => Some(s.state.snapping), "view.displayColorManagement" => Some(s.state.viewer.display_color_management), "view.simulateOutput" => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6ede490b..2f468782 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -363,6 +363,9 @@ pub struct Renderer<'a> { /// Switches inherited from the precomp layers this comp is rendered through (see /// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed. pub(crate) inherited: Option<(Quality, bool)>, + /// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed + /// precomp drawn straight into it): the region of interest's offset applies. + top: bool, } /// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). @@ -407,6 +410,7 @@ impl<'a> Renderer<'a> { opacity_mul: 1.0, collapse3d: None, inherited: None, + top: true, } } @@ -441,7 +445,7 @@ impl<'a> Renderer<'a> { /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { - Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self } + Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, top: false, ..*self } } /// The nested comp of a precomp layer whose transformations collapse into this comp: the @@ -604,18 +608,28 @@ impl<'a> Renderer<'a> { let s = self.opts.scale; let w = ((comp.width as f64 * s).round() as u32).max(1); let h = ((comp.height as f64 * s).round() as u32).max(1); - // Region of interest on a comp with 3D layers: render the frame and crop it. + // Region of interest on a comp with 3D layers: render the frame and crop it. A region + // reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the + // planes project through the offset camera — and Advanced 3D as the frame, padded. if self.depth == 0 && let Some(r) = self.opts.roi && comp.has_3d() + && (roi_inside(r, comp.width, comp.height) || comp.renderer == effectcraft_project::Renderer::Advanced3D) { let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t); - let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32); + let (x0, y0) = ((r[0] * s).round() as i64, (r[1] * s).round() as i64); let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)); let mut out = Image::new(rw, rh); - for y in 0..rh.min(full.height.saturating_sub(y0)) { - for x in 0..rw.min(full.width.saturating_sub(x0)) { - out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize]; + for y in 0..rh as i64 { + let sy = y + y0; + if sy < 0 || sy >= full.height as i64 { + continue; + } + for x in 0..rw as i64 { + let sx = x + x0; + if sx >= 0 && sx < full.width as i64 { + out.data[(y * rw as i64 + x) as usize] = full.data[(sy * full.width as i64 + sx) as usize]; + } } } return out; @@ -1124,7 +1138,7 @@ impl<'a> Renderer<'a> { /// The region of interest of a top-level 2D frame: (x, y) offset of the output in output /// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp). pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> { - let r = self.opts.roi.filter(|_| self.depth == 0)?; + let r = self.opts.roi.filter(|_| self.top)?; let s = self.opts.scale; if r[2] <= 0.0 || r[3] <= 0.0 { return None; @@ -1132,6 +1146,11 @@ impl<'a> Renderer<'a> { Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1))) } + /// The region of interest's offset in output pixels for 3D projection (0 without one). + pub(crate) fn out_offset(&self) -> (f64, f64) { + self.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)) + } + /// Scaled comp pixel → output pixel: the region of interest's offset. fn out_matrix(&self) -> Mat3 { match self.roi_offset() { @@ -1645,6 +1664,30 @@ pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize { travel.ceil().clamp(lo, hi) as usize } +/// A region of interest `[x, y, w, h]` lies within a `w`×`h` comp frame. +pub fn roi_inside(r: [f64; 4], w: u32, h: u32) -> bool { + r[0] >= 0.0 && r[1] >= 0.0 && r[0] + r[2] <= w as f64 + 1e-6 && r[1] + r[3] <= h as f64 + 1e-6 +} + +/// The Extended Viewer's region of interest: the comp frame grown by `margin` × its size on every +/// side (`margin` = 1 shows a pasteboard one comp wide around the frame), intersected with the +/// `visible` comp-space rectangle `[x0, y0, x1, y1]` and snapped outward to whole 16 px steps from the frame edges +/// so panning re-renders rarely. `None` when the visible area lies inside the frame. +pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option<[f64; 4]> { + let (w, h) = (w as f64, h as f64); + let (mx, my) = (w * margin, h * margin); + let snap_lo = |v: f64| (v / 16.0).floor() * 16.0; + let snap_hi = |v: f64| (v / 16.0).ceil() * 16.0; + let x0 = snap_lo(visible[0].max(-mx)).min(0.0); + let y0 = snap_lo(visible[1].max(-my)).min(0.0); + let x1 = w + snap_hi(visible[2].min(w + mx) - w).max(0.0); + let y1 = h + snap_hi(visible[3].min(h + my) - h).max(0.0); + if x0 >= 0.0 && y0 >= 0.0 && x1 <= w && y1 <= h { + return None; + } + Some([x0, y0, x1 - x0, y1 - y0]) +} + pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) } diff --git a/crates/render/src/tests_roi.rs b/crates/render/src/tests_roi.rs index 9adc8a8e..9182817c 100644 --- a/crates/render/src/tests_roi.rs +++ b/crates/render/src/tests_roi.rs @@ -71,3 +71,33 @@ fn roi_at_half_resolution() { assert_eq!((roi.width, roi.height), (30, 20)); assert_same(&roi, &crop(&full, 20, 10, 30, 20)); } + +/// Extended Viewer: a region reaching past the comp frame shows the 3D layer's pixels on the +/// pasteboard, and its inner part equals the normal frame. +#[test] +fn extended_region_renders_3d_layers_beyond_the_frame() { + let (mut p, cid) = project(true); + // Push the 3D (blue) layer half out of the frame on the right. + let comp = p.comp_mut(cid).unwrap(); + comp.layers[1].props.prop_mut("transform/position").unwrap().value = Value::Vec3([150.0, 60.0, 0.0]); + comp.layers[1].props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(0.0); + let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + let region = crate::extended_region(160, 120, [-100.0, -100.0, 260.0, 220.0], 0.5).unwrap(); + assert_eq!(region, [-80.0, -64.0, 320.0, 248.0]); + let ext = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some(region), ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert_eq!((ext.width, ext.height), (320, 248)); + // Comp x 175 (past the 160 px frame) at y 60: the layer covers x 115..185. + let out = ext.get(175 + 80, 60 + 64); + assert!(out[3] > 0.5 && out[2] > 0.5, "pasteboard pixel {out:?}"); + // Nothing is drawn far outside every layer. + assert_eq!(ext.get(5, 5)[3], 0.0); + // The frame part is the normal frame. + assert_same(&crop(&ext, 80, 64, 160, 120), &full); +} + +#[test] +fn extended_region_is_none_inside_the_frame() { + assert_eq!(crate::extended_region(160, 120, [10.0, 10.0, 150.0, 110.0], 1.0), None); + // Clamped to the margin. + assert_eq!(crate::extended_region(100, 100, [-1000.0, 0.0, 50.0, 100.0], 1.0), Some([-112.0, 0.0, 212.0, 100.0])); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 25755a47..1f5470b3 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -74,8 +74,10 @@ impl Item<'_> { } /// Build the plane geometry of a layer for a camera. -fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { - let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); +fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32), off: (f64, f64)) -> Option { + // The output canvas may start at `off` (scaled comp pixels): region of interest / Extended + // Viewer frames. + let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); let full = proj * world; let h = full.plane_to_mat3(); // Edge-on planes project to a line (singular homography): nothing to draw. @@ -222,7 +224,7 @@ fn prepare_with<'a>( let bounds = buf_bounds(&buf); let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { - geo(&cam, world, bounds, comp, s, out).into_iter().collect() + geo(&cam, world, bounds, comp, s, out, r.out_offset()).into_iter().collect() } else { let fd = ctx.comp.frame_duration().seconds(); let angle = ctx.comp.shutter_angle / 360.0; @@ -232,7 +234,7 @@ fn prepare_with<'a>( let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out) + geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out, r.out_offset()) }) .collect() }; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index d2de8fc3..ac9a028e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -89,6 +89,8 @@ pub enum Icon { MaskVis, Checker, Region, + /// Extended Viewer: a small frame with corner ticks reaching outward. + ExtendedViewer, Plus, Minus, Close, @@ -513,6 +515,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(2.5, 2.5, 13.5, 13.5); pen.rect(5.5, 5.5, 10.5, 10.5); } + ExtendedViewer => { + pen.rect(5.0, 5.0, 11.0, 11.0); + for (x, y, dx, dy) in [(2.0, 2.0, 1.0, 1.0), (14.0, 2.0, -1.0, 1.0), (2.0, 14.0, 1.0, -1.0), (14.0, 14.0, -1.0, -1.0)] { + pen.line(&[(x + dx * 3.0, y), (x, y), (x, y + dy * 3.0)]); + } + } Plus => { pen.line(&[(8.0, 3.0), (8.0, 13.0)]); pen.line(&[(3.0, 8.0), (13.0, 8.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c56100af..e345e9c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -344,6 +344,7 @@ impl EffectcraftApp { && v.channel == effectcraft_engine::viewer::Channel::Rgb && v.exposure == 0.0 && self.session.state.region_of_interest.is_none() + && self.ui.viewer.extended.is_none() } /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs @@ -439,7 +440,7 @@ impl EffectcraftApp { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); // The region of interest changes what is rendered too. - let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + let roi = self.viewer_region(comp); if let Some(r) = roi { r.map(f64::to_bits).hash(&mut h); } @@ -454,6 +455,15 @@ impl EffectcraftApp { h.finish() | 1 } + /// The comp-space region viewer frames of `comp` cover: the region of interest, else the + /// Extended Viewer's area (frame plus pasteboard), else `None` (the whole frame). + pub fn viewer_region(&self, comp: ItemId) -> Option<[f64; 4]> { + if self.session.active_comp_id() != Some(comp) { + return None; + } + self.session.state.region_of_interest.or(self.ui.viewer.extended) + } + /// Request a prefetch frame render (no-op if cached/in flight). pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) { self.request_frame_with(comp, frame, scale, false); @@ -467,7 +477,7 @@ impl EffectcraftApp { /// Render options of viewer frames of `comp` at `scale`. pub fn frame_opts(&self, comp: ItemId, scale: f64) -> RenderOpts { let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); - let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + let roi = self.viewer_region(comp); RenderOpts { scale, motion_blur: true, diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index ccb963cf..3d1f8de4 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -461,6 +461,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.insert_temp(map_id(), map)); let comp_rect = Rect::from_min_size(origin, vec2(cw * zoom, ch * zoom)); let painter = p.with_clip_rect(area); + // Settings ▸ 3D ▸ Extended Viewer: custom 3D views (and Draft 3D) render the visible + // pasteboard too, so 3D layers reaching past the comp frame stay visible there. + app.ui.viewer.extended = extended_region(app, &comp, cid, map); // Frame. let ppp = ctx.pixels_per_point(); @@ -491,7 +494,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None }; // The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region. vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx); - painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + if app.ui.viewer.extended.is_some() { + // The comp frame outlined over the extended render. + painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + app.auto.add("viewer.extendedArea", rect_of(&map, app.ui.viewer.extended), "Extended Viewer area"); + } else { + painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + } app.auto.add("viewer.comp", comp_rect, &comp_name); app.auto.add("viewer.area", area, "Composition viewer"); @@ -1654,6 +1663,34 @@ pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { Some([p(0)?, p(2)?, p(4)?]) } +/// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): +/// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view +/// (or Draft 3D is on), and part of the visible viewer lies outside the comp frame. +/// +/// [`ViewerState::extended`]: crate::state::ViewerState::extended +pub(crate) fn extended_region( + app: &EffectcraftApp, + comp: &effectcraft_engine::project::Comp, + cid: effectcraft_engine::project::ItemId, + map: ViewerMap, +) -> Option<[f64; 4]> { + if !app.session.prefs.three_d.extended_viewer || !comp.has_3d() || app.session.state.region_of_interest.is_some() { + return None; + } + let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + if view == View3D::ActiveCamera && !comp.draft_3d { + return None; + } + let a = map.to_comp(map.area.min); + let b = map.to_comp(map.area.max); + effectcraft_engine::render::extended_region(comp.width, comp.height, [a[0], a[1], b[0], b[1]], 1.0) +} + +fn rect_of(map: &ViewerMap, r: Option<[f64; 4]>) -> Rect { + let [x, y, w, h] = r.unwrap_or_default(); + Rect::from_min_max(map.to_screen([x, y]), map.to_screen([x + w, y + h])) +} + /// Put a rendered frame on screen: CPU pixels go into an egui texture; GPU frames are drawn /// straight from their wgpu texture (registered with egui-wgpu, no readback). fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage) { @@ -1672,7 +1709,7 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: } app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k)); app.viewer_image = Some(img); - vt::set_texture_roi(ctx, app.session.state.region_of_interest); + vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended)); } FrameImage::Gpu(f) => { let Some(rs) = &app.wgpu else { return }; diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 0ce1b29c..18f67973 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -694,7 +694,13 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") { let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()})); } - x = r.max.x + 6.0; + x = r.max.x + 4.0; + // Extended Viewer (Settings ▸ 3D): custom views and Draft 3D show past the comp frame. + let on = app.session.prefs.three_d.extended_viewer; + if tog(ui, &mut app.auto, &mut x, Icon::ExtendedViewer, on, "extendedViewer", "Extended Viewer").0 { + let _ = app.session.execute("view.extendedViewer", json!({})); + } + x += 2.0; } // Current time. let tc = crate::panels::timecode(&app.session, comp, time); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 07715d16..584cdd95 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -299,6 +299,10 @@ pub struct ViewerState { /// The pointer is dragging in the viewer this frame (Adaptive Resolution). #[serde(skip)] pub interacting: bool, + /// Extended Viewer: the comp-space region the viewer renders this frame (comp frame plus + /// the visible pasteboard), set while a 3D view shows past the frame. + #[serde(skip)] + pub extended: Option<[f64; 4]>, /// View ▸ Panel Background Color (`None` = theme default). pub pasteboard: Option<[u8; 3]>, pub custom_pasteboard: [u8; 3], @@ -326,6 +330,7 @@ impl Default for ViewerState { ruler_origin: [0.0, 0.0], roi_draw: false, interacting: false, + extended: None, pasteboard: None, custom_pasteboard: [0x80, 0x80, 0x80], } diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 9cd7e04f..385bf7ac 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -116,3 +116,51 @@ fn snapshot() { img.save(format!("{d}/ui3d_reveal_rotation.png")).unwrap(); } } + +/// Settings ▸ 3D ▸ Extended Viewer: a custom 3D view renders the pasteboard around the comp +/// frame, and 3D layers reaching past the frame draw pixels there. +#[test] +fn extended_viewer_renders_past_the_comp_frame() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("view.3d.custom1", json!({})).unwrap(); + // Zoom out so the viewer shows plenty of pasteboard. + h.state_mut().ui.viewer.zoom = Some(0.15); + settle(&mut h); + assert!(h.state().ui.viewer.extended.is_none(), "off by default"); + 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 == "viewer.extendedViewer"), "toggle registered"); + assert_eq!(h.state_mut().session.execute("view.extendedViewer", json!({"value": true})).unwrap(), json!(true)); + h.step(); + settle(&mut h); + h.step(); + settle(&mut h); + let region = h.state().ui.viewer.extended.expect("extended region in a custom view"); + let (cw, ch) = { + let c = h.state().session.active_comp().unwrap(); + (c.width as f64, c.height as f64) + }; + assert!(region[0] < 0.0 && region[1] < 0.0 && region[0] + region[2] > cw && region[1] + region[3] > ch, "{region:?}"); + 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 == "viewer.extendedArea")); + let img = h.state_mut().viewer_pixels().expect("frame"); + let s = img.size[0] as f64 / region[2]; + assert!((img.size[1] as f64 / region[3] - s).abs() < 0.05, "frame covers the region"); + // Opaque pixels outside the comp rectangle. + let (fx0, fy0) = (-region[0] * s, -region[1] * s); + let (fx1, fy1) = (fx0 + cw * s, fy0 + ch * s); + let mut outside = 0; + for y in 0..img.size[1] { + for x in 0..img.size[0] { + let (xf, yf) = (x as f64 + 0.5, y as f64 + 0.5); + if (xf < fx0 - 1.0 || xf > fx1 + 1.0 || yf < fy0 - 1.0 || yf > fy1 + 1.0) && img.pixels[y * img.size[0] + x].a() > 0 { + outside += 1; + } + } + } + assert!(outside > 50, "pixels drawn on the pasteboard: {outside}"); + // The Active Camera view (Draft 3D off) shows just the frame. + h.state_mut().session.execute("view.3d.activeCamera", json!({})).unwrap(); + h.step(); + h.step(); + assert!(h.state().ui.viewer.extended.is_none()); +}