Skip to main content

siplot/widget/
scene_widget.rs

1//! [`SceneWidget`] — an interactive 3D scene inside an egui `Ui`.
2//!
3//! The plot3d analogue of [`crate::widget::plot_widget::PlotView`]: it owns a
4//! [`Camera`], the scene bounds, and the scene geometry; on each frame it
5//! handles orbit/pan/zoom pointer interaction (driven by the pure helpers in
6//! [`crate::core::scene3d::interaction`]) and registers the wgpu paint callback
7//! ([`paint_scene3d`]) that renders the scene offscreen and blits it in.
8//!
9//! Port of silx `Plot3DWidget` + `SceneWidget`'s default `RotateCameraControl`:
10//! left-drag orbits around the scene centre, right-drag pans, the wheel zooms.
11//! The scene chrome (bounding box + RGB axes) is generated from the bounds via
12//! [`Scene3dGeometry::add_bounding_box_with_axes`]; data-item geometry set with
13//! [`SceneWidget::set_geometry`] is merged in beneath the chrome (every channel,
14//! via [`Scene3dGeometry::extend_from`]).
15
16use egui::{Color32, PointerButton, Pos2, Response, Sense, Ui};
17use egui_wgpu::RenderState;
18
19use crate::core::scene3d::camera::{Camera, CameraDirection, CameraFace};
20use crate::core::scene3d::interaction::{OrbitDrag, PanDrag, window_to_ndc};
21use crate::core::scene3d::mat4::Vec3;
22use crate::core::scene3d::pick::{picking_segment, segment_triangles_intersection};
23use crate::render::gpu_scene3d::{
24    Scene3dGeometry, Scene3dId, install_scene3d, paint_scene3d, set_scene3d, snapshot_scene3d,
25};
26
27/// Default scene background (a dark neutral grey, as in silx's 3D views).
28const DEFAULT_BACKGROUND: Color32 = Color32::from_gray(30);
29/// Default bounding-box / wireframe stroke colour.
30const DEFAULT_BOX_COLOR: Color32 = Color32::from_gray(200);
31
32/// An interactive 3D scene widget. Construct with [`SceneWidget::new`], optionally
33/// set the data bounds and content geometry, then call [`SceneWidget::show`] each
34/// frame.
35pub struct SceneWidget {
36    id: Scene3dId,
37    camera: Camera,
38    /// Axis-aligned scene bounds `(min, max)`; the chrome and camera framing
39    /// derive from these.
40    bounds: (Vec3, Vec3),
41    box_color: Color32,
42    background: Color32,
43    /// Data-item geometry (excludes the box/axes chrome, which is regenerated
44    /// from `bounds` on every upload). Empty until [`SceneWidget::set_geometry`].
45    content: Scene3dGeometry,
46    /// In-progress orbit drag (left button), if any.
47    orbit: Option<OrbitDrag>,
48    /// In-progress pan drag (right button), if any.
49    pan: Option<PanDrag>,
50}
51
52impl SceneWidget {
53    /// Create a scene widget bound to `id`, installing the 3D GPU resources into
54    /// `render_state` if needed. Starts with a unit-box scene framed from the
55    /// silx "side" viewpoint.
56    pub fn new(render_state: &RenderState, id: Scene3dId) -> Self {
57        install_scene3d(render_state);
58
59        let bounds = (Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0));
60        let mut camera = Camera::new(
61            30.0,
62            0.1,
63            100.0,
64            (1.0, 1.0),
65            Vec3::new(0.0, 0.0, 1.0),
66            Vec3::new(0.0, 0.0, -1.0),
67            Vec3::new(0.0, 1.0, 0.0),
68        );
69        // Default to the silx "side" three-quarter view, then frame the bounds.
70        camera.extrinsic.reset(CameraFace::Side);
71        camera.reset_camera(bounds);
72        camera.adjust_depth_extent(bounds);
73
74        let widget = SceneWidget {
75            id,
76            camera,
77            bounds,
78            box_color: DEFAULT_BOX_COLOR,
79            background: DEFAULT_BACKGROUND,
80            content: Scene3dGeometry::new(),
81            orbit: None,
82            pan: None,
83        };
84        widget.upload(render_state);
85        widget
86    }
87
88    /// Set the scene background colour (used to clear the offscreen target).
89    pub fn set_background(&mut self, color: Color32) {
90        self.background = color;
91    }
92
93    /// The scene's centre of bounds (centre of rotation for orbit/pan).
94    pub fn center(&self) -> Vec3 {
95        (self.bounds.0 + self.bounds.1) * 0.5
96    }
97
98    /// Read-only access to the camera.
99    pub fn camera(&self) -> &Camera {
100        &self.camera
101    }
102
103    /// Mutable access to the camera (e.g. to apply a viewpoint preset).
104    pub fn camera_mut(&mut self) -> &mut Camera {
105        &mut self.camera
106    }
107
108    /// Set the axis-aligned scene bounds, re-frame the camera, and re-upload the
109    /// chrome geometry.
110    pub fn set_bounds(&mut self, render_state: &RenderState, bounds: (Vec3, Vec3)) {
111        self.bounds = bounds;
112        self.camera.reset_camera(bounds);
113        self.camera.adjust_depth_extent(bounds);
114        self.upload(render_state);
115    }
116
117    /// Set the scene bounds and re-upload the chrome **without** re-framing the
118    /// camera, so the user's current orbit/zoom is preserved. Used when the data
119    /// changes but the viewpoint should stay put — silx re-frames (`centerScene`)
120    /// only on the first `setData`, not on subsequent updates. The depth frustum
121    /// is still adjusted so the new bounds stay clipped correctly.
122    pub fn set_bounds_keep_view(&mut self, render_state: &RenderState, bounds: (Vec3, Vec3)) {
123        self.bounds = bounds;
124        self.camera.adjust_depth_extent(bounds);
125        self.upload(render_state);
126    }
127
128    /// Replace the data-item geometry (the box/axes chrome is kept) and re-upload.
129    pub fn set_geometry(&mut self, render_state: &RenderState, geometry: Scene3dGeometry) {
130        self.content = geometry;
131        self.upload(render_state);
132    }
133
134    /// Re-frame the camera to the current bounds without changing orientation.
135    pub fn reset_camera(&mut self) {
136        self.camera.reset_camera(self.bounds);
137        self.camera.adjust_depth_extent(self.bounds);
138    }
139
140    /// Set the camera to one of the predefined viewpoints (front/back/left/
141    /// right/top/bottom/side) and re-frame the scene. Port of silx
142    /// `_SetViewpointAction`: `camera.extrinsic.reset(face)` followed by
143    /// `centerScene()`.
144    pub fn set_viewpoint(&mut self, face: CameraFace) {
145        self.camera.extrinsic.reset(face);
146        self.camera.reset_camera(self.bounds);
147        self.camera.adjust_depth_extent(self.bounds);
148    }
149
150    /// Orbit the scene about its centre around the vertical axis by
151    /// `angle_degrees` (positive = the silx "left" orbit direction). Port of
152    /// silx `RotateViewpoint`'s per-frame `viewport.orbitCamera("left", angle)`;
153    /// the caller drives the animation (e.g. `angle = deg_per_sec * dt` each
154    /// frame, requesting a repaint). The depth frustum is re-adjusted so the
155    /// scene stays clipped correctly.
156    pub fn rotate_scene(&mut self, angle_degrees: f32) {
157        let center = self.center();
158        self.camera
159            .extrinsic
160            .orbit(CameraDirection::Left, center, angle_degrees);
161        self.camera.adjust_depth_extent(self.bounds);
162    }
163
164    /// Build the combined geometry (chrome + content) and upload it for this
165    /// scene id.
166    fn upload(&self, render_state: &RenderState) {
167        let mut geometry = Scene3dGeometry::new();
168        geometry.add_bounding_box_with_axes(self.bounds, self.box_color);
169        // Append every data-item channel beneath the chrome (points, meshes,
170        // images, and textured meshes too — not only lines/triangles), so the
171        // P1.x/P2.x items render through the widget.
172        geometry.extend_from(&self.content);
173        set_scene3d(render_state, self.id, &geometry);
174    }
175
176    /// Lay out the scene over the available space, handle interaction, and paint.
177    /// Returns the egui [`Response`] for the scene rect.
178    pub fn show(&mut self, ui: &mut Ui) -> Response {
179        let (rect, response) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
180        let ppp = ui.ctx().pixels_per_point();
181        let size_px = (
182            (rect.width() * ppp).max(1.0),
183            (rect.height() * ppp).max(1.0),
184        );
185        // Keep the camera aspect in sync so interaction un-projection matches the
186        // rendered frame (paint_scene3d uses the same physical pixel size).
187        self.camera.set_size(size_px);
188        let center = self.center();
189
190        // Pointer position in physical pixels relative to the scene rect's origin.
191        let to_local = |p: Pos2| ((p.x - rect.min.x) * ppp, (p.y - rect.min.y) * ppp);
192        // Where the button went down. A drag is only *recognised* after the pointer
193        // clears egui's click-vs-drag threshold, by which point
194        // `interact_pointer_pos` has already moved; anchoring the orbit/pan at the
195        // press origin keeps that threshold travel from being silently dropped.
196        let press_origin = ui.ctx().input(|i| i.pointer.press_origin());
197
198        // Orbit — left drag.
199        if response.drag_started_by(PointerButton::Primary)
200            && let Some(p) = press_origin
201        {
202            self.orbit = Some(OrbitDrag::begin(&self.camera, to_local(p), center));
203        }
204        if response.dragged_by(PointerButton::Primary)
205            && let (Some(orbit), Some(p)) = (self.orbit, response.interact_pointer_pos())
206        {
207            orbit.update(&mut self.camera, to_local(p), size_px);
208        }
209        if response.drag_stopped_by(PointerButton::Primary) {
210            self.orbit = None;
211        }
212
213        // Pan — right drag.
214        if response.drag_started_by(PointerButton::Secondary)
215            && let Some(p) = press_origin
216        {
217            self.pan = Some(PanDrag::begin(&self.camera, to_local(p), size_px, center));
218        }
219        if response.dragged_by(PointerButton::Secondary)
220            && let (Some(mut pan), Some(p)) = (self.pan, response.interact_pointer_pos())
221        {
222            pan.update(&mut self.camera, to_local(p), size_px);
223            self.pan = Some(pan);
224        }
225        if response.drag_stopped_by(PointerButton::Secondary) {
226            self.pan = None;
227        }
228
229        // Zoom — wheel while hovering.
230        let scroll = ui.input(|i| i.smooth_scroll_delta.y);
231        if scroll != 0.0
232            && let Some(p) = response.hover_pos()
233        {
234            let (nx, ny) = window_to_ndc(to_local(p), size_px);
235            let ndc_z = self.camera.matrix().transform_point(center, true).z;
236            self.camera.zoom_at((nx, ny), ndc_z, scroll > 0.0);
237        }
238
239        // Keep the scene within the depth frustum after any interaction.
240        self.camera.adjust_depth_extent(self.bounds);
241
242        paint_scene3d(ui, rect, self.id, &self.camera, self.background);
243        response
244    }
245
246    /// Render the current scene at `size_px` physical pixels from the widget's
247    /// camera, returning it as tightly packed RGBA8 (`width * height * 4`, top
248    /// row first), or `None` if the GPU readback fails. Off-screen and
249    /// synchronous — independent of the egui frame loop — so it suits saving a
250    /// scene to an image file (pair with [`crate::encode_png`]).
251    pub fn snapshot(&self, render_state: &RenderState, size_px: (u32, u32)) -> Option<Vec<u8>> {
252        snapshot_scene3d(
253            render_state,
254            self.id,
255            &self.camera,
256            self.background,
257            size_px,
258        )
259    }
260
261    /// Pick the scene geometry under a click at normalized device coordinates
262    /// `ndc` (`x, y ∈ [-1, 1]`; convert a widget-local pixel with
263    /// [`window_to_ndc`]). Returns the nearest hit (smallest NDC depth) among the
264    /// data surfaces and scatter points, or `None` if the ray misses everything
265    /// or the camera is singular.
266    ///
267    /// Port of silx `SceneWidget.pickItems` reduced to the data the
268    /// [`ScalarFieldView`](crate::ScalarFieldView) flagship draws: it builds the
269    /// picking segment ([`picking_segment`]) and intersects it with the data
270    /// triangles ([`segment_triangles_intersection`] over
271    /// `Scene3dGeometry::pick_triangles` — flat fills, lit meshes, iso-surfaces);
272    /// scatter points are hit-tested by projecting each to NDC and keeping those
273    /// within [`PICK_POINT_TOLERANCE_PX`] of the click. The bounding-box / axes
274    /// chrome is excluded (it is not part of the data content), matching silx
275    /// picking scene items rather than the frame.
276    ///
277    /// Uses the camera's current viewport size, so call after [`SceneWidget::show`]
278    /// has run this frame (it syncs the camera aspect to the rendered rect).
279    pub fn pick(&self, ndc: (f32, f32)) -> Option<ScenePick> {
280        let segment = picking_segment(&self.camera, ndc)?;
281        let mvp = self.camera.matrix();
282
283        let mut best: Option<ScenePick> = None;
284        let mut consider = |cand: ScenePick| {
285            if best.is_none_or(|b| cand.ndc_depth < b.ndc_depth) {
286                best = Some(cand);
287            }
288        };
289
290        // Surfaces: the nearest triangle hit (the list is depth-sorted).
291        let triangles = self.content.pick_triangles();
292        if let Some(hit) = segment_triangles_intersection(segment, &triangles).first() {
293            let position = hit.position(segment.0, segment.1);
294            let ndc_depth = mvp.transform_point(position, true).z;
295            consider(ScenePick {
296                position,
297                ndc_depth,
298                kind: ScenePickKind::Surface,
299            });
300        }
301
302        // Scatter points: nearest within the click tolerance, in front of the camera.
303        let (vw, vh) = self.camera.size();
304        let radius_ndc_x = 2.0 * PICK_POINT_TOLERANCE_PX / vw.max(1.0);
305        let radius_ndc_y = 2.0 * PICK_POINT_TOLERANCE_PX / vh.max(1.0);
306        for (index, world) in self.content.pick_points().into_iter().enumerate() {
307            let p = mvp.transform_point(world, true);
308            if !(-1.0..=1.0).contains(&p.z) {
309                continue; // outside the depth frustum (behind camera / clipped)
310            }
311            let dx = (p.x - ndc.0) / radius_ndc_x;
312            let dy = (p.y - ndc.1) / radius_ndc_y;
313            if dx * dx + dy * dy <= 1.0 {
314                consider(ScenePick {
315                    position: world,
316                    ndc_depth: p.z,
317                    kind: ScenePickKind::Point { index },
318                });
319            }
320        }
321
322        best
323    }
324}
325
326/// Pixel tolerance for scatter-point picking: a point is pickable when it
327/// projects within this many pixels of the click. silx tests against the
328/// marker footprint; a fixed tolerance is a documented simplification (the
329/// per-point marker size is not threaded into the pick path).
330pub const PICK_POINT_TOLERANCE_PX: f32 = 7.0;
331
332/// What [`SceneWidget::pick`] hit.
333#[derive(Clone, Copy, Debug, PartialEq)]
334pub enum ScenePickKind {
335    /// A data surface (a triangle of a fill, lit mesh, or iso-surface).
336    Surface,
337    /// A scatter point, with its index in the points channel.
338    Point { index: usize },
339}
340
341/// The nearest scene hit from [`SceneWidget::pick`].
342#[derive(Clone, Copy, Debug, PartialEq)]
343pub struct ScenePick {
344    /// World-space position of the hit.
345    pub position: Vec3,
346    /// NDC depth `z ∈ [-1, 1]` of the hit (smaller is nearer the camera); the
347    /// key used to choose the nearest across surfaces and points.
348    pub ndc_depth: f32,
349    /// Which channel was hit.
350    pub kind: ScenePickKind,
351}
352
353/// The seven predefined viewpoints in silx's menu order, each with its silx menu
354/// label and tooltip (`actions/viewpoint.py`).
355const VIEWPOINT_PRESETS: [(CameraFace, &str, &str); 7] = [
356    (CameraFace::Front, "Front", "View along the -Z axis"),
357    (CameraFace::Back, "Back", "View along the +Z axis"),
358    (CameraFace::Top, "Top", "View along the -Y axis"),
359    (CameraFace::Bottom, "Bottom", "View along the +Y axis"),
360    (CameraFace::Right, "Right", "View along the -X axis"),
361    (CameraFace::Left, "Left", "View along the +X axis"),
362    (CameraFace::Side, "Side", "Side view"),
363];
364
365/// Draw a viewpoint drop-down menu button (port of silx
366/// `tools.ViewpointTools.ViewpointToolButton`): a `View` button whose menu sets
367/// one of the seven predefined viewpoints on `scene`. Returns the chosen
368/// [`CameraFace`] when a preset is selected this frame, otherwise `None`.
369pub fn viewpoint_menu(ui: &mut Ui, scene: &mut SceneWidget) -> Option<CameraFace> {
370    let mut chosen = None;
371    ui.menu_button("View", |ui| {
372        for (face, label, tip) in VIEWPOINT_PRESETS {
373            if ui.button(label).on_hover_text(tip).clicked() {
374                scene.set_viewpoint(face);
375                chosen = Some(face);
376                ui.close();
377            }
378        }
379    })
380    .response
381    .on_hover_text("Reset the viewpoint to a defined position");
382    chosen
383}