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}