1use glam::{DQuat, DVec3};
4use volren_core::{Aabb, SlicePlane, ThickSlabMode, ThickSlabParams};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum VolumeBlendMode {
9 #[default]
11 Composite,
12 MaximumIntensity,
14 MinimumIntensity,
16 AverageIntensity,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum SlicePreviewMode {
23 #[default]
25 Axial,
26 Coronal,
28 Sagittal,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum SliceProjectionMode {
35 #[default]
37 Thin,
38 MaximumIntensity,
40 MinimumIntensity,
42 AverageIntensity,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct SlicePreviewState {
49 pub mode: SlicePreviewMode,
51 pub offset: f64,
53 pub orientation: DQuat,
55 pub projection_mode: SliceProjectionMode,
57 pub slab_half_thickness: f64,
59 pub crosshair_world: Option<DVec3>,
61 pub transfer_center_hu: Option<f64>,
63 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 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 #[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 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 pub fn reset(&mut self) {
114 *self = Self::default();
115 }
116
117 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 #[must_use]
126 pub fn slice_plane(&self, bounds: Aabb) -> SlicePlane {
127 slice_plane_for_state(bounds, *self)
128 }
129
130 #[must_use]
132 pub fn crosshair_world(&self, bounds: Aabb) -> DVec3 {
133 self.crosshair_world.unwrap_or(bounds.center())
134 }
135
136 pub fn set_crosshair_world(&mut self, world: DVec3) {
138 self.crosshair_world = Some(world);
139 }
140
141 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 pub fn center_on_crosshair(&mut self, bounds: Aabb) {
153 self.center_on_world(self.crosshair_world(bounds), bounds);
154 }
155
156 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 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 #[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 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
244pub struct VolumeViewState {
245 pub orientation: DQuat,
247 pub pan_x: f64,
249 pub pan_y: f64,
251 pub zoom: f64,
253 pub blend_mode: VolumeBlendMode,
255 pub transfer_center_hu: Option<f64>,
257 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 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 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 pub fn zoom_by(&mut self, factor: f64) {
292 self.zoom = (self.zoom * factor).clamp(0.25, 8.0);
293 }
294
295 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 #[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 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 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}