mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 03:57:44 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ed46bc0cce
commit
baf4716246
@@ -89,6 +89,18 @@ fn share_view_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -301,6 +301,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
||||
"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" => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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<Geo> {
|
||||
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<Geo> {
|
||||
// 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<Geo> = 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()
|
||||
};
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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<String> = 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<String> = 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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user