Skip to main content

dicomview_core/
viewport_state.rs

1//! Pure data and math for MPR and volume viewport state.
2
3use glam::{DQuat, DVec3};
4use volren_core::{Aabb, SlicePlane, ThickSlabMode, ThickSlabParams};
5
6/// Blend modes exposed by the dicomview volume viewport.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum VolumeBlendMode {
9    /// Front-to-back compositing.
10    #[default]
11    Composite,
12    /// Maximum intensity projection.
13    MaximumIntensity,
14    /// Minimum intensity projection.
15    MinimumIntensity,
16    /// Average intensity projection.
17    AverageIntensity,
18}
19
20/// One of the three orthogonal MPR orientations.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum SlicePreviewMode {
23    /// Axial plane.
24    #[default]
25    Axial,
26    /// Coronal plane.
27    Coronal,
28    /// Sagittal plane.
29    Sagittal,
30}
31
32/// Projection style used when rendering one reslice viewport.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum SliceProjectionMode {
35    /// Thin single-voxel slice.
36    #[default]
37    Thin,
38    /// Maximum intensity slab projection.
39    MaximumIntensity,
40    /// Minimum intensity slab projection.
41    MinimumIntensity,
42    /// Mean intensity slab projection.
43    AverageIntensity,
44}
45
46/// Mutable state for one MPR viewport.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct SlicePreviewState {
49    /// Active orthogonal slice family.
50    pub mode: SlicePreviewMode,
51    /// Signed offset from the volume center along the current slice normal.
52    pub offset: f64,
53    /// Additional quaternion rotation applied around the shared MPR cursor.
54    pub orientation: DQuat,
55    /// Thin-slice vs slab projection mode.
56    pub projection_mode: SliceProjectionMode,
57    /// Half-thickness of the active slab in world units.
58    pub slab_half_thickness: f64,
59    /// Shared MPR cursor in world space.
60    pub crosshair_world: Option<DVec3>,
61    /// Explicit transfer-window center in modality space.
62    pub transfer_center_hu: Option<f64>,
63    /// Explicit transfer-window width in modality space.
64    pub transfer_width_hu: Option<f64>,
65    slab_settings_by_mode: [SliceSlabSettings; 3],
66}
67
68impl Default for SlicePreviewState {
69    fn default() -> Self {
70        let slab_settings = [SliceSlabSettings::default(); 3];
71        Self {
72            mode: SlicePreviewMode::Axial,
73            offset: 0.0,
74            orientation: DQuat::IDENTITY,
75            projection_mode: slab_settings[0].projection_mode,
76            slab_half_thickness: slab_settings[0].slab_half_thickness,
77            crosshair_world: None,
78            transfer_center_hu: None,
79            transfer_width_hu: None,
80            slab_settings_by_mode: slab_settings,
81        }
82    }
83}
84
85impl SlicePreviewState {
86    /// Ensures that the state has a transfer window appropriate for the scalar range.
87    pub fn ensure_transfer_window(&mut self, scalar_min: f64, scalar_max: f64) {
88        let (center, width) = resolved_slice_transfer_window(*self, scalar_min, scalar_max);
89        self.transfer_center_hu.get_or_insert(center);
90        self.transfer_width_hu.get_or_insert(width);
91    }
92
93    /// Returns the current slice transfer window, falling back to a safe default.
94    #[must_use]
95    pub fn transfer_window(&self, scalar_min: f64, scalar_max: f64) -> (f64, f64) {
96        resolved_slice_transfer_window(*self, scalar_min, scalar_max)
97    }
98
99    /// Updates the slice transfer window with clamping.
100    pub fn set_transfer_window(
101        &mut self,
102        center: f64,
103        width: f64,
104        scalar_min: f64,
105        scalar_max: f64,
106    ) {
107        let (center, width) = clamp_transfer_window(center, width, scalar_min, scalar_max);
108        self.transfer_center_hu = Some(center);
109        self.transfer_width_hu = Some(width);
110    }
111
112    /// Resets the slice state back to the default centered slice.
113    pub fn reset(&mut self) {
114        *self = Self::default();
115    }
116
117    /// Switches the viewport to another orthogonal slice family.
118    pub fn set_mode(&mut self, mode: SlicePreviewMode) {
119        self.persist_current_slab_settings();
120        self.mode = mode;
121        self.restore_current_slab_settings();
122    }
123
124    /// Resolves the current oriented slice plane within the provided bounds.
125    #[must_use]
126    pub fn slice_plane(&self, bounds: Aabb) -> SlicePlane {
127        slice_plane_for_state(bounds, *self)
128    }
129
130    /// Returns the active shared crosshair, defaulting to the volume center.
131    #[must_use]
132    pub fn crosshair_world(&self, bounds: Aabb) -> DVec3 {
133        self.crosshair_world.unwrap_or(bounds.center())
134    }
135
136    /// Updates the shared crosshair point.
137    pub fn set_crosshair_world(&mut self, world: DVec3) {
138        self.crosshair_world = Some(world);
139    }
140
141    /// Moves the slice so it passes through `world`.
142    pub fn center_on_world(&mut self, world: DVec3, bounds: Aabb) {
143        let center = bounds.center();
144        let normal = self.slice_plane(bounds).normal();
145        let unclamped_offset = (world - center).dot(normal);
146        self.offset = unclamped_offset;
147        self.clamp_offset(bounds);
148        self.crosshair_world = Some(world + normal * (self.offset - unclamped_offset));
149    }
150
151    /// Moves the slice so it passes through the shared crosshair.
152    pub fn center_on_crosshair(&mut self, bounds: Aabb) {
153        self.center_on_world(self.crosshair_world(bounds), bounds);
154    }
155
156    /// Cycles between thin-slice and the supported slab modes.
157    pub fn cycle_projection_mode(&mut self, default_half_thickness: f64) {
158        self.projection_mode = match self.projection_mode {
159            SliceProjectionMode::Thin => SliceProjectionMode::MaximumIntensity,
160            SliceProjectionMode::MaximumIntensity => SliceProjectionMode::MinimumIntensity,
161            SliceProjectionMode::MinimumIntensity => SliceProjectionMode::AverageIntensity,
162            SliceProjectionMode::AverageIntensity => SliceProjectionMode::Thin,
163        };
164        self.slab_half_thickness = if matches!(self.projection_mode, SliceProjectionMode::Thin) {
165            0.0
166        } else {
167            default_half_thickness.max(0.5)
168        };
169        self.persist_current_slab_settings();
170    }
171
172    /// Adjusts slab thickness based on pointer drag semantics.
173    pub fn set_slab_half_thickness_from_drag(
174        &mut self,
175        half_thickness: f64,
176        min_active_half_thickness: f64,
177        fallback_mode: SliceProjectionMode,
178    ) {
179        if half_thickness <= min_active_half_thickness {
180            self.projection_mode = SliceProjectionMode::Thin;
181            self.slab_half_thickness = 0.0;
182        } else {
183            if matches!(self.projection_mode, SliceProjectionMode::Thin) {
184                self.projection_mode = fallback_mode;
185            }
186            self.slab_half_thickness = half_thickness.max(0.5);
187        }
188        self.persist_current_slab_settings();
189    }
190
191    /// Resolves the thick-slab parameters for the current projection state.
192    #[must_use]
193    pub fn thick_slab(self) -> Option<ThickSlabParams> {
194        let mode = match self.projection_mode {
195            SliceProjectionMode::Thin => return None,
196            SliceProjectionMode::MaximumIntensity => ThickSlabMode::Mip,
197            SliceProjectionMode::MinimumIntensity => ThickSlabMode::MinIp,
198            SliceProjectionMode::AverageIntensity => ThickSlabMode::Mean,
199        };
200        Some(ThickSlabParams {
201            half_thickness: self.slab_half_thickness.max(0.5),
202            mode,
203            num_samples: 16,
204        })
205    }
206
207    /// Clamps the current slice offset to the volume bounds.
208    pub fn clamp_offset(&mut self, bounds: Aabb) {
209        let (min_offset, max_offset) =
210            slice_offset_range(bounds, self.slice_plane(bounds).normal());
211        self.offset = self.offset.clamp(min_offset, max_offset);
212    }
213
214    /// Scrolls along the current slice normal.
215    pub fn scroll_by(&mut self, delta: f64, bounds: Aabb) {
216        let world = self.crosshair_world(bounds) + self.slice_plane(bounds).normal() * delta;
217        self.center_on_world(world, bounds);
218    }
219
220    /// Rotates the slice around its current normal axis.
221    pub fn rotate_about_normal(&mut self, angle_rad: f64, bounds: Aabb) {
222        let axis = self.slice_plane(bounds).normal();
223        let rotation = DQuat::from_axis_angle(axis.normalize_or(DVec3::Z), angle_rad);
224        self.orientation = (rotation * self.orientation).normalize();
225        self.center_on_crosshair(bounds);
226    }
227
228    fn persist_current_slab_settings(&mut self) {
229        self.slab_settings_by_mode[mode_index(self.mode)] = SliceSlabSettings {
230            projection_mode: self.projection_mode,
231            slab_half_thickness: self.slab_half_thickness,
232        };
233    }
234
235    fn restore_current_slab_settings(&mut self) {
236        let settings = self.slab_settings_by_mode[mode_index(self.mode)];
237        self.projection_mode = settings.projection_mode;
238        self.slab_half_thickness = settings.slab_half_thickness;
239    }
240}
241
242/// Mutable camera and transfer state for the 3D volume viewport.
243#[derive(Debug, Clone, Copy, PartialEq)]
244pub struct VolumeViewState {
245    /// Accumulated camera orientation relative to the default AP view.
246    pub orientation: DQuat,
247    /// Horizontal pan in screen-like units.
248    pub pan_x: f64,
249    /// Vertical pan in screen-like units.
250    pub pan_y: f64,
251    /// Camera zoom factor.
252    pub zoom: f64,
253    /// Active raycasting blend mode.
254    pub blend_mode: VolumeBlendMode,
255    /// Explicit transfer-window center in modality space.
256    pub transfer_center_hu: Option<f64>,
257    /// Explicit transfer-window width in modality space.
258    pub transfer_width_hu: Option<f64>,
259}
260
261impl Default for VolumeViewState {
262    fn default() -> Self {
263        Self {
264            orientation: DQuat::IDENTITY,
265            pan_x: 0.0,
266            pan_y: 0.0,
267            zoom: 1.0,
268            blend_mode: VolumeBlendMode::Composite,
269            transfer_center_hu: None,
270            transfer_width_hu: None,
271        }
272    }
273}
274
275impl VolumeViewState {
276    /// Orbits the virtual camera around the volume center.
277    pub fn orbit(&mut self, delta_x: f64, delta_y: f64) {
278        let yaw = DQuat::from_axis_angle(DVec3::Z, -delta_x.to_radians());
279        let local_right = self.orientation * DVec3::X;
280        let pitch = DQuat::from_axis_angle(local_right, -delta_y.to_radians());
281        self.orientation = (pitch * yaw * self.orientation).normalize();
282    }
283
284    /// Pans the camera in the view plane.
285    pub fn pan(&mut self, delta_x: f64, delta_y: f64) {
286        self.pan_x += delta_x;
287        self.pan_y += delta_y;
288    }
289
290    /// Applies a multiplicative zoom factor.
291    pub fn zoom_by(&mut self, factor: f64) {
292        self.zoom = (self.zoom * factor).clamp(0.25, 8.0);
293    }
294
295    /// Ensures that the state has a reasonable transfer window for the scalar range.
296    pub fn ensure_transfer_window(&mut self, scalar_min: f64, scalar_max: f64) {
297        let (center, width) = resolved_transfer_window(*self, scalar_min, scalar_max);
298        self.transfer_center_hu.get_or_insert(center);
299        self.transfer_width_hu.get_or_insert(width);
300    }
301
302    /// Returns the active transfer window or a derived default.
303    #[must_use]
304    pub fn transfer_window(&self, scalar_min: f64, scalar_max: f64) -> (f64, f64) {
305        resolved_transfer_window(*self, scalar_min, scalar_max)
306    }
307
308    /// Updates the transfer window with clamping.
309    pub fn set_transfer_window(
310        &mut self,
311        center: f64,
312        width: f64,
313        scalar_min: f64,
314        scalar_max: f64,
315    ) {
316        let (center, width) = clamp_transfer_window(center, width, scalar_min, scalar_max);
317        self.transfer_center_hu = Some(center);
318        self.transfer_width_hu = Some(width);
319    }
320
321    /// Resets the volume viewport state back to defaults.
322    pub fn reset(&mut self) {
323        *self = Self::default();
324    }
325}
326
327#[derive(Debug, Clone, Copy, PartialEq)]
328struct SliceSlabSettings {
329    projection_mode: SliceProjectionMode,
330    slab_half_thickness: f64,
331}
332
333impl Default for SliceSlabSettings {
334    fn default() -> Self {
335        Self {
336            projection_mode: SliceProjectionMode::Thin,
337            slab_half_thickness: 0.0,
338        }
339    }
340}
341
342fn mode_index(mode: SlicePreviewMode) -> usize {
343    match mode {
344        SlicePreviewMode::Axial => 0,
345        SlicePreviewMode::Coronal => 1,
346        SlicePreviewMode::Sagittal => 2,
347    }
348}
349
350fn looks_ct_like(scalar_min: f64, scalar_max: f64) -> bool {
351    scalar_min <= -500.0 && scalar_max >= 1200.0
352}
353
354fn resolved_transfer_window(
355    view_state: VolumeViewState,
356    scalar_min: f64,
357    scalar_max: f64,
358) -> (f64, f64) {
359    let range = (scalar_max - scalar_min).max(1.0);
360    let default_center = if looks_ct_like(scalar_min, scalar_max) {
361        90.0
362    } else {
363        scalar_min + range * 0.5
364    };
365    let default_width = if looks_ct_like(scalar_min, scalar_max) {
366        700.0
367    } else {
368        range
369    };
370    clamp_transfer_window(
371        view_state.transfer_center_hu.unwrap_or(default_center),
372        view_state.transfer_width_hu.unwrap_or(default_width),
373        scalar_min,
374        scalar_max,
375    )
376}
377
378fn resolved_slice_transfer_window(
379    view_state: SlicePreviewState,
380    scalar_min: f64,
381    scalar_max: f64,
382) -> (f64, f64) {
383    let range = (scalar_max - scalar_min).max(1.0);
384    clamp_transfer_window(
385        view_state
386            .transfer_center_hu
387            .unwrap_or(scalar_min + range * 0.5),
388        view_state.transfer_width_hu.unwrap_or(range),
389        scalar_min,
390        scalar_max,
391    )
392}
393
394fn clamp_transfer_window(center: f64, width: f64, scalar_min: f64, scalar_max: f64) -> (f64, f64) {
395    let range = (scalar_max - scalar_min).max(1.0);
396    (
397        center.clamp(scalar_min - range * 0.25, scalar_max + range * 0.25),
398        width.clamp(range / 200.0, range * 1.25),
399    )
400}
401
402fn slice_basis_for_mode(mode: SlicePreviewMode) -> (DVec3, DVec3) {
403    match mode {
404        SlicePreviewMode::Axial => (DVec3::X, DVec3::Y),
405        SlicePreviewMode::Coronal => (DVec3::X, -DVec3::Z),
406        SlicePreviewMode::Sagittal => (DVec3::Y, -DVec3::Z),
407    }
408}
409
410fn slice_preferred_up_for_mode(mode: SlicePreviewMode) -> DVec3 {
411    match mode {
412        SlicePreviewMode::Axial => DVec3::Y,
413        SlicePreviewMode::Coronal | SlicePreviewMode::Sagittal => -DVec3::Z,
414    }
415}
416
417fn slice_basis_from_normal(mode: SlicePreviewMode, normal: DVec3) -> (DVec3, DVec3) {
418    let project_reference = |reference: DVec3| {
419        let projected = reference - normal * reference.dot(normal);
420        (projected.length_squared() > 1.0e-10).then(|| projected.normalize())
421    };
422
423    let up = project_reference(slice_preferred_up_for_mode(mode))
424        .or_else(|| {
425            [DVec3::X, DVec3::Y, DVec3::Z]
426                .into_iter()
427                .find_map(project_reference)
428        })
429        .unwrap_or(DVec3::Y);
430    let right = up.cross(normal).normalize_or(DVec3::X);
431    let up = normal.cross(right).normalize_or(up);
432    (right, up)
433}
434
435fn slice_offset_range(bounds: Aabb, normal: DVec3) -> (f64, f64) {
436    let center = bounds.center();
437    let corners = [
438        DVec3::new(bounds.min.x, bounds.min.y, bounds.min.z),
439        DVec3::new(bounds.min.x, bounds.min.y, bounds.max.z),
440        DVec3::new(bounds.min.x, bounds.max.y, bounds.min.z),
441        DVec3::new(bounds.min.x, bounds.max.y, bounds.max.z),
442        DVec3::new(bounds.max.x, bounds.min.y, bounds.min.z),
443        DVec3::new(bounds.max.x, bounds.min.y, bounds.max.z),
444        DVec3::new(bounds.max.x, bounds.max.y, bounds.min.z),
445        DVec3::new(bounds.max.x, bounds.max.y, bounds.max.z),
446    ];
447
448    let mut min_offset = f64::INFINITY;
449    let mut max_offset = f64::NEG_INFINITY;
450    for corner in corners {
451        let offset = (corner - center).dot(normal);
452        min_offset = min_offset.min(offset);
453        max_offset = max_offset.max(offset);
454    }
455    (min_offset, max_offset)
456}
457
458fn slice_plane_for_state(bounds: Aabb, view_state: SlicePreviewState) -> SlicePlane {
459    let center = bounds.center();
460    let size = bounds.size();
461    let (base_right, base_up) = slice_basis_for_mode(view_state.mode);
462    let default_normal = base_right.cross(base_up).normalize_or(DVec3::Z);
463    let normal = (view_state.orientation * default_normal).normalize_or(default_normal);
464    let (right, up) = slice_basis_from_normal(view_state.mode, normal);
465    let (min_offset, max_offset) = slice_offset_range(bounds, normal);
466    let clamped_offset = view_state.offset.clamp(min_offset, max_offset);
467    let origin = center + normal * clamped_offset;
468
469    match view_state.mode {
470        SlicePreviewMode::Axial => {
471            SlicePlane::new(origin, right, up, size.x.max(1.0), size.y.max(1.0))
472        }
473        SlicePreviewMode::Coronal => {
474            SlicePlane::new(origin, right, up, size.x.max(1.0), size.z.max(1.0))
475        }
476        SlicePreviewMode::Sagittal => {
477            SlicePlane::new(origin, right, up, size.y.max(1.0), size.z.max(1.0))
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn volume_view_state_orbit_and_zoom_clamp() {
488        let mut state = VolumeViewState::default();
489        state.orbit(10.0, 200.0);
490        state.zoom_by(100.0);
491        assert_ne!(state.orientation, DQuat::IDENTITY);
492        assert_eq!(state.zoom, 8.0);
493    }
494
495    #[test]
496    fn transfer_window_defaults_to_soft_tissue_for_ct() {
497        let mut state = VolumeViewState::default();
498        state.ensure_transfer_window(-1024.0, 3071.0);
499        assert_eq!(state.transfer_window(-1024.0, 3071.0), (90.0, 700.0));
500    }
501
502    #[test]
503    fn slice_preview_state_clamps_scroll_to_volume_bounds() {
504        let bounds = Aabb::new(DVec3::ZERO, DVec3::new(10.0, 20.0, 30.0));
505        let mut state = SlicePreviewState::default();
506        state.scroll_by(100.0, bounds);
507        assert_eq!(state.offset, 15.0);
508        state.scroll_by(-100.0, bounds);
509        assert_eq!(state.offset, -15.0);
510    }
511
512    #[test]
513    fn slice_projection_mode_is_remembered_per_axis() {
514        let mut state = SlicePreviewState::default();
515        state.cycle_projection_mode(6.0);
516        assert_eq!(state.projection_mode, SliceProjectionMode::MaximumIntensity);
517        assert_eq!(state.slab_half_thickness, 6.0);
518
519        state.set_mode(SlicePreviewMode::Coronal);
520        assert_eq!(state.projection_mode, SliceProjectionMode::Thin);
521        state.cycle_projection_mode(10.0);
522        state.cycle_projection_mode(10.0);
523        assert_eq!(state.projection_mode, SliceProjectionMode::MinimumIntensity);
524        assert_eq!(state.slab_half_thickness, 10.0);
525
526        state.set_mode(SlicePreviewMode::Axial);
527        assert_eq!(state.projection_mode, SliceProjectionMode::MaximumIntensity);
528        assert_eq!(state.slab_half_thickness, 6.0);
529    }
530
531    #[test]
532    fn slice_default_planes_follow_radiology_view_conventions() {
533        let bounds = Aabb::new(DVec3::ZERO, DVec3::new(10.0, 20.0, 30.0));
534
535        let mut coronal = SlicePreviewState::default();
536        coronal.set_mode(SlicePreviewMode::Coronal);
537        let coronal_plane = coronal.slice_plane(bounds);
538        assert!(coronal_plane.right.distance(DVec3::X) < 1.0e-6);
539        assert!(coronal_plane.up.distance(-DVec3::Z) < 1.0e-6);
540
541        let mut sagittal = SlicePreviewState::default();
542        sagittal.set_mode(SlicePreviewMode::Sagittal);
543        let sagittal_plane = sagittal.slice_plane(bounds);
544        assert!(sagittal_plane.right.distance(DVec3::Y) < 1.0e-6);
545        assert!(sagittal_plane.up.distance(-DVec3::Z) < 1.0e-6);
546    }
547}