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:
Brandon Thomas
2026-10-03 05:14:12 -04:00
co-authored by Claude Opus 5.5
parent ed46bc0cce
commit baf4716246
11 changed files with 219 additions and 16 deletions
+13
View File
@@ -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),
]
}
+1
View File
@@ -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" => {
+50 -7
View File
@@ -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)
}
+30
View File
@@ -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]));
}
+6 -4
View File
@@ -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()
};
+8
View File
@@ -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)]);
+12 -2
View File
@@ -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,
+39 -2
View File
@@ -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 };
+7 -1
View File
@@ -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);
+5
View File
@@ -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],
}
+48
View File
@@ -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());
}