1use 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#[derive(Debug, Clone)]
53pub struct ViewOpts {
54 pub title: String,
56 pub width: u32,
58 pub height: u32,
60 pub background: [f32; 4],
62 pub ambient: f32,
64 pub edges: bool,
66 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 #[must_use]
87 pub fn new(title: impl Into<String>) -> Self {
88 Self {
89 title: title.into(),
90 ..Self::default()
91 }
92 }
93}
94
95pub 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 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#[derive(Debug, Clone, Copy)]
126struct OrbitCamera {
127 target: Point3,
128 azimuth: f64,
130 elevation: f64,
132 distance: f64,
134 radius: f64,
137 fov_y: f64,
139}
140
141const ELEVATION_LIMIT: f64 = std::f64::consts::FRAC_PI_2 - 0.01;
143
144impl OrbitCamera {
145 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 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 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 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 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 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 fn pan(&mut self, dx: f64, dy: f64) {
223 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 self.target = self.target + right * (-dx * scale) + cam_up * (dy * scale);
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240enum DragMode {
241 None,
242 Orbit,
243 Pan,
244}
245
246struct 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
302struct 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 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 fn aspect(&self) -> f64 {
338 aspect_of(self.config.width, self.config.height)
339 }
340}
341
342fn aspect_of(width: u32, height: u32) -> f64 {
344 f64::from(width.max(1)) / f64::from(height.max(1))
345}
346
347struct ViewerApp {
351 mesh: RenderMesh,
352 opts: ViewOpts,
353 orbit: OrbitCamera,
354 gpu: Option<GpuState>,
355 error: Option<RenderError>,
356
357 modifiers: ModifiersState,
359 cursor: PhysicalPosition<f64>,
360 drag: DragMode,
361 right_drag: bool,
362 press_pos: Option<PhysicalPosition<f64>>,
365 moved_while_pressed: bool,
366 selected_id: u32,
368}
369
370const CLICK_SLOP: f64 = 4.0;
373
374impl ViewerApp {
375 fn new(mesh: RenderMesh, opts: ViewOpts) -> Self {
376 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 fn into_result(self) -> Result<(), RenderError> {
409 match self.error {
410 Some(e) => Err(e),
411 None => Ok(()),
412 }
413 }
414
415 fn request_redraw(&self) {
417 if let Some(gpu) = self.gpu.as_ref() {
418 gpu.window.request_redraw();
419 }
420 }
421
422 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 fn init_gpu(&self, window: Arc<Window>) -> Result<GpuState, RenderError> {
432 let instance = wgpu::Instance::default();
433 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 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 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 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 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 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 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 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 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 log::warn!("brepkit-render: face pick readback failed: {e}");
597 return false;
598 }
599 };
600 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 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 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 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
739fn 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
750fn 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 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
822fn 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 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 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 assert!(
879 whole_model_visible(&cam),
880 "initial framing must show the whole model"
881 );
882
883 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 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}