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