Skip to main content

brepkit_render/
viewer.rs

1//! Interactive viewer window (winit surface + orbit camera + click-to-pick).
2//!
3//! [`view_solid`] opens a window that renders a solid with the same Lambert
4//! mesh + crisp-edge pipeline as the offscreen path ([`crate::pipeline`]), but
5//! to a swapchain surface instead of a texture. It adds:
6//!
7//! - **Orbit camera:** left-drag orbits (azimuth/elevation), the scroll wheel
8//!   dollies in/out, and right-drag (or shift + left-drag) pans the target.
9//! - **Click-to-pick:** a left click that does not drag reads the per-pixel
10//!   face-id target under the cursor and highlights the picked
11//!   [`FaceId`](brepkit_topology::face::FaceId) by tinting it in the shader.
12//!
13//! The face-id target is the same `R32Uint` buffer the offscreen renderer
14//! produces, so a click yields the kernel `FaceId` directly — the CAD payoff.
15//!
16//! # Running
17//!
18//! This needs a display server and the `window` feature:
19//!
20//! ```text
21//! cargo run -p brepkit-render --example viewer --features window
22//! ```
23//!
24//! It cannot run headlessly (no surface without a display); the offscreen path
25//! ([`crate::render_solid_offscreen`]) covers headless rendering.
26//!
27//! # Precision
28//!
29//! Like the offscreen path, geometry is uploaded relative to the model's AABB
30//! center (RTC) and the f64 center is folded into the view matrix, so models
31//! far from the origin stay crisp.
32
33use std::sync::Arc;
34
35use brepkit_math::vec::{Point3, Vec3};
36use brepkit_topology::Topology;
37use brepkit_topology::solid::SolidId;
38use winit::application::ApplicationHandler;
39use winit::dpi::{PhysicalPosition, PhysicalSize};
40use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
41use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
42use winit::keyboard::ModifiersState;
43use winit::window::{Window, WindowId};
44
45use crate::DEFAULT_DEFLECTION;
46use crate::camera::Camera;
47use crate::error::RenderError;
48use crate::mesh::RenderMesh;
49use crate::pipeline::{self, DEPTH_FORMAT, GeometryBuffers, GlobalsBinding, ID_FORMAT, Pipelines};
50
51/// Options controlling an interactive viewer window.
52#[derive(Debug, Clone)]
53pub struct ViewOpts {
54    /// Window title.
55    pub title: String,
56    /// Initial window width in physical pixels.
57    pub width: u32,
58    /// Initial window height in physical pixels.
59    pub height: u32,
60    /// Background clear color as linear RGBA in `[0, 1]`.
61    pub background: [f32; 4],
62    /// Ambient light fraction in `[0, 1]`.
63    pub ambient: f32,
64    /// Draw topological edges as crisp dark lines.
65    pub edges: bool,
66    /// Linear chord tolerance for tessellation (smaller = finer mesh).
67    pub deflection: f64,
68}
69
70impl Default for ViewOpts {
71    fn default() -> Self {
72        Self {
73            title: "brepkit viewer".to_string(),
74            width: 1024,
75            height: 768,
76            background: [0.11, 0.12, 0.14, 1.0],
77            ambient: 0.25,
78            edges: true,
79            deflection: DEFAULT_DEFLECTION,
80        }
81    }
82}
83
84impl ViewOpts {
85    /// Default options with a custom title.
86    #[must_use]
87    pub fn new(title: impl Into<String>) -> Self {
88        Self {
89            title: title.into(),
90            ..Self::default()
91        }
92    }
93}
94
95/// Open an interactive window showing `solid`, blocking until it is closed.
96///
97/// Left-drag orbits, scroll zooms, right-drag (or shift + left-drag) pans, and a
98/// left click highlights the picked face. Tessellation happens once up front;
99/// only the camera and selection change per frame.
100///
101/// # Errors
102///
103/// - [`RenderError::Operations`] / [`RenderError::Topology`] if the solid
104///   cannot be tessellated.
105/// - [`RenderError::EventLoop`] if the windowing event loop fails to start.
106/// - [`RenderError::NoAdapter`] / [`RenderError::DeviceRequest`] /
107///   [`RenderError::SurfaceConfig`] on GPU/surface setup failure (surfaced from
108///   inside the loop).
109pub fn view_solid(topo: &Topology, solid: SolidId, opts: &ViewOpts) -> Result<(), RenderError> {
110    let mesh = RenderMesh::build(topo, solid, opts.deflection)?;
111
112    let event_loop = EventLoop::new().map_err(|e| RenderError::EventLoop(e.to_string()))?;
113    // Wait for events rather than busy-looping; we explicitly request redraws on
114    // interaction and resize.
115    event_loop.set_control_flow(ControlFlow::Wait);
116
117    let mut app = ViewerApp::new(mesh, opts.clone());
118    event_loop
119        .run_app(&mut app)
120        .map_err(|e| RenderError::EventLoop(e.to_string()))?;
121    app.into_result()
122}
123
124/// An orbit camera parameterized by spherical coordinates around a target.
125#[derive(Debug, Clone, Copy)]
126struct OrbitCamera {
127    target: Point3,
128    /// Horizontal angle (radians) about the world +Z axis.
129    azimuth: f64,
130    /// Vertical angle (radians) from the XY plane, clamped away from the poles.
131    elevation: f64,
132    /// Eye distance from the target.
133    distance: f64,
134    /// Model bounding-sphere radius; sets the scale for the clip planes so they
135    /// track `distance` as the user zooms (see [`OrbitCamera::clip_planes`]).
136    radius: f64,
137    /// Vertical field of view (radians).
138    fov_y: f64,
139}
140
141/// Keep elevation a hair below the poles so the up vector never degenerates.
142const ELEVATION_LIMIT: f64 = std::f64::consts::FRAC_PI_2 - 0.01;
143
144impl OrbitCamera {
145    /// Frame a model of bounding-sphere `radius` centered at `target` from an
146    /// isometric-ish direction, matching the offscreen test's framing.
147    fn framing(target: Point3, radius: f64) -> Self {
148        let fov_y = 40.0_f64.to_radians();
149        let radius = radius.max(1e-6);
150        let distance = radius / (fov_y * 0.5).sin() * 2.0;
151        Self {
152            target,
153            azimuth: 45.0_f64.to_radians(),
154            elevation: 30.0_f64.to_radians(),
155            distance,
156            radius,
157            fov_y,
158        }
159    }
160
161    /// Unit direction from the target toward the eye.
162    fn eye_dir(&self) -> Vec3 {
163        let ce = self.elevation.cos();
164        Vec3::new(
165            ce * self.azimuth.cos(),
166            ce * self.azimuth.sin(),
167            self.elevation.sin(),
168        )
169    }
170
171    /// Near/far clip planes for the current eye distance.
172    ///
173    /// Derived from `distance` (not cached) so the model stays inside the
174    /// frustum across the whole zoom range: the model spans roughly
175    /// `[distance - radius, distance + radius]` from the eye, so near sits just
176    /// inside the near surface and far just beyond the far surface. Both are
177    /// kept strictly positive with `near < far`.
178    fn clip_planes(&self) -> (f64, f64) {
179        let near = (self.distance - self.radius).max(self.distance * 0.01);
180        let near = near.max(1e-4);
181        let far = (self.distance + self.radius * 4.0).max(near * 10.0);
182        (near, far)
183    }
184
185    /// Build the f64 [`Camera`] for the current orbit state at `aspect`.
186    fn camera(&self, aspect: f64) -> Camera {
187        let eye = self.target + self.eye_dir() * self.distance;
188        let (near, far) = self.clip_planes();
189        Camera {
190            eye,
191            target: self.target,
192            up: Vec3::new(0.0, 0.0, 1.0),
193            fov_y: self.fov_y,
194            aspect,
195            near,
196            far,
197        }
198    }
199
200    /// Orbit by mouse deltas (pixels): horizontal drag changes azimuth,
201    /// vertical drag changes elevation (clamped away from the poles).
202    fn orbit(&mut self, dx: f64, dy: f64) {
203        const SPEED: f64 = 0.005;
204        self.azimuth -= dx * SPEED;
205        self.elevation = (self.elevation + dy * SPEED).clamp(-ELEVATION_LIMIT, ELEVATION_LIMIT);
206    }
207
208    /// Dolly toward/away from the target by a scroll amount (multiplicative so
209    /// zoom feels uniform regardless of current distance).
210    ///
211    /// The clip planes are recomputed from `distance` in [`OrbitCamera::camera`],
212    /// so zooming never leaves the model behind a stale near/far plane. Distance
213    /// is floored to a small fraction of the model radius so it can't collapse
214    /// to zero.
215    fn dolly(&mut self, amount: f64) {
216        let factor = (1.0 - amount * 0.1).clamp(0.2, 5.0);
217        self.distance = (self.distance * factor).max(self.radius * 0.05 + 1e-6);
218    }
219
220    /// Pan the target in the camera's view plane by mouse deltas (pixels),
221    /// scaled by distance so panning tracks the cursor at any zoom.
222    fn pan(&mut self, dx: f64, dy: f64) {
223        // eye_dir points target -> eye, so forward (eye -> target) is its negation.
224        let forward = -self.eye_dir();
225        let up = Vec3::new(0.0, 0.0, 1.0);
226        let right = forward
227            .cross(up)
228            .normalize()
229            .unwrap_or(Vec3::new(1.0, 0.0, 0.0));
230        let cam_up = right.cross(forward);
231        let scale = self.distance * 0.0015;
232        // Drag right -> scene moves right -> target moves left; drag down ->
233        // target moves up. Hence the sign choices below.
234        self.target = self.target + right * (-dx * scale) + cam_up * (dy * scale);
235    }
236}
237
238/// What a left-drag currently does, decided at press time by the modifiers.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240enum DragMode {
241    None,
242    Orbit,
243    Pan,
244}
245
246/// Size-dependent render targets: the depth buffer, the `R32Uint` id target
247/// (kept as a texture so it can be copied back for picking), and a scratch
248/// color target used by the on-demand id render during a pick.
249struct Targets {
250    depth_view: wgpu::TextureView,
251    id_texture: wgpu::Texture,
252    id_view: wgpu::TextureView,
253    pick_color_view: wgpu::TextureView,
254}
255
256impl Targets {
257    fn new(device: &wgpu::Device, format: wgpu::TextureFormat, width: u32, height: u32) -> Self {
258        let extent = wgpu::Extent3d {
259            width: width.max(1),
260            height: height.max(1),
261            depth_or_array_layers: 1,
262        };
263        let depth_tex = device.create_texture(&wgpu::TextureDescriptor {
264            label: Some("viewer depth"),
265            size: extent,
266            mip_level_count: 1,
267            sample_count: 1,
268            dimension: wgpu::TextureDimension::D2,
269            format: DEPTH_FORMAT,
270            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
271            view_formats: &[],
272        });
273        let id_texture = device.create_texture(&wgpu::TextureDescriptor {
274            label: Some("viewer id target"),
275            size: extent,
276            mip_level_count: 1,
277            sample_count: 1,
278            dimension: wgpu::TextureDimension::D2,
279            format: ID_FORMAT,
280            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
281            view_formats: &[],
282        });
283        let pick_color = device.create_texture(&wgpu::TextureDescriptor {
284            label: Some("viewer pick scratch color"),
285            size: extent,
286            mip_level_count: 1,
287            sample_count: 1,
288            dimension: wgpu::TextureDimension::D2,
289            format,
290            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
291            view_formats: &[],
292        });
293        Self {
294            depth_view: depth_tex.create_view(&wgpu::TextureViewDescriptor::default()),
295            id_view: id_texture.create_view(&wgpu::TextureViewDescriptor::default()),
296            id_texture,
297            pick_color_view: pick_color.create_view(&wgpu::TextureViewDescriptor::default()),
298        }
299    }
300}
301
302/// GPU + window state, created lazily in `resumed` once a window exists.
303struct GpuState {
304    window: Arc<Window>,
305    surface: wgpu::Surface<'static>,
306    device: wgpu::Device,
307    queue: wgpu::Queue,
308    config: wgpu::SurfaceConfiguration,
309    pipelines: Pipelines,
310    globals: GlobalsBinding,
311    geometry: GeometryBuffers,
312    targets: Targets,
313}
314
315impl GpuState {
316    /// Reconfigure the surface and rebuild size-dependent targets.
317    ///
318    /// The size is clamped to the device's max 2D texture dimension so an
319    /// oversized window never trips GPU validation on strict drivers.
320    fn resize(&mut self, size: PhysicalSize<u32>) {
321        if size.width == 0 || size.height == 0 {
322            return;
323        }
324        let max = self.device.limits().max_texture_dimension_2d;
325        self.config.width = size.width.min(max);
326        self.config.height = size.height.min(max);
327        self.surface.configure(&self.device, &self.config);
328        self.targets = Targets::new(
329            &self.device,
330            self.config.format,
331            self.config.width,
332            self.config.height,
333        );
334    }
335
336    /// Aspect ratio (width / height) of the current surface.
337    fn aspect(&self) -> f64 {
338        aspect_of(self.config.width, self.config.height)
339    }
340}
341
342/// Aspect ratio (width / height), guarding against a zero dimension.
343fn aspect_of(width: u32, height: u32) -> f64 {
344    f64::from(width.max(1)) / f64::from(height.max(1))
345}
346
347/// The winit application: owns the prepared geometry, options, orbit state, and
348/// (once resumed) the GPU/window state. Any setup error is stashed in `error`
349/// and the loop exits so `view_solid` can surface it.
350struct ViewerApp {
351    mesh: RenderMesh,
352    opts: ViewOpts,
353    orbit: OrbitCamera,
354    gpu: Option<GpuState>,
355    error: Option<RenderError>,
356
357    // Input state.
358    modifiers: ModifiersState,
359    cursor: PhysicalPosition<f64>,
360    drag: DragMode,
361    right_drag: bool,
362    /// Cursor position when the left button went down, to distinguish a click
363    /// (pick) from a drag (orbit/pan).
364    press_pos: Option<PhysicalPosition<f64>>,
365    moved_while_pressed: bool,
366    /// Encoded id (`FaceId.index() + 1`) of the highlighted face, or 0 for none.
367    selected_id: u32,
368}
369
370/// Pixel-distance threshold below which a press+release counts as a click, not
371/// a drag.
372const CLICK_SLOP: f64 = 4.0;
373
374impl ViewerApp {
375    fn new(mesh: RenderMesh, opts: ViewOpts) -> Self {
376        // Frame the model from its tessellated AABB (the mesh positions are in
377        // world space; center is the RTC origin).
378        let (min, max) = mesh_world_aabb(&mesh);
379        let center = Point3::new(
380            (min.x() + max.x()) * 0.5,
381            (min.y() + max.y()) * 0.5,
382            (min.z() + max.z()) * 0.5,
383        );
384        let radius = ((max.x() - min.x()).powi(2)
385            + (max.y() - min.y()).powi(2)
386            + (max.z() - min.z()).powi(2))
387        .sqrt()
388            * 0.5;
389        let orbit = OrbitCamera::framing(center, radius);
390
391        Self {
392            mesh,
393            opts,
394            orbit,
395            gpu: None,
396            error: None,
397            modifiers: ModifiersState::empty(),
398            cursor: PhysicalPosition::new(0.0, 0.0),
399            drag: DragMode::None,
400            right_drag: false,
401            press_pos: None,
402            moved_while_pressed: false,
403            selected_id: 0,
404        }
405    }
406
407    /// Consume the app and return any deferred setup error.
408    fn into_result(self) -> Result<(), RenderError> {
409        match self.error {
410            Some(e) => Err(e),
411            None => Ok(()),
412        }
413    }
414
415    /// Ask the window to redraw, if it exists yet.
416    fn request_redraw(&self) {
417        if let Some(gpu) = self.gpu.as_ref() {
418            gpu.window.request_redraw();
419        }
420    }
421
422    /// Record a setup error and ask the loop to exit.
423    fn fail(&mut self, event_loop: &ActiveEventLoop, err: RenderError) {
424        if self.error.is_none() {
425            self.error = Some(err);
426        }
427        event_loop.exit();
428    }
429
430    /// Build the GPU state for a freshly created window.
431    fn init_gpu(&self, window: Arc<Window>) -> Result<GpuState, RenderError> {
432        let instance = wgpu::Instance::default();
433        // An Arc<Window> is 'static and implements winit's window/display handle
434        // traits, which is exactly what wgpu 29's create_surface accepts.
435        let surface = instance
436            .create_surface(window.clone())
437            .map_err(|e| RenderError::SurfaceConfig(e.to_string()))?;
438
439        let ctx = pipeline::GpuContext::with_instance(instance, Some(&surface))?;
440
441        // Clamp the surface/target size to the device's max 2D texture
442        // dimension so an oversized window never trips GPU validation.
443        let max = ctx.device.limits().max_texture_dimension_2d;
444        let size = window.inner_size();
445        let width = size.width.clamp(1, max);
446        let height = size.height.clamp(1, max);
447
448        let caps = surface.get_capabilities(&ctx.adapter);
449        let format = choose_surface_format(&caps);
450        let config = wgpu::SurfaceConfiguration {
451            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
452            format,
453            color_space: wgpu::SurfaceColorSpace::Auto,
454            width,
455            height,
456            // Fifo (vsync) is guaranteed supported by the WebGPU spec; prefer it
457            // explicitly rather than trusting the first advertised mode.
458            present_mode: caps
459                .present_modes
460                .iter()
461                .copied()
462                .find(|m| *m == wgpu::PresentMode::Fifo)
463                .or_else(|| caps.present_modes.first().copied())
464                .unwrap_or(wgpu::PresentMode::Fifo),
465            desired_maximum_frame_latency: 2,
466            alpha_mode: caps
467                .alpha_modes
468                .first()
469                .copied()
470                .unwrap_or(wgpu::CompositeAlphaMode::Auto),
471            view_formats: vec![],
472        };
473        surface.configure(&ctx.device, &config);
474
475        let cam = self.orbit.camera(aspect_of(width, height));
476        let globals = pipeline::build_globals(&cam, self.mesh.center, self.opts.ambient);
477        let globals = GlobalsBinding::new(&ctx.device, &globals);
478        let pipeline_layout = ctx
479            .device
480            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
481                label: Some("viewer pipeline layout"),
482                bind_group_layouts: &[Some(&globals.layout)],
483                immediate_size: 0,
484            });
485        let with_edges = self.opts.edges && !self.mesh.edge_vertices.is_empty();
486        let pipelines = Pipelines::new(&ctx.device, &pipeline_layout, format, with_edges);
487        let geometry = GeometryBuffers::new(&ctx.device, &self.mesh);
488        let targets = Targets::new(&ctx.device, format, width, height);
489
490        Ok(GpuState {
491            window,
492            surface,
493            device: ctx.device,
494            queue: ctx.queue,
495            config,
496            pipelines,
497            globals,
498            geometry,
499            targets,
500        })
501    }
502
503    /// Upload the current camera + selection and draw one frame to the surface.
504    fn redraw(&mut self) {
505        let Some(gpu) = self.gpu.as_mut() else {
506            return;
507        };
508
509        let frame = match gpu.surface.get_current_texture() {
510            wgpu::CurrentSurfaceTexture::Success(f)
511            | wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
512            // Surface needs reconfiguring; do it and skip this frame (a fresh
513            // redraw is requested below).
514            wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
515                let size = PhysicalSize::new(gpu.config.width, gpu.config.height);
516                gpu.resize(size);
517                gpu.window.request_redraw();
518                return;
519            }
520            // Transient: skip and try again next frame.
521            wgpu::CurrentSurfaceTexture::Timeout
522            | wgpu::CurrentSurfaceTexture::Occluded
523            | wgpu::CurrentSurfaceTexture::Validation => return,
524        };
525
526        let cam = self.orbit.camera(gpu.aspect());
527        let mut globals = pipeline::build_globals(&cam, self.mesh.center, self.opts.ambient);
528        globals.selected_id = self.selected_id;
529        gpu.globals.upload(&gpu.queue, &globals);
530
531        let color_view = frame
532            .texture
533            .create_view(&wgpu::TextureViewDescriptor::default());
534
535        let mut encoder = gpu
536            .device
537            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
538                label: Some("viewer encoder"),
539            });
540        pipeline::encode_scene(
541            &mut encoder,
542            &gpu.pipelines,
543            &gpu.globals,
544            &gpu.geometry,
545            &pipeline::PassTargets {
546                color: &color_view,
547                id: &gpu.targets.id_view,
548                depth: &gpu.targets.depth_view,
549                background: self.opts.background,
550            },
551        );
552        gpu.queue.submit(Some(encoder.finish()));
553        gpu.window.pre_present_notify();
554        gpu.queue.present(frame);
555    }
556
557    /// Render the id buffer for the current camera and read the face id under
558    /// the cursor, updating the highlight. Returns whether the selection
559    /// changed (so the caller can request a redraw).
560    ///
561    /// The id pass is rendered fresh here rather than reusing the last frame's
562    /// id target, so a pick is correct even immediately after a resize (before
563    /// the next on-screen redraw).
564    fn pick(&mut self) -> bool {
565        let Some(gpu) = self.gpu.as_ref() else {
566            return false;
567        };
568        if gpu.config.width == 0 || gpu.config.height == 0 {
569            return false;
570        }
571        // Reject positions outside the viewport, then floor to a pixel index and
572        // clamp to the last in-bounds pixel so an in-bounds cursor (which can sit
573        // at exactly `width`/`height` at the far edge) never reads out of range.
574        let (w, h) = (gpu.config.width, gpu.config.height);
575        let cx = self.cursor.x;
576        let cy = self.cursor.y;
577        if cx < 0.0 || cy < 0.0 || cx >= f64::from(w) || cy >= f64::from(h) {
578            return false;
579        }
580        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
581        let px = (cx.floor() as u32).min(w - 1);
582        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
583        let py = (cy.floor() as u32).min(h - 1);
584
585        // Re-render the id target for the current camera (selection irrelevant
586        // to face ids), then read the one pixel.
587        let cam = self.orbit.camera(gpu.aspect());
588        let globals = pipeline::build_globals(&cam, self.mesh.center, self.opts.ambient);
589        gpu.globals.upload(&gpu.queue, &globals);
590
591        let picked = match render_and_read_id(gpu, &self.opts.background, px, py) {
592            Ok(id) => id,
593            Err(e) => {
594                // Non-fatal: a failed id readback (e.g. device lost) must not
595                // crash the viewer, but surface it so it isn't a silent no-op.
596                log::warn!("brepkit-render: face pick readback failed: {e}");
597                return false;
598            }
599        };
600        // Clicking the same face again clears the highlight.
601        let next = if picked == self.selected_id {
602            0
603        } else {
604            picked
605        };
606        if next == self.selected_id {
607            return false;
608        }
609        self.selected_id = next;
610        true
611    }
612}
613
614impl ApplicationHandler for ViewerApp {
615    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
616        if self.gpu.is_some() {
617            return;
618        }
619        let attrs = Window::default_attributes()
620            .with_title(self.opts.title.clone())
621            .with_inner_size(PhysicalSize::new(self.opts.width, self.opts.height));
622        let window = match event_loop.create_window(attrs) {
623            Ok(w) => Arc::new(w),
624            Err(e) => {
625                self.fail(event_loop, RenderError::EventLoop(e.to_string()));
626                return;
627            }
628        };
629        match self.init_gpu(window) {
630            Ok(gpu) => {
631                gpu.window.request_redraw();
632                self.gpu = Some(gpu);
633            }
634            Err(e) => self.fail(event_loop, e),
635        }
636    }
637
638    #[allow(clippy::too_many_lines)]
639    fn window_event(
640        &mut self,
641        event_loop: &ActiveEventLoop,
642        _window_id: WindowId,
643        event: WindowEvent,
644    ) {
645        match event {
646            WindowEvent::CloseRequested => event_loop.exit(),
647
648            WindowEvent::Resized(size) => {
649                if let Some(gpu) = self.gpu.as_mut() {
650                    gpu.resize(size);
651                    gpu.window.request_redraw();
652                }
653            }
654
655            WindowEvent::ModifiersChanged(mods) => {
656                self.modifiers = mods.state();
657            }
658
659            WindowEvent::MouseInput { state, button, .. } => match (button, state) {
660                (MouseButton::Left, ElementState::Pressed) => {
661                    self.press_pos = Some(self.cursor);
662                    self.moved_while_pressed = false;
663                    self.drag = if self.modifiers.shift_key() {
664                        DragMode::Pan
665                    } else {
666                        DragMode::Orbit
667                    };
668                }
669                (MouseButton::Left, ElementState::Released) => {
670                    // A press that never left the click slop (neither at release
671                    // nor at any point during the press) is a pick; anything that
672                    // dragged past the slop already moved the camera.
673                    let near_press = self.press_pos.is_some_and(|p| {
674                        (p.x - self.cursor.x).abs() <= CLICK_SLOP
675                            && (p.y - self.cursor.y).abs() <= CLICK_SLOP
676                    });
677                    let was_click = near_press && !self.moved_while_pressed;
678                    self.drag = DragMode::None;
679                    self.press_pos = None;
680                    if was_click && self.pick() {
681                        self.request_redraw();
682                    }
683                }
684                (MouseButton::Right, ElementState::Pressed) => self.right_drag = true,
685                (MouseButton::Right, ElementState::Released) => self.right_drag = false,
686                _ => {}
687            },
688
689            WindowEvent::CursorMoved { position, .. } => {
690                let dx = position.x - self.cursor.x;
691                let dy = position.y - self.cursor.y;
692                self.cursor = position;
693
694                // Right-drag always pans; otherwise the left-drag mode decides.
695                let changed = if self.right_drag {
696                    self.orbit.pan(dx, dy);
697                    true
698                } else {
699                    match self.drag {
700                        DragMode::Orbit => {
701                            self.orbit.orbit(dx, dy);
702                            true
703                        }
704                        DragMode::Pan => {
705                            self.orbit.pan(dx, dy);
706                            true
707                        }
708                        DragMode::None => false,
709                    }
710                };
711                if changed {
712                    // Only count as a drag (suppressing a pick) once the cursor
713                    // has moved beyond the click slop from where it was pressed;
714                    // sub-slop jitter must still register as a click.
715                    self.moved_while_pressed |= self.press_pos.is_some_and(|p| {
716                        (p.x - self.cursor.x).abs() > CLICK_SLOP
717                            || (p.y - self.cursor.y).abs() > CLICK_SLOP
718                    });
719                    self.request_redraw();
720                }
721            }
722
723            WindowEvent::MouseWheel { delta, .. } => {
724                let amount = match delta {
725                    MouseScrollDelta::LineDelta(_, y) => f64::from(y),
726                    MouseScrollDelta::PixelDelta(p) => p.y / 50.0,
727                };
728                self.orbit.dolly(amount);
729                self.request_redraw();
730            }
731
732            WindowEvent::RedrawRequested => self.redraw(),
733
734            _ => {}
735        }
736    }
737}
738
739/// Prefer an sRGB surface format (so shading matches the offscreen
740/// `Rgba8UnormSrgb` path); fall back to the surface's preferred format.
741fn choose_surface_format(caps: &wgpu::SurfaceCapabilities) -> wgpu::TextureFormat {
742    caps.formats
743        .iter()
744        .copied()
745        .find(wgpu::TextureFormat::is_srgb)
746        .or_else(|| caps.formats.first().copied())
747        .unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb)
748}
749
750/// Render the id target for the current (already-uploaded) globals, then copy
751/// the single row containing `(px, py)` back and decode that pixel.
752///
753/// The id target is `R32Uint`; the value is `FaceId.index() + 1` (or 0 for
754/// background). Rendering the id pass here makes the pick independent of the
755/// last on-screen frame. The scratch color target absorbs the color writes the
756/// shared pass also produces (only the id is read back).
757fn render_and_read_id(
758    gpu: &GpuState,
759    background: &[f32; 4],
760    px: u32,
761    py: u32,
762) -> Result<u32, RenderError> {
763    let padded_bpr = pipeline::padded_bytes_per_row(gpu.config.width, 4);
764
765    // Read back only the one row that holds the picked pixel.
766    let readback = gpu.device.create_buffer(&wgpu::BufferDescriptor {
767        label: Some("id pick readback"),
768        size: u64::from(padded_bpr),
769        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
770        mapped_at_creation: false,
771    });
772
773    let mut encoder = gpu
774        .device
775        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
776            label: Some("id pick encoder"),
777        });
778    pipeline::encode_scene(
779        &mut encoder,
780        &gpu.pipelines,
781        &gpu.globals,
782        &gpu.geometry,
783        &pipeline::PassTargets {
784            color: &gpu.targets.pick_color_view,
785            id: &gpu.targets.id_view,
786            depth: &gpu.targets.depth_view,
787            background: *background,
788        },
789    );
790    encoder.copy_texture_to_buffer(
791        wgpu::TexelCopyTextureInfo {
792            texture: &gpu.targets.id_texture,
793            mip_level: 0,
794            origin: wgpu::Origin3d { x: 0, y: py, z: 0 },
795            aspect: wgpu::TextureAspect::All,
796        },
797        wgpu::TexelCopyBufferInfo {
798            buffer: &readback,
799            layout: wgpu::TexelCopyBufferLayout {
800                offset: 0,
801                bytes_per_row: Some(padded_bpr),
802                rows_per_image: Some(1),
803            },
804        },
805        wgpu::Extent3d {
806            width: gpu.config.width,
807            height: 1,
808            depth_or_array_layers: 1,
809        },
810    );
811    gpu.queue.submit(Some(encoder.finish()));
812
813    let bytes = pipeline::map_and_read(&gpu.device, &readback)?;
814    let off = (px * 4) as usize;
815    let id = bytes
816        .get(off..off + 4)
817        .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
818        .unwrap_or(0);
819    Ok(id)
820}
821
822/// Axis-aligned bounding box of the mesh's world-space positions, recovered by
823/// adding the RTC center back to the uploaded center-relative vertices.
824fn mesh_world_aabb(mesh: &RenderMesh) -> (Point3, Point3) {
825    let mut min = [f64::INFINITY; 3];
826    let mut max = [f64::NEG_INFINITY; 3];
827    for v in &mesh.vertices {
828        let p = [
829            f64::from(v.position[0]) + mesh.center.x(),
830            f64::from(v.position[1]) + mesh.center.y(),
831            f64::from(v.position[2]) + mesh.center.z(),
832        ];
833        for i in 0..3 {
834            if p[i] < min[i] {
835                min[i] = p[i];
836            }
837            if p[i] > max[i] {
838                max[i] = p[i];
839            }
840        }
841    }
842    if !min[0].is_finite() {
843        return (Point3::new(-1.0, -1.0, -1.0), Point3::new(1.0, 1.0, 1.0));
844    }
845    (
846        Point3::new(min[0], min[1], min[2]),
847        Point3::new(max[0], max[1], max[2]),
848    )
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    /// The model spans `[distance - radius, distance + radius]` along the view
856    /// ray. Some of it must be visible (not entirely clipped) and the back must
857    /// not be clipped away, with a valid frustum throughout. (The near plane may
858    /// legitimately sit ahead of the model front when the eye is *inside* the
859    /// bounding sphere — that geometry is correctly behind the camera.)
860    fn model_visible(cam: &OrbitCamera) -> bool {
861        let (near, far) = cam.clip_planes();
862        let model_far = cam.distance + cam.radius;
863        near > 0.0 && near < far && near < model_far && far >= model_far
864    }
865
866    /// When the eye is outside the bounding sphere (the normal framing regime),
867    /// the near plane must also not clip the *front* of the model.
868    fn whole_model_visible(cam: &OrbitCamera) -> bool {
869        let (near, _far) = cam.clip_planes();
870        let model_near = cam.distance - cam.radius;
871        model_visible(cam) && near <= model_near + 1e-9
872    }
873
874    #[test]
875    fn clip_planes_valid_across_full_zoom_range() {
876        let mut cam = OrbitCamera::framing(Point3::new(10.0, 20.0, 30.0), 50.0);
877        // Initial framing sits well outside the model, so the whole model fits.
878        assert!(
879            whole_model_visible(&cam),
880            "initial framing must show the whole model"
881        );
882
883        // Zoom all the way in: repeated dolly-in must never produce an invalid
884        // frustum or clip the model entirely out of view.
885        for _ in 0..200 {
886            cam.dolly(1.0);
887            let (near, far) = cam.clip_planes();
888            assert!(near > 0.0, "near must stay positive (near={near})");
889            assert!(near < far, "near < far must hold (near={near} far={far})");
890            assert!(model_visible(&cam), "model must stay visible zooming in");
891        }
892
893        // Zoom all the way out: the model must remain fully framed (eye stays
894        // outside the bounding sphere), so this is the regime the P1 bug hit.
895        let mut cam = OrbitCamera::framing(Point3::new(0.0, 0.0, 0.0), 2.0);
896        for _ in 0..200 {
897            cam.dolly(-1.0);
898            assert!(
899                whole_model_visible(&cam),
900                "whole model must stay framed zooming out (distance={} near/far stale?)",
901                cam.distance
902            );
903        }
904    }
905
906    #[test]
907    fn dolly_floors_distance_above_zero() {
908        let mut cam = OrbitCamera::framing(Point3::new(0.0, 0.0, 0.0), 10.0);
909        for _ in 0..1000 {
910            cam.dolly(1.0);
911        }
912        assert!(
913            cam.distance > 0.0,
914            "distance must not collapse to zero (distance={})",
915            cam.distance
916        );
917    }
918
919    #[test]
920    fn tiny_radius_still_yields_valid_planes() {
921        let cam = OrbitCamera::framing(Point3::new(0.0, 0.0, 0.0), 1e-9);
922        let (near, far) = cam.clip_planes();
923        assert!(near > 0.0 && near < far, "near={near} far={far}");
924    }
925}