Skip to main content

dicomview_gpu/
engine.rs

1//! Multi-viewport orchestration on top of `volren-gpu`.
2
3use crate::incremental_texture::update_texture_slice_i16;
4use dicomview_core::{
5    preset, IncrementalVolume, IncrementalVolumeError, SlicePreviewMode, SlicePreviewState,
6    SliceProjectionMode, VolumeBlendMode, VolumeGeometry, VolumePresetId, VolumeViewState,
7};
8use glam::DVec3;
9use std::sync::Arc;
10use thiserror::Error;
11use volren_core::{
12    camera::{Camera, Projection},
13    render_params::{BlendMode, VolumeRenderParams},
14    transfer_function::{ColorTransferFunction, OpacityTransferFunction},
15    Aabb, WindowLevel,
16};
17use volren_gpu::{CrosshairParams, RenderError, Viewport, VolumeRenderer};
18
19/// One render target view paired with its viewport rectangle.
20pub struct RenderTarget<'a> {
21    /// The output texture view to render into.
22    pub view: &'a wgpu::TextureView,
23    /// The sub-viewport inside that texture.
24    pub viewport: Viewport,
25}
26
27/// The four targets required for one standard MPR + volume frame.
28pub struct FrameTargets<'a> {
29    /// Axial viewport target.
30    pub axial: RenderTarget<'a>,
31    /// Coronal viewport target.
32    pub coronal: RenderTarget<'a>,
33    /// Sagittal viewport target.
34    pub sagittal: RenderTarget<'a>,
35    /// Volume viewport target.
36    pub volume: RenderTarget<'a>,
37}
38
39/// Errors raised while preparing or rendering the dicomview GPU layer.
40#[derive(Debug, Error)]
41pub enum RenderEngineError {
42    /// Rendering was requested before a volume was prepared.
43    #[error("no prepared volume is available")]
44    NoPreparedVolume,
45    /// The underlying incremental volume rejected the update.
46    #[error(transparent)]
47    IncrementalVolume(#[from] IncrementalVolumeError),
48    /// The underlying renderer rejected the draw or upload request.
49    #[error(transparent)]
50    Render(#[from] RenderError),
51}
52
53/// Shared renderer and viewport state for the four-canvas layout.
54pub struct RenderEngine {
55    renderer: VolumeRenderer,
56    prepared_volume: Option<IncrementalVolume>,
57    geometry: Option<VolumeGeometry>,
58    volume_state: VolumeViewState,
59    axial_state: SlicePreviewState,
60    coronal_state: SlicePreviewState,
61    sagittal_state: SlicePreviewState,
62    active_preset: VolumePresetId,
63    #[allow(dead_code)]
64    device: Arc<wgpu::Device>,
65    #[allow(dead_code)]
66    queue: Arc<wgpu::Queue>,
67}
68
69impl RenderEngine {
70    /// Creates a renderer that targets the provided output format.
71    #[must_use]
72    pub fn new(
73        device: &wgpu::Device,
74        queue: &wgpu::Queue,
75        output_format: wgpu::TextureFormat,
76    ) -> Self {
77        Self::from_arc(
78            Arc::new(device.clone()),
79            Arc::new(queue.clone()),
80            output_format,
81        )
82    }
83
84    /// Creates a renderer from shared `Arc` device and queue handles.
85    #[must_use]
86    pub fn from_arc(
87        device: Arc<wgpu::Device>,
88        queue: Arc<wgpu::Queue>,
89        output_format: wgpu::TextureFormat,
90    ) -> Self {
91        let mut coronal_state = SlicePreviewState::default();
92        coronal_state.set_mode(SlicePreviewMode::Coronal);
93        let mut sagittal_state = SlicePreviewState::default();
94        sagittal_state.set_mode(SlicePreviewMode::Sagittal);
95
96        Self {
97            renderer: VolumeRenderer::from_arc(device.clone(), queue.clone(), output_format),
98            prepared_volume: None,
99            geometry: None,
100            volume_state: VolumeViewState::default(),
101            axial_state: SlicePreviewState::default(),
102            coronal_state,
103            sagittal_state,
104            active_preset: VolumePresetId::CtSoftTissue,
105            device,
106            queue,
107        }
108    }
109
110    /// Prepares an empty progressive volume and allocates its GPU texture.
111    pub fn prepare_volume(&mut self, geometry: VolumeGeometry) -> Result<(), RenderEngineError> {
112        self.prepared_volume = Some(IncrementalVolume::new(geometry)?);
113        self.geometry = Some(geometry);
114        self.renderer.allocate_volume(
115            geometry.dimensions,
116            geometry.spacing,
117            geometry.origin,
118            geometry.direction,
119            (0.0, 1.0),
120            true,
121        );
122        // Upload an initial transfer function so render_volume() doesn't fail
123        // before the first slice arrives and updates the scalar range.
124        let params = render_params_for_state(self.active_preset, self.volume_state, (0.0, 1.0));
125        let _ = self.renderer.set_render_params(&params);
126        Ok(())
127    }
128
129    /// Inserts one slice into the progressive volume and uploads it to the GPU.
130    pub fn insert_slice(&mut self, z_index: u32, pixels: &[i16]) -> Result<(), RenderEngineError> {
131        let volume = self
132            .prepared_volume
133            .as_mut()
134            .ok_or(RenderEngineError::NoPreparedVolume)?;
135        volume.insert_slice(z_index, pixels)?;
136        let scalar_range = volume
137            .scalar_range()
138            .map(|(min, max)| (f64::from(min), f64::from(max)))
139            .unwrap_or((0.0, 1.0));
140        update_texture_slice_i16(&mut self.renderer, z_index, pixels, scalar_range)?;
141        // Refresh the transfer function with the updated scalar range
142        let params = render_params_for_state(self.active_preset, self.volume_state, scalar_range);
143        let _ = self.renderer.set_render_params(&params);
144        Ok(())
145    }
146
147    /// Returns the prepared progressive volume, if any.
148    #[must_use]
149    pub fn prepared_volume(&self) -> Option<&IncrementalVolume> {
150        self.prepared_volume.as_ref()
151    }
152
153    /// Returns the active volume geometry, if any.
154    #[must_use]
155    pub fn geometry(&self) -> Option<VolumeGeometry> {
156        self.geometry
157    }
158
159    /// Returns the currently known scalar range.
160    #[must_use]
161    pub fn scalar_range(&self) -> Option<(f64, f64)> {
162        self.prepared_volume
163            .as_ref()
164            .and_then(IncrementalVolume::scalar_range)
165            .map(|(min, max)| (f64::from(min), f64::from(max)))
166    }
167
168    /// Returns mutable access to the volume viewport state.
169    pub fn volume_state_mut(&mut self) -> &mut VolumeViewState {
170        &mut self.volume_state
171    }
172
173    /// Returns mutable access to one slice viewport state.
174    pub fn slice_state_mut(&mut self, mode: SlicePreviewMode) -> &mut SlicePreviewState {
175        match mode {
176            SlicePreviewMode::Axial => &mut self.axial_state,
177            SlicePreviewMode::Coronal => &mut self.coronal_state,
178            SlicePreviewMode::Sagittal => &mut self.sagittal_state,
179        }
180    }
181
182    /// Sets the active volume-rendering preset.
183    pub fn set_volume_preset(&mut self, preset_id: VolumePresetId) {
184        self.active_preset = preset_id;
185    }
186
187    /// Moves the shared MPR crosshair and centers all slice views on that point.
188    pub fn set_crosshair(&mut self, world: DVec3) -> Result<(), RenderEngineError> {
189        let bounds = self.bounds()?;
190        for state in [
191            &mut self.axial_state,
192            &mut self.coronal_state,
193            &mut self.sagittal_state,
194        ] {
195            state.set_crosshair_world(world);
196            state.center_on_world(world, bounds);
197        }
198        Ok(())
199    }
200
201    /// Scrolls one slice viewport along its normal.
202    pub fn scroll_slice(
203        &mut self,
204        mode: SlicePreviewMode,
205        delta: f64,
206    ) -> Result<(), RenderEngineError> {
207        let bounds = self.bounds()?;
208        self.slice_state_mut(mode).scroll_by(delta, bounds);
209        Ok(())
210    }
211
212    /// Applies one transfer window to all viewports.
213    pub fn set_window_level(&mut self, center: f64, width: f64) -> Result<(), RenderEngineError> {
214        let (scalar_min, scalar_max) = self
215            .scalar_range()
216            .ok_or(RenderEngineError::NoPreparedVolume)?;
217        for state in [
218            &mut self.axial_state,
219            &mut self.coronal_state,
220            &mut self.sagittal_state,
221        ] {
222            state.set_transfer_window(center, width, scalar_min, scalar_max);
223        }
224        self.volume_state
225            .set_transfer_window(center, width, scalar_min, scalar_max);
226        Ok(())
227    }
228
229    /// Configures the thick-slab mode for one slice viewport.
230    pub fn set_thick_slab(
231        &mut self,
232        mode: SlicePreviewMode,
233        thickness: f64,
234        projection_mode: SliceProjectionMode,
235    ) {
236        let state = self.slice_state_mut(mode);
237        if thickness <= 0.0 {
238            state.projection_mode = SliceProjectionMode::Thin;
239            state.slab_half_thickness = 0.0;
240        } else {
241            state.projection_mode = projection_mode;
242            state.slab_half_thickness = (thickness * 0.5).max(0.5);
243        }
244    }
245
246    /// Resets all viewports back to their default interaction state.
247    pub fn reset(&mut self) {
248        self.volume_state.reset();
249        self.axial_state.reset();
250        self.coronal_state.reset();
251        self.coronal_state.set_mode(SlicePreviewMode::Coronal);
252        self.sagittal_state.reset();
253        self.sagittal_state.set_mode(SlicePreviewMode::Sagittal);
254    }
255
256    /// Renders the four-view layout into the provided targets.
257    pub fn render_frame(
258        &mut self,
259        encoder: &mut wgpu::CommandEncoder,
260        targets: FrameTargets<'_>,
261        show_crosshairs: bool,
262    ) -> Result<(), RenderEngineError> {
263        let volume = self
264            .prepared_volume
265            .as_ref()
266            .ok_or(RenderEngineError::NoPreparedVolume)?;
267        let geometry = self.geometry.ok_or(RenderEngineError::NoPreparedVolume)?;
268        let bounds = bounds_from_geometry(geometry);
269        let scalar_range = volume
270            .scalar_range()
271            .map(|(min, max)| (f64::from(min), f64::from(max)))
272            .unwrap_or((0.0, 1.0));
273
274        self.render_slice_view(
275            encoder,
276            &targets.axial,
277            &self.axial_state,
278            bounds,
279            scalar_range,
280            show_crosshairs,
281            crosshair_colors(SlicePreviewMode::Axial),
282        )?;
283        self.render_slice_view(
284            encoder,
285            &targets.coronal,
286            &self.coronal_state,
287            bounds,
288            scalar_range,
289            show_crosshairs,
290            crosshair_colors(SlicePreviewMode::Coronal),
291        )?;
292        self.render_slice_view(
293            encoder,
294            &targets.sagittal,
295            &self.sagittal_state,
296            bounds,
297            scalar_range,
298            show_crosshairs,
299            crosshair_colors(SlicePreviewMode::Sagittal),
300        )?;
301
302        let camera = camera_for_state(geometry, self.volume_state);
303        let params = render_params_for_state(self.active_preset, self.volume_state, scalar_range);
304        self.renderer.render_volume(
305            encoder,
306            targets.volume.view,
307            &camera,
308            &params,
309            targets.volume.viewport,
310        )?;
311        Ok(())
312    }
313
314    fn render_slice_view(
315        &self,
316        encoder: &mut wgpu::CommandEncoder,
317        target: &RenderTarget<'_>,
318        state: &SlicePreviewState,
319        bounds: Aabb,
320        scalar_range: (f64, f64),
321        show_crosshairs: bool,
322        colors: ([f32; 4], [f32; 4]),
323    ) -> Result<(), RenderEngineError> {
324        let (center, width) = state.transfer_window(scalar_range.0, scalar_range.1);
325        let window_level = WindowLevel::new(center, width.max(1.0));
326        let mut slice_plane = state.slice_plane(bounds);
327
328        // Preserve data aspect ratio within the viewport
329        let vp_w = f64::from(target.viewport.width.max(1));
330        let vp_h = f64::from(target.viewport.height.max(1));
331        let vp_aspect = vp_w / vp_h;
332        let data_aspect = slice_plane.width / slice_plane.height.max(1e-6);
333        if vp_aspect > data_aspect {
334            slice_plane.width = slice_plane.height * vp_aspect;
335        } else {
336            slice_plane.height = slice_plane.width / vp_aspect;
337        }
338
339        self.renderer.render_slice(
340            encoder,
341            target.view,
342            &slice_plane,
343            &window_level,
344            target.viewport,
345            state.thick_slab().as_ref(),
346        )?;
347
348        if show_crosshairs {
349            let crosshair_world = state.crosshair_world(bounds);
350            let (uv, _) = slice_plane.world_to_point(crosshair_world);
351            if (0.0..=1.0).contains(&uv.x) && (0.0..=1.0).contains(&uv.y) {
352                self.renderer.render_crosshair(
353                    encoder,
354                    target.view,
355                    target.viewport,
356                    &CrosshairParams {
357                        position: [uv.x as f32, uv.y as f32],
358                        horizontal_color: colors.0,
359                        vertical_color: colors.1,
360                        thickness: 1.5,
361                    },
362                )?;
363            }
364        }
365
366        Ok(())
367    }
368
369    fn bounds(&self) -> Result<Aabb, RenderEngineError> {
370        let geometry = self.geometry.ok_or(RenderEngineError::NoPreparedVolume)?;
371        Ok(bounds_from_geometry(geometry))
372    }
373}
374
375fn render_params_for_state(
376    preset_id: VolumePresetId,
377    view_state: VolumeViewState,
378    scalar_range: (f64, f64),
379) -> VolumeRenderParams {
380    let mut params = match view_state.blend_mode {
381        VolumeBlendMode::Composite => {
382            preset(preset_id, scalar_range.0, scalar_range.1).to_render_params()
383        }
384        VolumeBlendMode::MaximumIntensity
385        | VolumeBlendMode::MinimumIntensity
386        | VolumeBlendMode::AverageIntensity => {
387            let blend_mode = match view_state.blend_mode {
388                VolumeBlendMode::Composite => BlendMode::Composite,
389                VolumeBlendMode::MaximumIntensity => BlendMode::MaximumIntensity,
390                VolumeBlendMode::MinimumIntensity => BlendMode::MinimumIntensity,
391                VolumeBlendMode::AverageIntensity => BlendMode::AverageIntensity,
392            };
393            VolumeRenderParams::builder()
394                .blend_mode(blend_mode)
395                .step_size_factor(0.35)
396                .color_tf(ColorTransferFunction::greyscale(
397                    scalar_range.0,
398                    scalar_range.1,
399                ))
400                .opacity_tf(OpacityTransferFunction::linear_ramp(
401                    scalar_range.0,
402                    scalar_range.1,
403                ))
404                .build()
405        }
406    };
407    let (center, width) = view_state.transfer_window(scalar_range.0, scalar_range.1);
408    params.window_level = Some(WindowLevel::new(center, width.max(1.0)));
409    params
410}
411
412fn camera_for_state(geometry: VolumeGeometry, view_state: VolumeViewState) -> Camera {
413    let bounds = bounds_from_geometry(geometry);
414    let center = bounds.center();
415    let diagonal = bounds.diagonal().max(1.0);
416    let default_forward = DVec3::Y;
417    let default_up = DVec3::NEG_Z;
418    let forward = view_state.orientation * default_forward;
419    let up = view_state.orientation * default_up;
420    let right = forward.cross(up).normalize_or(DVec3::X);
421    let fov_y_deg = 30.0_f64;
422    let half_diag = diagonal * 0.5;
423    let fit_distance = half_diag / (fov_y_deg.to_radians() * 0.5).tan();
424    let distance = fit_distance * 1.15 / view_state.zoom.clamp(0.25, 8.0);
425    let position = center - forward * distance;
426    let pan_scale = distance * 0.001;
427    let pan_offset = right * (-view_state.pan_x * pan_scale) + up * (-view_state.pan_y * pan_scale);
428
429    Camera::new(position + pan_offset, center + pan_offset, up)
430        .with_projection(Projection::Perspective { fov_y_deg })
431        .with_clip_range(
432            (distance - diagonal).max(diagonal * 0.01).max(0.1),
433            distance + diagonal * 2.0,
434        )
435}
436
437fn bounds_from_geometry(geometry: VolumeGeometry) -> Aabb {
438    let dims = geometry.dimensions.as_dvec3();
439    let corners = [
440        DVec3::ZERO,
441        DVec3::new(dims.x - 1.0, 0.0, 0.0),
442        DVec3::new(0.0, dims.y - 1.0, 0.0),
443        DVec3::new(0.0, 0.0, dims.z - 1.0),
444        DVec3::new(dims.x - 1.0, dims.y - 1.0, 0.0),
445        DVec3::new(dims.x - 1.0, 0.0, dims.z - 1.0),
446        DVec3::new(0.0, dims.y - 1.0, dims.z - 1.0),
447        dims - DVec3::ONE,
448    ];
449    let world_corners: Vec<DVec3> = corners
450        .iter()
451        .map(|&corner| geometry.origin + geometry.direction * (corner * geometry.spacing))
452        .collect();
453    let min = world_corners
454        .iter()
455        .fold(DVec3::splat(f64::INFINITY), |acc, point| acc.min(*point));
456    let max = world_corners
457        .iter()
458        .fold(DVec3::splat(f64::NEG_INFINITY), |acc, point| {
459            acc.max(*point)
460        });
461    Aabb::new(min, max)
462}
463
464fn crosshair_colors(mode: SlicePreviewMode) -> ([f32; 4], [f32; 4]) {
465    match mode {
466        SlicePreviewMode::Axial => ([0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]),
467        SlicePreviewMode::Coronal => ([0.0, 0.5, 1.0, 1.0], [1.0, 0.0, 0.0, 1.0]),
468        SlicePreviewMode::Sagittal => ([0.0, 0.5, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]),
469    }
470}
471
472/// Lightweight single-canvas renderer for stack (2D) viewing.
473///
474/// Unlike [`RenderEngine`] which manages 4 viewports, this engine renders
475/// a single slice viewport into one canvas. It uses the same underlying
476/// [`VolumeRenderer`] and [`IncrementalVolume`] for data storage and
477/// GPU-accelerated reslicing.
478pub struct SingleSliceEngine {
479    renderer: VolumeRenderer,
480    prepared_volume: Option<IncrementalVolume>,
481    geometry: Option<VolumeGeometry>,
482    slice_state: SlicePreviewState,
483    #[allow(dead_code)]
484    device: Arc<wgpu::Device>,
485    #[allow(dead_code)]
486    queue: Arc<wgpu::Queue>,
487}
488
489impl SingleSliceEngine {
490    /// Creates a single-slice renderer targeting the provided output format.
491    #[must_use]
492    pub fn new(
493        device: &wgpu::Device,
494        queue: &wgpu::Queue,
495        output_format: wgpu::TextureFormat,
496    ) -> Self {
497        Self::from_arc(
498            Arc::new(device.clone()),
499            Arc::new(queue.clone()),
500            output_format,
501        )
502    }
503
504    /// Creates a single-slice renderer from shared `Arc` handles.
505    #[must_use]
506    pub fn from_arc(
507        device: Arc<wgpu::Device>,
508        queue: Arc<wgpu::Queue>,
509        output_format: wgpu::TextureFormat,
510    ) -> Self {
511        Self {
512            renderer: VolumeRenderer::from_arc(device.clone(), queue.clone(), output_format),
513            prepared_volume: None,
514            geometry: None,
515            slice_state: SlicePreviewState::default(),
516            device,
517            queue,
518        }
519    }
520
521    /// Prepares an empty progressive volume and allocates its GPU texture.
522    pub fn prepare_volume(&mut self, geometry: VolumeGeometry) -> Result<(), RenderEngineError> {
523        self.prepared_volume = Some(IncrementalVolume::new(geometry)?);
524        self.geometry = Some(geometry);
525        self.renderer.allocate_volume(
526            geometry.dimensions,
527            geometry.spacing,
528            geometry.origin,
529            geometry.direction,
530            (0.0, 1.0),
531            true,
532        );
533        Ok(())
534    }
535
536    /// Inserts one slice into the progressive volume and uploads it to the GPU.
537    pub fn insert_slice(&mut self, z_index: u32, pixels: &[i16]) -> Result<(), RenderEngineError> {
538        let volume = self
539            .prepared_volume
540            .as_mut()
541            .ok_or(RenderEngineError::NoPreparedVolume)?;
542        volume.insert_slice(z_index, pixels)?;
543        let scalar_range = volume
544            .scalar_range()
545            .map(|(min, max)| (f64::from(min), f64::from(max)))
546            .unwrap_or((0.0, 1.0));
547        update_texture_slice_i16(&mut self.renderer, z_index, pixels, scalar_range)?;
548        Ok(())
549    }
550
551    /// Returns the prepared progressive volume, if any.
552    #[must_use]
553    pub fn prepared_volume(&self) -> Option<&IncrementalVolume> {
554        self.prepared_volume.as_ref()
555    }
556
557    /// Returns mutable access to the slice viewport state.
558    pub fn slice_state_mut(&mut self) -> &mut SlicePreviewState {
559        &mut self.slice_state
560    }
561
562    /// Returns the currently known scalar range.
563    #[must_use]
564    pub fn scalar_range(&self) -> Option<(f64, f64)> {
565        self.prepared_volume
566            .as_ref()
567            .and_then(IncrementalVolume::scalar_range)
568            .map(|(min, max)| (f64::from(min), f64::from(max)))
569    }
570
571    /// Switches which orthogonal plane is displayed.
572    pub fn set_slice_mode(&mut self, mode: SlicePreviewMode) {
573        self.slice_state.set_mode(mode);
574    }
575
576    /// Scrolls the slice along its normal.
577    pub fn scroll_slice(&mut self, delta: f64) -> Result<(), RenderEngineError> {
578        let bounds = self.bounds()?;
579        self.slice_state.scroll_by(delta, bounds);
580        Ok(())
581    }
582
583    /// Applies a transfer window to the slice viewport.
584    pub fn set_window_level(&mut self, center: f64, width: f64) -> Result<(), RenderEngineError> {
585        let (scalar_min, scalar_max) = self
586            .scalar_range()
587            .ok_or(RenderEngineError::NoPreparedVolume)?;
588        self.slice_state
589            .set_transfer_window(center, width, scalar_min, scalar_max);
590        Ok(())
591    }
592
593    /// Resets the slice viewport state.
594    pub fn reset(&mut self) {
595        self.slice_state.reset();
596    }
597
598    /// Renders the single slice into the provided target.
599    pub fn render_slice(
600        &mut self,
601        encoder: &mut wgpu::CommandEncoder,
602        target: &RenderTarget<'_>,
603    ) -> Result<(), RenderEngineError> {
604        let _volume = self
605            .prepared_volume
606            .as_ref()
607            .ok_or(RenderEngineError::NoPreparedVolume)?;
608        let geometry = self.geometry.ok_or(RenderEngineError::NoPreparedVolume)?;
609        let bounds = bounds_from_geometry(geometry);
610        let scalar_range = self.scalar_range().unwrap_or((0.0, 1.0));
611        let (center, width) = self.slice_state.transfer_window(scalar_range.0, scalar_range.1);
612        let window_level = WindowLevel::new(center, width.max(1.0));
613        let mut slice_plane = self.slice_state.slice_plane(bounds);
614
615        // Adjust the slice extent so the data aspect ratio is preserved
616        // within the viewport. Extra space is filled with black (out-of-bounds).
617        let vp_w = f64::from(target.viewport.width.max(1));
618        let vp_h = f64::from(target.viewport.height.max(1));
619        let vp_aspect = vp_w / vp_h;
620        let data_aspect = slice_plane.width / slice_plane.height.max(1e-6);
621        if vp_aspect > data_aspect {
622            slice_plane.width = slice_plane.height * vp_aspect;
623        } else {
624            slice_plane.height = slice_plane.width / vp_aspect;
625        }
626
627        self.renderer.render_slice(
628            encoder,
629            target.view,
630            &slice_plane,
631            &window_level,
632            target.viewport,
633            self.slice_state.thick_slab().as_ref(),
634        )?;
635        Ok(())
636    }
637
638    fn bounds(&self) -> Result<Aabb, RenderEngineError> {
639        let geometry = self.geometry.ok_or(RenderEngineError::NoPreparedVolume)?;
640        Ok(bounds_from_geometry(geometry))
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use approx::assert_abs_diff_eq;
648    use glam::{DMat3, UVec3};
649    use std::sync::mpsc;
650
651    fn geometry() -> VolumeGeometry {
652        VolumeGeometry::new(
653            UVec3::new(10, 20, 30),
654            DVec3::new(0.8, 0.6, 1.2),
655            DVec3::ZERO,
656            DMat3::IDENTITY,
657        )
658    }
659
660    #[test]
661    fn bounds_match_geometry() {
662        let bounds = bounds_from_geometry(geometry());
663        assert_abs_diff_eq!(bounds.max.x, 7.2, epsilon = 1e-6);
664        assert_abs_diff_eq!(bounds.max.y, 11.4, epsilon = 1e-6);
665        assert_abs_diff_eq!(bounds.max.z, 34.8, epsilon = 1e-6);
666    }
667
668    #[test]
669    fn camera_targets_volume_center() {
670        let geometry = geometry();
671        let camera = camera_for_state(geometry, VolumeViewState::default());
672        let center = bounds_from_geometry(geometry).center();
673        assert!((camera.focal_point() - center).length() < 1e-6);
674        assert!(camera.distance() > bounds_from_geometry(geometry).diagonal());
675    }
676
677    fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
678        pollster::block_on(async {
679            let instance = wgpu::Instance::default();
680            let adapter = instance
681                .request_adapter(&wgpu::RequestAdapterOptions {
682                    power_preference: wgpu::PowerPreference::LowPower,
683                    compatible_surface: None,
684                    force_fallback_adapter: false,
685                })
686                .await
687                .ok()?;
688            adapter
689                .request_device(&wgpu::DeviceDescriptor::default())
690                .await
691                .ok()
692        })
693    }
694
695    fn create_render_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
696        device.create_texture(&wgpu::TextureDescriptor {
697            label: Some("dicomview_gpu_test_target"),
698            size: wgpu::Extent3d {
699                width: size,
700                height: size,
701                depth_or_array_layers: 1,
702            },
703            mip_level_count: 1,
704            sample_count: 1,
705            dimension: wgpu::TextureDimension::D2,
706            format: wgpu::TextureFormat::Rgba8Unorm,
707            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
708            view_formats: &[],
709        })
710    }
711
712    fn read_texture(
713        device: &wgpu::Device,
714        queue: &wgpu::Queue,
715        texture: &wgpu::Texture,
716        width: u32,
717        height: u32,
718    ) -> Vec<u8> {
719        let unpadded_bytes_per_row = width * 4;
720        let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(256) * 256;
721        let buffer_size = u64::from(padded_bytes_per_row) * u64::from(height);
722        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
723            label: Some("dicomview_gpu_test_readback"),
724            size: buffer_size,
725            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
726            mapped_at_creation: false,
727        });
728
729        let mut encoder =
730            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
731        encoder.copy_texture_to_buffer(
732            texture.as_image_copy(),
733            wgpu::TexelCopyBufferInfo {
734                buffer: &buffer,
735                layout: wgpu::TexelCopyBufferLayout {
736                    offset: 0,
737                    bytes_per_row: Some(padded_bytes_per_row),
738                    rows_per_image: Some(height),
739                },
740            },
741            wgpu::Extent3d {
742                width,
743                height,
744                depth_or_array_layers: 1,
745            },
746        );
747        queue.submit(std::iter::once(encoder.finish()));
748
749        let (sender, receiver) = mpsc::channel();
750        buffer
751            .slice(..)
752            .map_async(wgpu::MapMode::Read, move |result| {
753                let _ = sender.send(result);
754            });
755        let _ = device.poll(wgpu::PollType::wait_indefinitely());
756        receiver.recv().expect("map callback").expect("map success");
757
758        let mapped = buffer.slice(..).get_mapped_range();
759        let mut pixels = vec![0u8; (unpadded_bytes_per_row * height) as usize];
760        for row in 0..height as usize {
761            let src_offset = row * padded_bytes_per_row as usize;
762            let dst_offset = row * unpadded_bytes_per_row as usize;
763            pixels[dst_offset..dst_offset + unpadded_bytes_per_row as usize]
764                .copy_from_slice(&mapped[src_offset..src_offset + unpadded_bytes_per_row as usize]);
765        }
766        drop(mapped);
767        buffer.unmap();
768        pixels
769    }
770
771    fn checksum(bytes: &[u8]) -> u64 {
772        bytes.iter().enumerate().fold(0u64, |acc, (index, value)| {
773            acc.wrapping_add((index as u64 + 1) * u64::from(*value))
774        })
775    }
776
777    #[test]
778    #[ignore = "requires a working GPU adapter"]
779    fn render_engine_progressive_snapshot_checksum() {
780        let Some((device, queue)) = test_device() else {
781            return;
782        };
783        let mut engine = RenderEngine::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm);
784        let geometry = VolumeGeometry::new(
785            UVec3::new(16, 16, 16),
786            DVec3::ONE,
787            DVec3::ZERO,
788            DMat3::IDENTITY,
789        );
790        engine.prepare_volume(geometry).expect("prepare volume");
791
792        for z in 0..geometry.dimensions.z {
793            let mut slice = vec![0i16; geometry.slice_len()];
794            for y in 0..geometry.dimensions.y {
795                for x in 0..geometry.dimensions.x {
796                    let index = (y * geometry.dimensions.x + x) as usize;
797                    let dx = x as f64 - 7.5;
798                    let dy = y as f64 - 7.5;
799                    let dz = z as f64 - 7.5;
800                    if (dx * dx + dy * dy + dz * dz).sqrt() <= 5.0 {
801                        slice[index] = 1500;
802                    }
803                }
804            }
805            engine.insert_slice(z, &slice).expect("insert slice");
806        }
807
808        engine
809            .set_crosshair(DVec3::new(8.0, 8.0, 8.0))
810            .expect("set crosshair");
811        engine.set_thick_slab(
812            SlicePreviewMode::Axial,
813            6.0,
814            SliceProjectionMode::MaximumIntensity,
815        );
816
817        let axial_texture = create_render_texture(&device, 96);
818        let coronal_texture = create_render_texture(&device, 96);
819        let sagittal_texture = create_render_texture(&device, 96);
820        let volume_texture = create_render_texture(&device, 96);
821        let axial_view = axial_texture.create_view(&wgpu::TextureViewDescriptor::default());
822        let coronal_view = coronal_texture.create_view(&wgpu::TextureViewDescriptor::default());
823        let sagittal_view = sagittal_texture.create_view(&wgpu::TextureViewDescriptor::default());
824        let volume_view = volume_texture.create_view(&wgpu::TextureViewDescriptor::default());
825
826        let mut encoder =
827            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
828        engine
829            .render_frame(
830                &mut encoder,
831                FrameTargets {
832                    axial: RenderTarget {
833                        view: &axial_view,
834                        viewport: Viewport::full(96, 96),
835                    },
836                    coronal: RenderTarget {
837                        view: &coronal_view,
838                        viewport: Viewport::full(96, 96),
839                    },
840                    sagittal: RenderTarget {
841                        view: &sagittal_view,
842                        viewport: Viewport::full(96, 96),
843                    },
844                    volume: RenderTarget {
845                        view: &volume_view,
846                        viewport: Viewport::full(96, 96),
847                    },
848                },
849                true,
850            )
851            .expect("render frame");
852        queue.submit(std::iter::once(encoder.finish()));
853
854        let axial_pixels = read_texture(&device, &queue, &axial_texture, 96, 96);
855        let volume_pixels = read_texture(&device, &queue, &volume_texture, 96, 96);
856        assert!(
857            checksum(&axial_pixels) > 0,
858            "axial slice should not be empty"
859        );
860        assert!(
861            checksum(&volume_pixels) > 0,
862            "volume render should not be empty"
863        );
864    }
865}