dicom_viewer/dcm/study.rs
1//! In-memory model of loaded DICOM studies.
2//!
3//! Only metadata is held here; pixel data is decoded on demand by
4//! [`crate::dcm::pixel`] and cached as egui textures in the app layer.
5
6use std::path::PathBuf;
7
8/// A single DICOM image (one frame, one SOP Instance).
9///
10/// Metadata only — pixel data is decoded on demand by
11/// [`crate::dcm::pixel::load_raw`]. The whole struct is intentionally
12/// cheap to clone so it can be passed across rendering / measurement /
13/// export paths.
14#[allow(dead_code)] // some fields surface in the metadata panel (Milestone 6)
15#[derive(Debug, Clone)]
16pub struct Instance {
17 /// On-disk path to the DICOM file.
18 pub path: PathBuf,
19 /// SOP Instance UID — used as the cache key for pixel data,
20 /// annotations, and rendered textures.
21 pub sop_instance_uid: String,
22 /// `InstanceNumber` — slice ordering fallback when ImagePositionPatient
23 /// is missing.
24 pub instance_number: i32,
25 /// Pixel `Rows`.
26 pub rows: u16,
27 /// Pixel `Columns`.
28 pub cols: u16,
29 /// `Modality` (CT, MR, MG, CR, …).
30 pub modality: String,
31 /// `PhotometricInterpretation`. `MONOCHROME1` requires display
32 /// inversion.
33 pub photometric: String,
34 /// Default VOI window centre, if present in the file.
35 pub window_center: Option<f64>,
36 /// Default VOI window width, if present in the file.
37 pub window_width: Option<f64>,
38 /// `RescaleSlope` (default 1.0).
39 pub rescale_slope: f64,
40 /// `RescaleIntercept` (default 0.0).
41 pub rescale_intercept: f64,
42 /// `(row_spacing_mm, col_spacing_mm)` from `PixelSpacing`, when
43 /// present. Drives millimetre measurements.
44 pub pixel_spacing: Option<(f64, f64)>,
45 /// MG-specific: "CC", "MLO", etc.
46 pub view_position: Option<String>,
47 /// MG-specific: "L" or "R".
48 pub image_laterality: Option<String>,
49 /// ImagePositionPatient z — gold-standard slice ordering for CT/MR.
50 /// `None` when the series doesn't expose it (e.g. MG, US).
51 pub image_position_z: Option<f64>,
52}
53
54/// One DICOM series (typically one acquisition, many instances).
55///
56/// Instances are pre-sorted by `ImagePositionPatient` z when available
57/// (CT/MR), otherwise by `InstanceNumber`.
58#[allow(dead_code)]
59#[derive(Debug, Clone)]
60pub struct Series {
61 /// Series Instance UID — stable across re-scans of the same disc.
62 pub series_instance_uid: String,
63 /// `SeriesNumber` — drives sidebar ordering within a study.
64 pub series_number: i32,
65 /// Series modality. Empty when the source files don't agree.
66 pub modality: String,
67 /// `SeriesDescription`, if present.
68 pub description: String,
69 /// Slices ordered for radiologist-friendly scrolling.
70 pub instances: Vec<Instance>,
71}
72
73impl Series {
74 /// `true` when the series modality is mammography (MG). Drives the
75 /// 2×2 hanging-protocol path in [`crate::app::DicomViewerApp::select_series`].
76 pub fn is_mammography(&self) -> bool {
77 self.modality.eq_ignore_ascii_case("MG")
78 }
79}
80
81/// A DICOM study — one patient encounter, possibly multiple series.
82///
83/// Studies are sorted newest-first by [`Self::study_date`] in
84/// [`crate::dcm::loader::load_folder`].
85#[allow(dead_code)]
86#[derive(Debug, Clone)]
87pub struct Study {
88 /// Study Instance UID.
89 pub study_instance_uid: String,
90 /// `PatientName`, raw DICOM-encoded string.
91 pub patient_name: String,
92 /// `PatientID`.
93 pub patient_id: String,
94 /// `StudyDate` (YYYYMMDD). Empty when missing.
95 pub study_date: String,
96 /// `StudyDescription`.
97 pub study_description: String,
98 /// Distinct modality codes seen across the study's series.
99 pub modalities: Vec<String>,
100 /// Series, ordered by `SeriesNumber`.
101 pub series: Vec<Series>,
102}
103
104impl Study {
105 /// One-line label used in the sidebar header (e.g.
106 /// `"Doe^John [CT/MG] 20251104"`).
107 pub fn label(&self) -> String {
108 let mods = if self.modalities.is_empty() {
109 "—".into()
110 } else {
111 self.modalities.join("/")
112 };
113 let name = if self.patient_name.is_empty() {
114 "(no name)".into()
115 } else {
116 self.patient_name.clone()
117 };
118 format!("{name} [{mods}] {}", self.study_date)
119 }
120}