Skip to main content

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}