diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..ac2b23f
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[target.x86_64-pc-windows-msvc]
+rustflags = ["-C", "target-feature=+crt-static"]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f9b7f7b..6bc49e0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,14 +28,6 @@ jobs:
path: dicom-viewer
persist-credentials: false
- - name: Checkout wsi-dicom-annotations
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: frames-sg/wsi-dicom-annotations
- ref: a623e84405d9be6779e7065a71031228b20849a5
- path: wsi-dicom-annotations
- persist-credentials: false
-
- name: Install Linux GUI dependencies
if: runner.os == 'Linux'
run: |
@@ -108,14 +100,6 @@ jobs:
path: dicom-viewer
persist-credentials: false
- - name: Checkout wsi-dicom-annotations
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- repository: frames-sg/wsi-dicom-annotations
- ref: a623e84405d9be6779e7065a71031228b20849a5
- path: wsi-dicom-annotations
- persist-credentials: false
-
- name: Install Rust
uses: dtolnay/rust-toolchain@a75363d06101555fc97c6c7e1e65670b99104d98 # 1.96.1
diff --git a/.gitignore b/.gitignore
index d79565e..78f2998 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,55 @@
/.DS_Store
*.profraw
*.profdata
+
+# Local tools, credentials, and editor state
+.DS_Store
+.codex/
+.claude/
+.local-docs/
+.local-tools/
+.codex-security-*/
+reasonix.toml
+.env
+.env.*
+!.env.example
+!.env.sample
+*.key
+*.pem
+*.p12
+*.pfx
+.vscode/
+.idea/
+*.swp
+
+# Local dependencies, build outputs, and caches
+node_modules/
+.venv*/
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.ruff_cache/
+/dist/
+/coverage/
+lcov*.info
+*.log
+
+# Private writing and execution plans; maintained outside the software repos
+/manuscript/
+/grant/
+/paper/
+/papers/
+/uscap/
+/docs/manuscript/
+/docs/USCAP*
+/docs/papers/
+/docs/benchmarks/paper/
+/docs/plans/
+/docs/workplans/
+/docs/refactor/
+/docs/superpowers/
+/MIGRATION.md
+/migration.md
+*.docx
+*.pptx
+*.pdf
diff --git a/Cargo.lock b/Cargo.lock
index 6794130..7f722b5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -995,7 +995,6 @@ dependencies = [
"j2k-metal-support",
"j2k-native",
"metal-wgpu-interop",
- "peak_alloc",
"pollster",
"rfd",
"rstar",
@@ -1004,20 +1003,19 @@ dependencies = [
"sha2",
"tempfile",
"thiserror 2.0.20",
+ "tiff",
"uuid",
+ "winresource",
"wsi-dicom-annotations",
]
[[package]]
name = "dicom-viewer-core"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"dicom-core",
"dicom-dictionary-std",
- "dicom-encoding",
"dicom-object",
- "dicom-parser",
- "dicom-transfer-syntax-registry",
"geo",
"j2k-metal-support",
"j2k-native",
@@ -1107,6 +1105,18 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+[[package]]
+name = "dwrote"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "winapi",
+ "wio",
+]
+
[[package]]
name = "ecolor"
version = "0.34.3"
@@ -1358,11 +1368,10 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.34.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6675898a291ec212fc3df04f537d177fce8496120244590e6359dcaa4c25da79"
dependencies = [
"ahash",
"bytemuck",
+ "dwrote",
"ecolor",
"emath",
"epaint_default_fonts",
@@ -1376,6 +1385,8 @@ dependencies = [
"skrifa",
"smallvec",
"vello_cpu",
+ "winapi",
+ "wio",
]
[[package]]
@@ -1397,7 +1408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3334,12 +3345,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
-[[package]]
-name = "peak_alloc"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccc90935a8dd139fdf341762773687a1e361d3f54b396a55d9dd1f7001e484bb"
-
[[package]]
name = "peniko"
version = "0.6.1"
@@ -3934,7 +3939,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4311,7 +4316,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4515,7 +4520,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5108,7 +5113,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5452,6 +5457,24 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winresource"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "wio"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -5554,7 +5577,8 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "wsi-dicom-annotations"
-version = "0.1.1"
+version = "0.1.2"
+source = "git+https://github.com/frames-sg/wsi-dicom-annotations.git?rev=71851b4a0c286fa9b57326e426962bf1a63a781e#71851b4a0c286fa9b57326e426962bf1a63a781e"
dependencies = [
"chrono",
"dicom-core",
diff --git a/Cargo.toml b/Cargo.toml
index 1dc9b8c..18ea948 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ members = [
"crates/dicom-viewer-core",
"crates/metal-wgpu-interop",
]
-exclude = ["vendor/lru", "vendor/wayland-scanner"]
+exclude = ["vendor/epaint", "vendor/lru", "vendor/wayland-scanner"]
[workspace.package]
edition = "2021"
@@ -16,10 +16,7 @@ repository = "https://github.com/frames-sg/dicom-viewer"
[workspace.dependencies]
dicom-core = "0.9"
dicom-dictionary-std = "0.9"
-dicom-encoding = "0.9"
dicom-object = "0.9"
-dicom-parser = "0.9"
-dicom-transfer-syntax-registry = { version = "0.9", default-features = false }
eframe = { version = "0.34.2", default-features = false, features = [
"accesskit",
"default_fonts",
@@ -43,17 +40,18 @@ serde = { version = "1", features = ["derive", "rc"] }
wsi-rs = { version = "0.6.0", git = "https://github.com/frames-sg/wsi-rs.git", rev = "b940ea94f3290e54ca2c5f87823109538709c59d" }
tempfile = "3"
thiserror = "2"
+tiff = "0.11.3"
lcms2 = { version = "6.1.1", features = ["static"] }
sha2 = "0.10"
uuid = { version = "1", features = ["serde", "v4"] }
geo = { version = "0.33.1", default-features = false }
rstar = "0.12.2"
-peak_alloc = "0.3"
-wsi-dicom-annotations = { version = "=0.1.1", path = "../wsi-dicom-annotations" }
+wsi-dicom-annotations = { version = "=0.1.2", git = "https://github.com/frames-sg/wsi-dicom-annotations.git", rev = "71851b4a0c286fa9b57326e426962bf1a63a781e" }
-# Temporary security patches for advisories without compatible upstream
-# releases. See each vendored crate's SECURITY-PATCH.md for removal criteria.
+# Dependency patches and the Windows font backend; see SUPPLY_CHAIN.md.
+# Security-patched crates retain their SECURITY-PATCH.md removal criteria.
[patch.crates-io]
+epaint = { path = "vendor/epaint" }
lru = { path = "vendor/lru" }
wayland-scanner = { path = "vendor/wayland-scanner" }
diff --git a/README.md b/README.md
index 950cb7f..b6dd003 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,6 @@
Lightweight desktop viewer for local whole-slide image files through `wsi-rs`.
-This application is for research use only. It is not a medical device and is
-not intended for diagnosis, treatment decisions, or other clinical use. Use
-only research inputs that contain no patient data; the viewer does not perform
-de-identification or validate that an input is free of identifying metadata.
-
The app is intended for checking `wsi-dicom` output locally and for verifying
other wsi-rs-supported WSI inputs. It does not upload files or use DICOMweb.
Its facts panel reads only the technical WSI tags documented below; local file
@@ -14,7 +9,7 @@ paths can still be visible in the UI and in screenshots.
See [Architecture](docs/ARCHITECTURE.md) for ownership, scheduling, cache, and
Metal interoperability invariants, the [pathology annotation
-workflow](docs/ANNOTATION_WORKFLOW.md), and the [research release
+workflow](docs/ANNOTATION_WORKFLOW.md), and the [release
checklist](docs/RELEASE.md) for distribution gates.
## Build
@@ -27,6 +22,15 @@ complete revisions, so no sibling codec checkout is required.
cargo run -p dicom-viewer
```
+Build the standalone Windows GUI executable on an x86-64 Windows host with:
+
+```powershell
+cargo build -p dicom-viewer --bin dicom-viewer --release --locked
+```
+
+The Windows build statically links the MSVC runtime, so the resulting
+`target\release\dicom-viewer.exe` requires no application-specific sidecar DLLs.
+
wgpu is the only presentation backend on every platform. On macOS, ordinary
builds automatically enable `wsi-rs` Metal decoding and use the renderer's
exact Metal device; resident RGB tiles are converted to RGBA by a wgpu compute
@@ -81,7 +85,7 @@ space, wsi-rs source caches, or GPU driver overhead.
DICOM inspection rejects metadata beyond explicit resource limits before the
eager object parser runs: 1 MiB of file-meta data, 16 MiB per primitive value,
128 MiB of cumulative primitive values, two million metadata tokens, and 64
-nested sequences. These are research-viewer safety limits rather than DICOM
+nested sequences. These are viewer safety limits rather than DICOM
conformance claims.
## Pathology annotation workspace
@@ -129,10 +133,20 @@ contract](docs/FRAMES_PATHOLOGY_GEOJSON_V1.md), [tumor-mask compatibility
adapter](docs/TUMOR_MASK_COMPATIBILITY.md), and [workspace storage/privacy
notes](docs/WORKSPACE_STORAGE.md).
+### Annotation dependency source
+
+Annotations 0.1.2 is pinned to immutable Git revision
+`71851b4a0c286fa9b57326e426962bf1a63a781e` in `frames-sg/wsi-dicom-annotations`.
+That revision owns the shared metadata reader and headless CLI. CI checks out only
+the viewer; Cargo resolves the owner without a sibling directory or local overlay.
+The dependency remains versioned and locked. A later registry migration requires
+publication of the matching API and a reviewed lockfile refresh.
+
### Headless annotation interoperability probe
-`annotation_probe` is a thin CLI over the separately versioned
-`wsi-dicom-annotations` library. It exposes ANN/SEG parsing and rewriting plus
+`annotation_probe` is maintained in the `wsi-dicom-annotations` repository as the
+`wsi-annotation-probe` package. Build it there with
+`cargo build -p wsi-annotation-probe --bin annotation_probe --locked`. It exposes ANN/SEG parsing and rewriting plus
Rust-owned GeoJSON and raster conversion without the GUI. It writes one
schema-versioned JSON object to stdout; warnings and human-readable failures go
to stderr.
@@ -207,8 +221,6 @@ Dual-licensed under either [MIT](LICENSE-MIT) or
## Current Scope
-- Research-use-only operation with non-patient inputs; no clinical claims or
- de-identification workflow.
- Desktop-only `egui/eframe` app with a unified wgpu renderer.
- Open one wsi-rs-supported WSI file or a folder of DICOM instances.
- View WSI levels as tiled RGB/RGBA pixels through `wsi-rs`.
diff --git a/SUPPLY_CHAIN.md b/SUPPLY_CHAIN.md
index 92bba98..a7d26fc 100644
--- a/SUPPLY_CHAIN.md
+++ b/SUPPLY_CHAIN.md
@@ -15,7 +15,7 @@ The production raster boundary adds only these direct crates:
- `npyz = 0.9.1` (MIT): no optional features are enabled. The crate publishes
no `rust-version`, so compatibility is established by the locked workspace
build on Rust 1.96 rather than an upstream MSRV declaration.
-- `zarrs = 0.23.13` (MIT OR Apache-2.0, declared Rust 1.91): defaults are
+- `zarrs = 0.23.14` (MIT OR Apache-2.0, declared Rust 1.91): defaults are
disabled; only `filesystem`, `blosc`, `crc32c`, `gzip`, `sharding`, and
`zstd` are enabled. The application exposes only a local filesystem array
path and does not compile the `ndarray`, async, remote-store, or optional
@@ -31,10 +31,16 @@ input surface.
## Temporary security patch
+The local `vendor/epaint` 0.34.3 integration also supplies the Windows DirectWrite
+font backend. It adds `dwrote` 0.11.5 on Windows. The license policy permits
+MPL-2.0 only for that exact crate version, with source and distribution notices
+in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). This font integration is
+separate from the security patches below.
+
`vendor/lru` is the crates.io `lru 0.16.4` source with the upstream
panic-safety fix and regression test from commit
`f9a7f00fcf2d33e00adb03758cb350aaaa52cddb`. This addresses
-RUSTSEC-2026-0253 while `zarrs 0.23.13` still requires `lru 0.16.x`. See
+RUSTSEC-2026-0253 while `zarrs 0.23.14` still requires `lru 0.16.x`. See
`vendor/lru/SECURITY-PATCH.md` for source, checksum, and removal criteria.
`vendor/wayland-scanner` is the crates.io `wayland-scanner 0.31.10` source with
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..8f26633
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,15 @@
+# Third-party notices
+
+## dwrote 0.11.5 — Windows DirectWrite binding
+
+Windows builds use the unmodified `dwrote` crate through the local epaint font
+backend. Its authors are the Servo Project Developers and Vladimir Vukicevic.
+The crate is licensed under the [Mozilla Public License 2.0](https://www.mozilla.org/en-US/MPL/2.0/).
+
+The corresponding source is available in the
+[0.11.5 registry package](https://crates.io/crates/dwrote/0.11.5) and at its
+[recorded upstream revision](https://github.com/servo/dwrote-rs/tree/5bbe910f7156213cdbaca4e1addfe85ea8b421ef).
+
+Include this notice and the MPL 2.0 license text in Windows release packages.
+If this dependency is modified, provide the corresponding modified covered source
+and retain its license notices. See [Mozilla's distribution guidance](https://www.mozilla.org/en-US/MPL/2.0/FAQ/).
diff --git a/apps/dicom-viewer/Cargo.toml b/apps/dicom-viewer/Cargo.toml
index 0d6669e..bc8f5e5 100644
--- a/apps/dicom-viewer/Cargo.toml
+++ b/apps/dicom-viewer/Cargo.toml
@@ -1,6 +1,7 @@
[package]
name = "dicom-viewer"
version = "0.1.0"
+build = "build.rs"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
@@ -11,19 +12,22 @@ default = []
cuda = ["dicom-viewer-core/cuda"]
[dependencies]
-dicom-viewer-core = { version = "=0.1.0", path = "../../crates/dicom-viewer-core" }
+dicom-viewer-core = { version = "=0.1.1", path = "../../crates/dicom-viewer-core" }
wsi-dicom-annotations = { workspace = true }
eframe = { workspace = true }
rfd = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
-peak_alloc = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
+tiff = { workspace = true }
uuid = { workspace = true }
rstar = { workspace = true }
+[build-dependencies]
+winresource = { version = "0.1.31", default-features = false }
+
[target.'cfg(target_os = "macos")'.dependencies]
j2k-metal-support = { workspace = true }
metal-wgpu-interop = { version = "=0.1.0", path = "../../crates/metal-wgpu-interop" }
diff --git a/apps/dicom-viewer/assets/app-icon.ico b/apps/dicom-viewer/assets/app-icon.ico
new file mode 100644
index 0000000..298d13e
Binary files /dev/null and b/apps/dicom-viewer/assets/app-icon.ico differ
diff --git a/apps/dicom-viewer/assets/app-icon.png b/apps/dicom-viewer/assets/app-icon.png
new file mode 100644
index 0000000..4da3a7a
Binary files /dev/null and b/apps/dicom-viewer/assets/app-icon.png differ
diff --git a/apps/dicom-viewer/assets/app-icon.svg b/apps/dicom-viewer/assets/app-icon.svg
new file mode 100644
index 0000000..0732583
--- /dev/null
+++ b/apps/dicom-viewer/assets/app-icon.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/apps/dicom-viewer/build.rs b/apps/dicom-viewer/build.rs
new file mode 100644
index 0000000..2ce0d0b
--- /dev/null
+++ b/apps/dicom-viewer/build.rs
@@ -0,0 +1,27 @@
+use std::path::PathBuf;
+
+fn main() {
+ println!("cargo:rerun-if-changed=assets/app-icon.ico");
+ if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
+ return;
+ }
+
+ let icon = PathBuf::from(
+ std::env::var_os("CARGO_MANIFEST_DIR")
+ .expect("Cargo must provide the package manifest directory"),
+ )
+ .join("assets")
+ .join("app-icon.ico");
+ let icon = icon
+ .to_str()
+ .expect("the embedded icon path must be valid UTF-8");
+
+ let mut resource = winresource::WindowsResource::new();
+ resource
+ .set_icon(icon)
+ .set("FileDescription", "Slide Viewer")
+ .set("ProductName", "Slide Viewer")
+ .set("OriginalFilename", "Slide-Viewer.exe")
+ .compile()
+ .expect("failed to embed the Slide Viewer Windows resources");
+}
diff --git a/apps/dicom-viewer/src/app.rs b/apps/dicom-viewer/src/app.rs
index 8460817..5c42153 100644
--- a/apps/dicom-viewer/src/app.rs
+++ b/apps/dicom-viewer/src/app.rs
@@ -3,7 +3,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use dicom_viewer_core::{TileDecodeBackend, ViewerStudy};
-use eframe::egui::{self, Frame, Sense};
+use eframe::egui;
mod annotation_actions;
mod annotation_job;
@@ -11,6 +11,7 @@ mod background_worker;
mod bounded_input;
mod camera;
mod canvas;
+mod canvas_frame;
mod export_job;
mod format;
mod level_warmer;
@@ -25,6 +26,7 @@ mod theme;
mod tile;
mod ui;
mod viewport;
+mod viewport_export;
mod workspace;
mod workspace_actions;
mod workspace_dialogs;
@@ -32,9 +34,11 @@ mod workspace_interaction;
use annotation_job::AnnotationLoadResult;
use background_worker::BackgroundWorker;
-use camera::{wheel_zoom_factor, CameraState};
+#[cfg(test)]
+use camera::{raw_wheel_delta_y, wheel_zoom_factor};
#[cfg(test)]
use camera::{CameraMotion, CameraView, MAX_ZOOM, MIN_ZOOM};
+use camera::{CameraState, WheelZoomSettings};
use canvas::SlideCanvas;
use export_job::WorkspaceExportJob;
use open_job::{OpenPoll, OpenQueue};
@@ -43,16 +47,16 @@ use raster::RasterState;
use report::ReportState;
use ui::chrome::{show_status_bar, show_toolbar, ToolbarState};
use ui::facts::show_facts_sidebar;
-use ui::overlay::{
- draw_canvas_overlays, paint_canvas_background, paint_empty_state, FrameStats, OverlayInfo,
-};
-use ui::pathology_workspace::{show_pathology_workspace_panel, show_tool_rail};
+use ui::overlay::FrameStats;
+use ui::pathology_workspace::{show_populated_pathology_workspace_panel, show_tool_rail};
+#[cfg(test)]
use viewport::screen_to_base;
#[cfg(test)]
use viewport::{choose_render_level, visible_tiles};
+use viewport_export::PendingViewportExport;
use workspace::{
- draw_external_layer_overlays, draw_workspace_overlay, AutosaveStatus, RestoredWorkspace,
- RevisionStore, SchemeLibrary, WorkspaceAutosave, WorkspaceRuntime, WorkspaceSaveRequest,
+ AutosaveStatus, RestoredWorkspace, RevisionStore, SchemeLibrary, WorkspaceAutosave,
+ WorkspaceRuntime, WorkspaceSaveRequest,
};
#[cfg(test)]
@@ -69,6 +73,7 @@ const VISIBLE_UPLOAD_BUDGET: Duration = Duration::from_millis(6);
const MAX_TRANSITION_UPLOADS_PER_FRAME: usize = 2;
const MAX_PREFETCH_UPLOADS_PER_FRAME: usize = 4;
const PREFETCH_UPLOAD_BUDGET: Duration = Duration::from_millis(1);
+const WHEEL_ZOOM_STORAGE_KEY: &str = "dicom-viewer-wheel-zoom-v1";
pub struct DicomViewerApp {
study: Option>,
@@ -80,11 +85,15 @@ pub struct DicomViewerApp {
frame_stats: FrameStats,
camera: CameraState,
show_facts_panel: bool,
+ show_pathology_workspace: bool,
+ wheel_zoom: WheelZoomSettings,
pathology: PathologyState,
report: ReportState,
raster: RasterState,
annotation_load_job: Option>,
workspace_export_job: Option,
+ pending_viewport_export: Option,
+ last_canvas_rect: Option,
active_path: Option,
reported_cpu_fallbacks: usize,
workspace: Option,
@@ -249,6 +258,13 @@ impl DicomViewerApp {
pub fn new(cc: &eframe::CreationContext<'_>, initial_path: Option) -> Self {
theme::install_visuals(&cc.egui_ctx);
+ let wheel_zoom = cc
+ .storage
+ .and_then(|storage| {
+ eframe::get_value::(storage, WHEEL_ZOOM_STORAGE_KEY)
+ })
+ .unwrap_or_default()
+ .sanitized();
let render_state = cc
.wgpu_render_state
.clone()
@@ -283,11 +299,15 @@ impl DicomViewerApp {
frame_stats: FrameStats::default(),
camera: CameraState::default(),
show_facts_panel: false,
+ show_pathology_workspace: false,
+ wheel_zoom,
pathology: PathologyState::default(),
report: ReportState::default(),
raster: RasterState::default(),
annotation_load_job: None,
workspace_export_job: None,
+ pending_viewport_export: None,
+ last_canvas_rect: None,
active_path: None,
reported_cpu_fallbacks: 0,
workspace: None,
@@ -377,12 +397,15 @@ impl DicomViewerApp {
job.cancel();
}
self.workspace_export_job = None;
+ self.pending_viewport_export = None;
+ self.last_canvas_rect = None;
self.active_path = None;
self.reported_cpu_fallbacks = 0;
self.workspace = None;
self.autosave = None;
self.pending_restore = None;
self.pending_scheme_migration = None;
+ self.show_pathology_workspace = false;
self.show_import_wizard = false;
self.show_export_wizard = false;
self.last_queued_workspace_revision = None;
@@ -507,7 +530,71 @@ fn reconcile_discovered_sidecar_stubs(
Ok(())
}
+impl DicomViewerApp {
+ fn show_app_toolbar(&mut self, ui: &mut egui::Ui) -> ui::chrome::ToolbarActions {
+ let has_study = self.study.is_some();
+
+ let autosave_label = self.autosave_label();
+ let (can_undo, can_redo) = self.workspace.as_ref().map_or((false, false), |runtime| {
+ (runtime.can_undo(), runtime.can_redo())
+ });
+ show_toolbar(
+ ui,
+ ToolbarState {
+ has_study,
+ show_facts: &mut self.show_facts_panel,
+ show_pathology: &mut self.show_pathology_workspace,
+ can_undo,
+ can_redo,
+ autosave_status: &autosave_label,
+ export_running: self.workspace_export_job.is_some()
+ || self.pending_viewport_export.is_some(),
+ export_cancel_requested: self
+ .workspace_export_job
+ .as_ref()
+ .is_some_and(WorkspaceExportJob::cancellation_requested),
+ smooth_camera: self.camera.smoothing_enabled_mut(),
+ wheel_zoom: &mut self.wheel_zoom,
+ },
+ )
+ }
+
+ fn apply_toolbar_actions(&mut self, ctx: &egui::Context, actions: &ui::chrome::ToolbarActions) {
+ if actions.open_file {
+ self.pick_file(ctx);
+ }
+ if actions.open_folder {
+ self.pick_folder(ctx);
+ }
+ if actions.undo && self.workspace.as_mut().is_some_and(WorkspaceRuntime::undo) {
+ self.status = "Undid the last pathology command.".into();
+ }
+ if actions.redo && self.workspace.as_mut().is_some_and(WorkspaceRuntime::redo) {
+ self.status = "Redid the pathology command.".into();
+ }
+ if actions.import {
+ self.show_pathology_workspace = true;
+ self.show_import_wizard = true;
+ }
+ if actions.export {
+ self.show_export_wizard = true;
+ }
+ if actions.cancel_export {
+ if let Some(job) = &self.workspace_export_job {
+ job.cancel();
+ self.status = "Cancelling export; the destination will remain unchanged…".into();
+ } else if self.pending_viewport_export.take().is_some() {
+ self.status = "Cancelled current view capture.".into();
+ }
+ }
+ }
+}
+
impl eframe::App for DicomViewerApp {
+ fn save(&mut self, storage: &mut dyn eframe::Storage) {
+ eframe::set_value(storage, WHEEL_ZOOM_STORAGE_KEY, &self.wheel_zoom);
+ }
+
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_workspace_autosave();
if ctx.input(|input| input.viewport().close_requested()) {
@@ -545,6 +632,7 @@ impl eframe::App for DicomViewerApp {
self.handle_dropped_files(ctx);
self.poll_open_job(ctx);
self.poll_annotation_jobs(ctx);
+ self.poll_current_view_tiff_export(ctx);
self.poll_workspace_export_job(ctx);
self.poll_pathology_job();
self.poll_report_job();
@@ -556,57 +644,13 @@ impl eframe::App for DicomViewerApp {
let (stable_dt, predicted_dt) = ui.input(|input| (input.stable_dt, input.predicted_dt));
self.frame_stats.record(stable_dt, predicted_dt);
- let has_study = self.study.is_some();
-
- let autosave_label = self.autosave_label();
- let (can_undo, can_redo) = self.workspace.as_ref().map_or((false, false), |runtime| {
- (runtime.can_undo(), runtime.can_redo())
- });
- let actions = show_toolbar(
- ui,
- ToolbarState {
- has_study,
- show_facts: &mut self.show_facts_panel,
- can_undo,
- can_redo,
- autosave_status: &autosave_label,
- export_running: self.workspace_export_job.is_some(),
- export_cancel_requested: self
- .workspace_export_job
- .as_ref()
- .is_some_and(WorkspaceExportJob::cancellation_requested),
- smooth_camera: self.camera.smoothing_enabled_mut(),
- },
- );
- if actions.open_file {
- self.pick_file(ui.ctx());
- }
- if actions.open_folder {
- self.pick_folder(ui.ctx());
- }
+ let actions = self.show_app_toolbar(ui);
+ self.apply_toolbar_actions(ui.ctx(), &actions);
// Opening replaces the active study and generation. Refresh the frame
// snapshot after the modal picker returns so this frame cannot submit
// work for the previous study under the replacement generation.
let study = self.study.clone();
let opening = self.open_queue.is_opening();
- if actions.undo && self.workspace.as_mut().is_some_and(WorkspaceRuntime::undo) {
- self.status = "Undid the last pathology command.".into();
- }
- if actions.redo && self.workspace.as_mut().is_some_and(WorkspaceRuntime::redo) {
- self.status = "Redid the pathology command.".into();
- }
- if actions.import {
- self.show_import_wizard = true;
- }
- if actions.export {
- self.show_export_wizard = true;
- }
- if actions.cancel_export {
- if let Some(job) = &self.workspace_export_job {
- job.cancel();
- }
- self.status = "Cancelling export; the destination will remain unchanged…".into();
- }
show_status_bar(
ui,
&self.status,
@@ -619,176 +663,21 @@ impl eframe::App for DicomViewerApp {
self.active_generation,
study.as_ref().map(|study| study.summary()),
);
- if let Some(runtime) = &mut self.workspace {
- if let Some(error) = show_tool_rail(ui, runtime) {
- self.status = error;
- }
- let panel_actions = show_pathology_workspace_panel(ui, runtime);
- self.handle_pathology_workspace_actions(panel_actions, ui.ctx());
- }
- self.show_workspace_dialogs(ui.ctx());
- // ── Central canvas ─────────────────────────────────────────
- egui::CentralPanel::default_margins()
- .frame(Frame::NONE.fill(theme::CANVAS))
- .show_inside(ui, |ui| {
- let rect = ui.available_rect_before_wrap();
- let response = ui.allocate_rect(rect, Sense::click_and_drag());
- if response.clicked() || response.drag_started() {
- response.request_focus();
- } else if ui.input(|input| input.pointer.any_pressed()) && !response.hovered() {
- response.surrender_focus();
+ if self.show_pathology_workspace {
+ if let Some(runtime) = &mut self.workspace {
+ if let Some(error) = show_tool_rail(ui, runtime) {
+ self.status = error;
}
- let painter = ui.painter_at(rect);
- paint_canvas_background(&painter, rect);
-
- let Some(study) = study.clone() else {
- paint_empty_state(&painter, rect, opening);
- return;
- };
-
- if actions.fit {
- self.canvas.record_zoom_input();
- self.camera.request_fit();
- }
- self.camera.prepare_canvas(rect, study.summary());
- if actions.zoom_out {
- self.canvas.record_zoom_input();
- self.camera.zoom_about_center(rect, 0.8);
- }
- if actions.zoom_in {
- self.canvas.record_zoom_input();
- self.camera.zoom_about_center(rect, 1.25);
- }
-
- let accepts_keys =
- (response.hovered() || response.has_focus()) && !ui.ctx().text_edit_focused();
- let zoom_before_keys = self.camera.target_view().zoom;
- if self.camera.handle_keys(ui, rect, accepts_keys) {
- if (self.camera.target_view().zoom - zoom_before_keys).abs() > f32::EPSILON {
- self.canvas.record_zoom_input();
+ if let Some(panel_actions) = show_populated_pathology_workspace_panel(ui, runtime) {
+ if panel_actions.close_panel {
+ self.show_pathology_workspace = false;
}
- ui.ctx().request_repaint();
- }
-
- let camera_frame = self.camera.frame(rect, study.summary(), stable_dt);
- if camera_frame.animating {
- ui.ctx().request_repaint();
+ self.handle_pathology_workspace_actions(panel_actions, ui.ctx());
}
- let workspace_interaction = self.handle_workspace_interaction(
- ui,
- &response,
- rect,
- study.summary(),
- camera_frame.rendered,
- accepts_keys,
- );
-
- if workspace_interaction.pan_requested {
- self.camera
- .pan_by_rendered(response.drag_delta(), camera_frame.rendered);
- ui.ctx().request_repaint();
- }
- if response.double_clicked() && !workspace_interaction.click_consumed {
- let pointer = response.interact_pointer_pos().unwrap_or(rect.center());
- self.canvas.record_zoom_input();
- self.camera
- .zoom_around_rendered(rect, pointer, 2.0, camera_frame.rendered);
- ui.ctx().request_repaint();
- }
-
- if response.hovered() {
- let scroll_y = ui.input(|input| input.smooth_scroll_delta.y);
- if scroll_y.abs() > 0.0 {
- let pointer = ui
- .input(|input| input.pointer.hover_pos())
- .unwrap_or(rect.center());
- self.canvas.record_zoom_input();
- self.camera.zoom_around_rendered(
- rect,
- pointer,
- wheel_zoom_factor(scroll_y),
- camera_frame.rendered,
- );
- ui.ctx().request_repaint();
- }
- let pinch = ui.input(|input| input.zoom_delta());
- if (pinch - 1.0).abs() > 0.001 {
- let pointer = ui
- .input(|input| input.pointer.hover_pos())
- .unwrap_or(rect.center());
- self.canvas.record_zoom_input();
- self.camera.zoom_around_rendered(
- rect,
- pointer,
- pinch,
- camera_frame.rendered,
- );
- ui.ctx().request_repaint();
- }
- if ui.input(|input| input.pointer.any_down()) {
- ui.ctx().request_repaint();
- }
- }
-
- self.canvas.paint(
- ui.ctx(),
- &painter,
- rect,
- &study,
- self.active_generation,
- camera_frame,
- );
- if let Some(runtime) = &mut self.workspace {
- if let Err(error) = runtime.refresh_spatial_index() {
- self.status =
- format!("Could not update annotation viewport index: {error}");
- }
- }
- if let Some((count, reason)) = self.canvas.cpu_fallback() {
- if count > self.reported_cpu_fallbacks {
- self.reported_cpu_fallbacks = count;
- self.status = format!(
- "{} preferred → wgpu; CPU fallback used for {count} tile(s): {reason}",
- study.summary().tile_decode_backend
- );
- }
- }
-
- let hover_base = response.hover_pos().map(|p| {
- screen_to_base(
- rect,
- p,
- camera_frame.rendered.center_base,
- camera_frame.rendered.zoom,
- )
- });
- let tile_failure = self.canvas.tile_failure();
- let debug_stats = self.canvas.debug_stats_text();
- draw_canvas_overlays(
- &painter,
- rect,
- OverlayInfo {
- summary: study.summary(),
- zoom: camera_frame.rendered.zoom,
- frame_rate: self.frame_stats.info(),
- hover_base,
- tile_failure,
- debug_stats: debug_stats.as_deref(),
- },
- );
- if let Some(runtime) = &self.workspace {
- if let Some(context) = study.annotation_context() {
- draw_external_layer_overlays(
- &painter,
- rect,
- runtime,
- context,
- camera_frame.rendered,
- );
- }
- draw_workspace_overlay(&painter, rect, runtime, camera_frame.rendered);
- }
- });
+ }
+ }
+ self.show_workspace_dialogs(ui.ctx());
+ self.show_canvas(ui, study.as_ref(), opening, &actions, stable_dt);
self.queue_workspace_autosave();
if self
.autosave
diff --git a/apps/dicom-viewer/src/app/camera.rs b/apps/dicom-viewer/src/app/camera.rs
index 806d586..0c9a3a8 100644
--- a/apps/dicom-viewer/src/app/camera.rs
+++ b/apps/dicom-viewer/src/app/camera.rs
@@ -10,6 +10,60 @@ const CAMERA_SMOOTHING_RESPONSE: f32 = 22.0;
const CAMERA_SMOOTHING_SNAP_PX: f32 = 0.25;
const CAMERA_SMOOTHING_SNAP_ZOOM: f32 = 0.0005;
const WHEEL_ZOOM_SENSITIVITY: f32 = 0.0015;
+const MIN_WHEEL_ZOOM_SPEED: f32 = 0.25;
+const MAX_WHEEL_ZOOM_SPEED: f32 = 4.0;
+const MAX_WHEEL_ZOOM_EXPONENT: f32 = 1.5;
+
+#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize, serde::Serialize)]
+#[serde(default)]
+pub(super) struct WheelZoomSettings {
+ speed: f32,
+ inverted: bool,
+}
+
+impl WheelZoomSettings {
+ pub(super) fn for_os(os: &str) -> Self {
+ if os == "windows" {
+ Self::new(2.0, false)
+ } else {
+ Self::new(1.0, true)
+ }
+ }
+
+ pub(super) fn new(speed: f32, inverted: bool) -> Self {
+ Self { speed, inverted }.sanitized()
+ }
+
+ pub(super) fn speed(self) -> f32 {
+ self.speed
+ }
+
+ pub(super) fn speed_mut(&mut self) -> &mut f32 {
+ &mut self.speed
+ }
+
+ pub(super) fn inverted(self) -> bool {
+ self.inverted
+ }
+
+ pub(super) fn inverted_mut(&mut self) -> &mut bool {
+ &mut self.inverted
+ }
+
+ pub(super) fn sanitized(mut self) -> Self {
+ if !self.speed.is_finite() {
+ self.speed = 1.0;
+ }
+ self.speed = self.speed.clamp(MIN_WHEEL_ZOOM_SPEED, MAX_WHEEL_ZOOM_SPEED);
+ self
+ }
+}
+
+impl Default for WheelZoomSettings {
+ fn default() -> Self {
+ Self::for_os(std::env::consts::OS)
+ }
+}
fn clamp_camera_view_to_min(view: &mut CameraView, summary: &StudySummary, minimum_zoom: f32) {
view.zoom = view.zoom.clamp(minimum_zoom, MAX_ZOOM);
@@ -196,6 +250,28 @@ impl CameraState {
}
}
+ pub(super) fn retarget_frame(
+ &mut self,
+ rendered: CameraView,
+ summary: &StudySummary,
+ ) -> CameraFrame {
+ clamp_camera_view_to_min(&mut self.target, summary, self.minimum_zoom);
+ if !self.motion.enabled || camera_is_settled(rendered, self.target) {
+ self.motion.reset(self.target);
+ return CameraFrame {
+ rendered: self.target,
+ target: self.target,
+ animating: false,
+ };
+ }
+
+ CameraFrame {
+ rendered,
+ target: self.target,
+ animating: true,
+ }
+ }
+
pub(super) fn pan_by(&mut self, delta_screen: Vec2) {
if delta_screen == Vec2::ZERO {
return;
@@ -353,8 +429,32 @@ fn smooth_zoom(current: f32, target: f32, alpha: f32) -> f32 {
.clamp(MIN_ZOOM, MAX_ZOOM)
}
-pub(super) fn wheel_zoom_factor(scroll_y: f32) -> f32 {
- (-scroll_y * WHEEL_ZOOM_SENSITIVITY).exp()
+pub(super) fn wheel_zoom_factor(scroll_y: f32, settings: WheelZoomSettings) -> f32 {
+ let direction = if settings.inverted() { -1.0 } else { 1.0 };
+ (scroll_y * WHEEL_ZOOM_SENSITIVITY * settings.speed() * direction)
+ .clamp(-MAX_WHEEL_ZOOM_EXPONENT, MAX_WHEEL_ZOOM_EXPONENT)
+ .exp()
+}
+
+pub(super) fn raw_wheel_delta_y(input: &egui::InputState) -> f32 {
+ let line_scroll_speed = egui::InputOptions::default().line_scroll_speed;
+ input
+ .events
+ .iter()
+ .filter_map(|event| match event {
+ egui::Event::MouseWheel {
+ unit,
+ delta,
+ modifiers,
+ ..
+ } if !modifiers.command => Some(match unit {
+ egui::MouseWheelUnit::Point => delta.y,
+ egui::MouseWheelUnit::Line => delta.y * line_scroll_speed,
+ egui::MouseWheelUnit::Page => delta.y * input.viewport_rect().height(),
+ }),
+ _ => None,
+ })
+ .sum()
}
fn camera_is_settled(rendered: CameraView, target: CameraView) -> bool {
diff --git a/apps/dicom-viewer/src/app/canvas.rs b/apps/dicom-viewer/src/app/canvas.rs
index c42fe00..1a5ccda 100644
--- a/apps/dicom-viewer/src/app/canvas.rs
+++ b/apps/dicom-viewer/src/app/canvas.rs
@@ -300,6 +300,21 @@ impl SlideCanvas {
);
}
}
+ if let Some(level) =
+ sharpening_transition_level(summary, plan.render_level, plan.prefetch_level)
+ .and_then(|level| level_by_index(summary, level))
+ {
+ for tile in &plan.prefetch {
+ self.tiles.draw_ready_tile(
+ painter,
+ rect,
+ level,
+ tile,
+ camera.rendered.center_base,
+ camera.rendered.zoom,
+ );
+ }
+ }
painter.rect_stroke(
slide_rect,
@@ -384,6 +399,19 @@ fn interaction_measurement_target(
}
}
+fn sharpening_transition_level(
+ summary: &StudySummary,
+ rendered_level: LevelIndex,
+ target_level: LevelIndex,
+) -> Option {
+ if rendered_level == target_level {
+ return None;
+ }
+ let rendered = level_by_index(summary, rendered_level)?;
+ let target = level_by_index(summary, target_level)?;
+ (target.downsample < rendered.downsample).then_some(target_level)
+}
+
fn level_preparation_observation(event: &LevelWarmerEvent) -> (Duration, LevelPreparationStatus) {
match event {
LevelWarmerEvent::Prepared { elapsed, .. } => (*elapsed, LevelPreparationStatus::Prepared),
@@ -1091,6 +1119,20 @@ mod tests {
.all(|tile| tile.key.level == plan.prefetch_level));
}
+ #[test]
+ fn zoom_in_target_tiles_sharpen_progressively_but_zoom_out_targets_do_not() {
+ let summary = summary();
+ let fine = LevelIndex::from_u32(0);
+ let coarse = LevelIndex::from_u32(1);
+
+ assert_eq!(
+ sharpening_transition_level(&summary, coarse, fine),
+ Some(fine)
+ );
+ assert_eq!(sharpening_transition_level(&summary, fine, coarse), None);
+ assert_eq!(sharpening_transition_level(&summary, fine, fine), None);
+ }
+
#[test]
fn hysteresis_holds_current_level_while_preloading_adjacent_target() {
let summary = summary();
diff --git a/apps/dicom-viewer/src/app/canvas_frame.rs b/apps/dicom-viewer/src/app/canvas_frame.rs
new file mode 100644
index 0000000..4a512f1
--- /dev/null
+++ b/apps/dicom-viewer/src/app/canvas_frame.rs
@@ -0,0 +1,235 @@
+use dicom_viewer_core::ViewerStudy;
+use eframe::egui::{self, Frame, Rect, Sense};
+use std::sync::Arc;
+
+use super::camera::{raw_wheel_delta_y, wheel_zoom_factor, CameraFrame, CameraView};
+use super::ui::chrome::ToolbarActions;
+use super::ui::overlay::{
+ draw_canvas_overlays, paint_canvas_background, paint_empty_state, OverlayInfo,
+};
+use super::viewport::screen_to_base;
+use super::viewport_export::should_draw_canvas_hud;
+use super::workspace::{draw_external_layer_overlays, draw_workspace_overlay};
+use super::workspace_interaction::WorkspaceCanvasInteraction;
+use super::{theme, DicomViewerApp};
+
+impl DicomViewerApp {
+ pub(super) fn show_canvas(
+ &mut self,
+ ui: &mut egui::Ui,
+ study: Option<&Arc>,
+ opening: bool,
+ actions: &ToolbarActions,
+ stable_dt: f32,
+ ) {
+ egui::CentralPanel::default_margins()
+ .frame(Frame::NONE.fill(theme::CANVAS))
+ .show_inside(ui, |ui| {
+ let rect = ui.available_rect_before_wrap();
+ self.last_canvas_rect = Some(rect);
+ if let Some(pending) = &mut self.pending_viewport_export {
+ pending.update_canvas(rect, ui.ctx().viewport_rect());
+ }
+ let response = ui.allocate_rect(rect, Sense::click_and_drag());
+ if response.clicked() || response.drag_started() {
+ response.request_focus();
+ } else if ui.input(|input| input.pointer.any_pressed()) && !response.hovered() {
+ response.surrender_focus();
+ }
+ let painter = ui.painter_at(rect);
+ paint_canvas_background(&painter, rect);
+
+ let Some(study) = study else {
+ paint_empty_state(&painter, rect, opening);
+ return;
+ };
+
+ let camera_frame =
+ self.prepare_canvas_camera(ui, &response, rect, study, actions, stable_dt);
+ self.canvas.paint(
+ ui.ctx(),
+ &painter,
+ rect,
+ study,
+ self.active_generation,
+ camera_frame,
+ );
+ if let Some(runtime) = &mut self.workspace {
+ if let Err(error) = runtime.refresh_spatial_index() {
+ self.status =
+ format!("Could not update annotation viewport index: {error}");
+ }
+ }
+ if let Some((count, reason)) = self.canvas.cpu_fallback() {
+ if count > self.reported_cpu_fallbacks {
+ self.reported_cpu_fallbacks = count;
+ self.status = format!(
+ "{} preferred → wgpu; CPU fallback used for {count} tile(s): {reason}",
+ study.summary().tile_decode_backend
+ );
+ }
+ }
+
+ self.paint_annotation_overlays(
+ &painter,
+ rect,
+ study,
+ &response,
+ camera_frame.rendered,
+ );
+ });
+ }
+
+ fn prepare_canvas_camera(
+ &mut self,
+ ui: &egui::Ui,
+ response: &egui::Response,
+ rect: Rect,
+ study: &ViewerStudy,
+ actions: &ToolbarActions,
+ stable_dt: f32,
+ ) -> CameraFrame {
+ if actions.fit {
+ self.canvas.record_zoom_input();
+ self.camera.request_fit();
+ }
+ self.camera.prepare_canvas(rect, study.summary());
+ if actions.zoom_out {
+ self.canvas.record_zoom_input();
+ self.camera.zoom_about_center(rect, 0.8);
+ }
+ if actions.zoom_in {
+ self.canvas.record_zoom_input();
+ self.camera.zoom_about_center(rect, 1.25);
+ }
+
+ let accepts_keys =
+ (response.hovered() || response.has_focus()) && !ui.ctx().text_edit_focused();
+ let zoom_before_keys = self.camera.target_view().zoom;
+ if self.camera.handle_keys(ui, rect, accepts_keys) {
+ if (self.camera.target_view().zoom - zoom_before_keys).abs() > f32::EPSILON {
+ self.canvas.record_zoom_input();
+ }
+ ui.ctx().request_repaint();
+ }
+
+ let camera_frame = self.camera.frame(rect, study.summary(), stable_dt);
+ if camera_frame.animating {
+ ui.ctx().request_repaint();
+ }
+ let workspace_interaction = self.handle_workspace_interaction(
+ ui,
+ response,
+ rect,
+ study.summary(),
+ camera_frame.rendered,
+ accepts_keys,
+ );
+
+ self.apply_canvas_gestures(
+ ui,
+ response,
+ rect,
+ camera_frame.rendered,
+ workspace_interaction,
+ );
+
+ // Pointer input can change the target after this frame's rendered view was
+ // advanced. Publish that new target immediately so tile lookahead starts during
+ // the gesture instead of waiting for the next animation frame.
+ let camera_frame = self
+ .camera
+ .retarget_frame(camera_frame.rendered, study.summary());
+ if camera_frame.animating {
+ ui.ctx().request_repaint();
+ }
+
+ camera_frame
+ }
+
+ fn apply_canvas_gestures(
+ &mut self,
+ ui: &egui::Ui,
+ response: &egui::Response,
+ rect: Rect,
+ rendered: CameraView,
+ workspace_interaction: WorkspaceCanvasInteraction,
+ ) {
+ if workspace_interaction.pan_requested {
+ self.camera.pan_by_rendered(response.drag_delta(), rendered);
+ ui.ctx().request_repaint();
+ }
+ if response.double_clicked() && !workspace_interaction.click_consumed {
+ let pointer = response.interact_pointer_pos().unwrap_or(rect.center());
+ self.canvas.record_zoom_input();
+ self.camera
+ .zoom_around_rendered(rect, pointer, 2.0, rendered);
+ ui.ctx().request_repaint();
+ }
+
+ if response.hovered() {
+ let scroll_y = ui.input(raw_wheel_delta_y);
+ if scroll_y.abs() > 0.0 {
+ let pointer = ui
+ .input(|input| input.pointer.hover_pos())
+ .unwrap_or(rect.center());
+ self.canvas.record_zoom_input();
+ self.camera.zoom_around_rendered(
+ rect,
+ pointer,
+ wheel_zoom_factor(scroll_y, self.wheel_zoom),
+ rendered,
+ );
+ ui.ctx().request_repaint();
+ }
+ let pinch = ui.input(|input| input.zoom_delta());
+ if (pinch - 1.0).abs() > 0.001 {
+ let pointer = ui
+ .input(|input| input.pointer.hover_pos())
+ .unwrap_or(rect.center());
+ self.canvas.record_zoom_input();
+ self.camera
+ .zoom_around_rendered(rect, pointer, pinch, rendered);
+ ui.ctx().request_repaint();
+ }
+ if ui.input(|input| input.pointer.any_down()) {
+ ui.ctx().request_repaint();
+ }
+ }
+ }
+
+ fn paint_annotation_overlays(
+ &self,
+ painter: &egui::Painter,
+ rect: Rect,
+ study: &ViewerStudy,
+ response: &egui::Response,
+ rendered: CameraView,
+ ) {
+ let hover_base = response
+ .hover_pos()
+ .map(|p| screen_to_base(rect, p, rendered.center_base, rendered.zoom));
+ let tile_failure = self.canvas.tile_failure();
+ let debug_stats = self.canvas.debug_stats_text();
+ if should_draw_canvas_hud(self.pending_viewport_export.is_some()) {
+ draw_canvas_overlays(
+ painter,
+ rect,
+ OverlayInfo {
+ summary: study.summary(),
+ zoom: rendered.zoom,
+ frame_rate: self.frame_stats.info(),
+ hover_base,
+ tile_failure,
+ debug_stats: debug_stats.as_deref(),
+ },
+ );
+ }
+ if let Some(runtime) = &self.workspace {
+ if let Some(context) = study.annotation_context() {
+ draw_external_layer_overlays(painter, rect, runtime, context, rendered);
+ }
+ draw_workspace_overlay(painter, rect, runtime, rendered);
+ }
+ }
+}
diff --git a/apps/dicom-viewer/src/app/export_job.rs b/apps/dicom-viewer/src/app/export_job.rs
index e4978cd..c697168 100644
--- a/apps/dicom-viewer/src/app/export_job.rs
+++ b/apps/dicom-viewer/src/app/export_job.rs
@@ -9,6 +9,7 @@ use super::background_worker::{BackgroundWorker, WorkerPoll};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum WorkspaceExportKind {
+ CurrentViewTiff,
PortableWorkspace,
SchemeGeoJson,
CompatibilityGeoJson,
@@ -22,6 +23,7 @@ pub(super) enum WorkspaceExportKind {
impl WorkspaceExportKind {
pub(super) const fn label(self) -> &'static str {
match self {
+ Self::CurrentViewTiff => "current view TIFF",
Self::PortableWorkspace => "portable workspace",
Self::SchemeGeoJson => "scheme-aware GeoJSON",
Self::CompatibilityGeoJson => "CellViT compatibility GeoJSON",
diff --git a/apps/dicom-viewer/src/app/tests.rs b/apps/dicom-viewer/src/app/tests.rs
index 3f797b1..8c87d5f 100644
--- a/apps/dicom-viewer/src/app/tests.rs
+++ b/apps/dicom-viewer/src/app/tests.rs
@@ -61,6 +61,13 @@ pub(super) fn wait_for_background(label: &str, mut poll: impl FnMut() -> Opti
}
pub(super) fn write_source_wsi(path: &std::path::Path) {
+ write_source_wsi_with_optical_paths(path, &[]);
+}
+
+pub(super) fn write_source_wsi_with_optical_paths(
+ path: &std::path::Path,
+ optical_path_identifiers: &[&str],
+) {
use dicom_core::value::{DataSetSequence, PrimitiveValue, Value};
use dicom_core::{DataElement, Length, VR};
use dicom_dictionary_std::{tags, uids};
@@ -89,7 +96,7 @@ pub(super) fn write_source_wsi(path: &std::path::Path) {
DataElement::new(tags::STUDY_INSTANCE_UID, VR::UI, "2.25.9902"),
DataElement::new(tags::SERIES_INSTANCE_UID, VR::UI, "2.25.9903"),
DataElement::new(tags::FRAME_OF_REFERENCE_UID, VR::UI, "2.25.9904"),
- DataElement::new(tags::PATIENT_NAME, VR::PN, "Research^Slide"),
+ DataElement::new(tags::PATIENT_NAME, VR::PN, "Example^Slide"),
DataElement::new(tags::PATIENT_ID, VR::LO, "R-1"),
DataElement::new(tags::STUDY_DATE, VR::DA, "20260814"),
DataElement::new(tags::STUDY_TIME, VR::TM, "120000"),
@@ -118,6 +125,30 @@ pub(super) fn write_source_wsi(path: &std::path::Path) {
VR::SQ,
Value::from(DataSetSequence::new(vec![origin], Length::UNDEFINED)),
));
+ if !optical_path_identifiers.is_empty() {
+ let optical_paths = optical_path_identifiers
+ .iter()
+ .map(|identifier| {
+ let mut item = InMemDicomObject::new_empty();
+ item.put(DataElement::new(
+ tags::OPTICAL_PATH_IDENTIFIER,
+ VR::SH,
+ *identifier,
+ ));
+ item
+ })
+ .collect::>();
+ object.put(DataElement::new(
+ tags::NUMBER_OF_OPTICAL_PATHS,
+ VR::UL,
+ PrimitiveValue::from(u32::try_from(optical_paths.len()).unwrap()),
+ ));
+ object.put(DataElement::new(
+ tags::OPTICAL_PATH_SEQUENCE,
+ VR::SQ,
+ Value::from(DataSetSequence::new(optical_paths, Length::UNDEFINED)),
+ ));
+ }
object.put(DataElement::new(
tags::PIXEL_DATA,
VR::OB,
@@ -157,6 +188,7 @@ fn headless_app_runs_empty_logic_and_ui_with_real_renderer_state() {
assert!(!output.shapes.is_empty());
assert!(app.study.is_none());
assert_eq!(app.active_generation, 0);
+ assert!(!app.show_pathology_workspace);
app.show_facts_panel = true;
let output = context.run_ui(egui::RawInput::default(), |ui| {
@@ -681,10 +713,65 @@ fn pointer_zoom_preserves_the_base_point_in_the_rendered_frame() {
}
#[test]
-fn wheel_zoom_direction_is_inverted_for_natural_scroll() {
- assert!(wheel_zoom_factor(120.0) < 1.0);
- assert!(wheel_zoom_factor(-120.0) > 1.0);
- assert_eq!(wheel_zoom_factor(0.0), 1.0);
+fn same_frame_wheel_zoom_retargets_tile_planning_before_animation_advances() {
+ let summary = summary();
+ let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(512.0, 512.0));
+ let pointer = pos2(400.0, 180.0);
+ let mut camera = CameraState::default();
+ camera.reset_for_study(&summary);
+ camera.prepare_canvas(rect, &summary);
+ let frame_before_input = camera.frame(rect, &summary, 1.0 / 60.0);
+
+ camera.zoom_around_rendered(rect, pointer, 2.0, frame_before_input.rendered);
+ let frame_for_tiles = camera.retarget_frame(frame_before_input.rendered, &summary);
+
+ assert_eq!(
+ frame_for_tiles.rendered.zoom,
+ frame_before_input.rendered.zoom
+ );
+ assert_eq!(frame_for_tiles.target.zoom, camera.target_view().zoom);
+ assert!(frame_for_tiles.target.zoom > frame_for_tiles.rendered.zoom);
+ assert!(frame_for_tiles.animating);
+}
+
+#[test]
+fn windows_wheel_zoom_defaults_to_native_direction_and_faster_steps() {
+ let settings = WheelZoomSettings::for_os("windows");
+
+ assert!(!settings.inverted());
+ assert_eq!(settings.speed(), 2.0);
+ assert!(wheel_zoom_factor(120.0, settings) > 1.0);
+ assert!(wheel_zoom_factor(-120.0, settings) < 1.0);
+ assert_eq!(wheel_zoom_factor(0.0, settings), 1.0);
+}
+
+#[test]
+fn wheel_zoom_direction_and_speed_are_explicitly_configurable() {
+ let normal = WheelZoomSettings::new(1.0, false);
+ let faster = WheelZoomSettings::new(2.0, false);
+ let inverted = WheelZoomSettings::new(1.0, true);
+
+ assert!(wheel_zoom_factor(60.0, faster) > wheel_zoom_factor(60.0, normal));
+ assert!(wheel_zoom_factor(60.0, inverted) < 1.0);
+}
+
+#[test]
+fn wheel_zoom_consumes_the_immediate_native_wheel_step() {
+ let context = egui::Context::default();
+ let input = egui::RawInput {
+ events: vec![egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Line,
+ delta: vec2(0.0, 1.0),
+ phase: egui::TouchPhase::Move,
+ modifiers: egui::Modifiers::NONE,
+ }],
+ ..Default::default()
+ };
+ let mut observed = 0.0;
+
+ let _ = context.run_ui(input, |ui| observed = ui.input(raw_wheel_delta_y));
+
+ assert_eq!(observed, egui::InputOptions::default().line_scroll_speed);
}
#[test]
diff --git a/apps/dicom-viewer/src/app/theme.rs b/apps/dicom-viewer/src/app/theme.rs
index b084e86..f13ff17 100644
--- a/apps/dicom-viewer/src/app/theme.rs
+++ b/apps/dicom-viewer/src/app/theme.rs
@@ -21,6 +21,7 @@ pub(super) const CYAN: Color32 = Color32::from_rgb(112, 192, 206);
pub(super) const GREEN: Color32 = Color32::from_rgb(112, 192, 116);
pub(super) const WARN: Color32 = Color32::from_rgb(206, 142, 60);
pub(super) fn install_visuals(ctx: &egui::Context) {
+ install_platform_font(ctx);
let mut style = (*ctx.global_style()).clone();
style.text_styles = [
@@ -84,12 +85,39 @@ pub(super) fn install_visuals(ctx: &egui::Context) {
ctx.set_global_style(style);
}
+#[cfg(target_os = "windows")]
+fn install_platform_font(ctx: &egui::Context) {
+ let windows_directory = std::env::var_os("WINDIR").unwrap_or_else(|| "C:\\Windows".into());
+ let path = std::path::PathBuf::from(windows_directory)
+ .join("Fonts")
+ .join("segoeui.ttf");
+ let Ok(bytes) = std::fs::read(path) else {
+ return;
+ };
+
+ let name = "Segoe UI".to_owned();
+ let mut fonts = egui::FontDefinitions::default();
+ fonts.font_data.insert(
+ name.clone(),
+ std::sync::Arc::new(egui::FontData::from_owned(bytes)),
+ );
+ fonts
+ .families
+ .entry(egui::FontFamily::Proportional)
+ .or_default()
+ .insert(0, name);
+ ctx.set_fonts(fonts);
+}
+
+#[cfg(not(target_os = "windows"))]
+fn install_platform_font(_ctx: &egui::Context) {}
+
#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn installed_visuals_keep_the_research_viewer_palette_and_spacing() {
+ fn installed_visuals_keep_the_viewer_palette_and_spacing() {
let context = egui::Context::default();
install_visuals(&context);
@@ -101,4 +129,15 @@ mod tests {
assert_eq!(style.spacing.item_spacing, vec2(8.0, 7.0));
assert_eq!(style.spacing.interact_size.y, 26.0);
}
+
+ #[test]
+ fn text_rasterizer_is_native_only_on_windows() {
+ let expected = if cfg!(target_os = "windows") {
+ "DirectWrite grayscale"
+ } else {
+ "skrifa/vello"
+ };
+
+ assert_eq!(egui::epaint::text::font_rasterizer_name(), expected);
+ }
}
diff --git a/apps/dicom-viewer/src/app/ui/chrome.rs b/apps/dicom-viewer/src/app/ui/chrome.rs
index 554587d..a6efef5 100644
--- a/apps/dicom-viewer/src/app/ui/chrome.rs
+++ b/apps/dicom-viewer/src/app/ui/chrome.rs
@@ -6,6 +6,7 @@ use eframe::egui::{
use dicom_viewer_core::StudySummary;
use super::super::theme;
+use super::super::WheelZoomSettings;
#[derive(Debug, Default)]
pub(in crate::app) struct ToolbarActions {
@@ -24,12 +25,14 @@ pub(in crate::app) struct ToolbarActions {
pub(in crate::app) struct ToolbarState<'a> {
pub(in crate::app) has_study: bool,
pub(in crate::app) show_facts: &'a mut bool,
+ pub(in crate::app) show_pathology: &'a mut bool,
pub(in crate::app) can_undo: bool,
pub(in crate::app) can_redo: bool,
pub(in crate::app) autosave_status: &'a str,
pub(in crate::app) export_running: bool,
pub(in crate::app) export_cancel_requested: bool,
pub(in crate::app) smooth_camera: &'a mut bool,
+ pub(in crate::app) wheel_zoom: &'a mut WheelZoomSettings,
}
pub(in crate::app) fn show_toolbar(ui: &mut egui::Ui, state: ToolbarState<'_>) -> ToolbarActions {
@@ -55,6 +58,10 @@ pub(in crate::app) fn show_toolbar(ui: &mut egui::Ui, state: ToolbarState<'_>) -
ui.toggle_value(state.show_facts, RichText::new("Info").size(13.0));
if state.has_study {
rule(ui);
+ ui.toggle_value(
+ state.show_pathology,
+ RichText::new("Annotations").size(13.0),
+ );
actions.import = ui.button(RichText::new("Import").size(13.0)).clicked();
if state.export_running {
actions.cancel_export = ui
@@ -83,6 +90,16 @@ pub(in crate::app) fn show_toolbar(ui: &mut egui::Ui, state: ToolbarState<'_>) -
actions.zoom_in = ui.button(RichText::new("+").size(13.0)).clicked();
ui.menu_button("View", |ui| {
ui.checkbox(state.smooth_camera, "Smooth navigation");
+ ui.checkbox(
+ state.wheel_zoom.inverted_mut(),
+ "Invert wheel zoom direction",
+ );
+ ui.add(
+ egui::Slider::new(state.wheel_zoom.speed_mut(), 0.25..=4.0)
+ .logarithmic(true)
+ .custom_formatter(|value, _| format!("{value:.2}×"))
+ .text("Wheel zoom speed"),
+ );
});
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
@@ -184,11 +201,7 @@ pub(in crate::app) fn privacy_badge(ui: &mut egui::Ui) {
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(RichText::new("\u{25CF}").color(theme::GREEN).size(9.0));
- ui.label(
- RichText::new("RESEARCH · LOCAL")
- .color(theme::TEXT_MUTED)
- .size(11.0),
- );
+ ui.label(RichText::new("LOCAL").color(theme::TEXT_MUTED).size(11.0));
});
}
@@ -211,19 +224,23 @@ mod tests {
#[test]
fn toolbar_and_status_bar_render_each_availability_state_without_actions() {
let mut show_facts = false;
+ let mut show_pathology = false;
let mut smooth_camera = true;
+ let mut wheel_zoom = WheelZoomSettings::for_os("windows");
let output = run_ui(|ui| {
let actions = show_toolbar(
ui,
ToolbarState {
has_study: false,
show_facts: &mut show_facts,
+ show_pathology: &mut show_pathology,
can_undo: false,
can_redo: false,
autosave_status: "Not saved",
export_running: false,
export_cancel_requested: false,
smooth_camera: &mut smooth_camera,
+ wheel_zoom: &mut wheel_zoom,
},
);
assert!(!actions.open_file);
@@ -239,12 +256,14 @@ mod tests {
ToolbarState {
has_study: true,
show_facts: &mut show_facts,
+ show_pathology: &mut show_pathology,
can_undo: true,
can_redo: true,
autosave_status: "Saved",
export_running: false,
export_cancel_requested: false,
smooth_camera: &mut smooth_camera,
+ wheel_zoom: &mut wheel_zoom,
},
);
assert!(!actions.undo);
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace.rs
index ca8b90b..4d073c9 100644
--- a/apps/dicom-viewer/src/app/ui/pathology_workspace.rs
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace.rs
@@ -1,6 +1,12 @@
+mod findings;
+mod inspector;
+mod layers;
+mod palette;
+mod tool_rail;
+
use std::path::PathBuf;
-use dicom_viewer_core::{AnnotationClassGeometry, SegmentOperation, VectorFindingGeometry};
+use dicom_viewer_core::{AnnotationClassGeometry, SegmentOperation, WorkspaceObjectGeometryKind};
use eframe::egui::{self, Color32, Margin, Panel, RichText, ScrollArea, Stroke};
use uuid::Uuid;
@@ -11,11 +17,21 @@ use super::super::workspace::{
use super::chrome::chrome_frame;
use super::section_heading;
+use findings::{finding_rows, show_findings};
+use inspector::show_inspector;
+use layers::show_layers;
+use palette::show_scheme_and_palette;
+#[cfg(test)]
+use palette::visible_palette;
+pub(in crate::app) use tool_rail::show_tool_rail;
+
const TOOL_RAIL_WIDTH: f32 = 58.0;
const FINDING_ROW_HEIGHT: f32 = 25.0;
#[derive(Debug, Default)]
pub(in crate::app) struct PathologyWorkspaceActions {
+ pub(in crate::app) close_panel: bool,
+ pub(in crate::app) export_current_view_tiff: bool,
pub(in crate::app) import_dicom: bool,
pub(in crate::app) import_profiled_geojson: bool,
pub(in crate::app) import_sr: bool,
@@ -38,57 +54,11 @@ pub(in crate::app) struct PathologyWorkspaceActions {
pub(in crate::app) status: Option,
}
-pub(in crate::app) fn show_tool_rail(
+pub(in crate::app) fn show_populated_pathology_workspace_panel(
ui: &mut egui::Ui,
runtime: &mut WorkspaceRuntime,
-) -> Option {
- let mut error = None;
- Panel::left("pathology-tool-rail")
- .exact_size(TOOL_RAIL_WIDTH)
- .frame(chrome_frame(theme::CHROME, Margin::symmetric(6, 8)))
- .show_inside(ui, |ui| {
- ui.vertical_centered(|ui| {
- ui.label(
- RichText::new("TOOLS")
- .size(9.0)
- .color(theme::TEXT_DIM)
- .strong(),
- );
- ui.add_space(3.0);
- for tool in ActiveTool::ALL {
- let selected = runtime.active_tool() == tool;
- let response = ui
- .selectable_label(
- selected,
- RichText::new(tool_glyph(tool)).size(17.0).strong(),
- )
- .on_hover_text(format!("{} ({})", tool.label(), tool.shortcut()));
- if response.clicked() {
- if tool == ActiveTool::Brush {
- if let Err(err) = runtime.ensure_segmentation_layer() {
- error = Some(err.to_string());
- continue;
- }
- }
- if runtime.request_tool(tool) == ToolTransitionOutcome::BlockedByDraft {
- error = Some(
- "Unfinished polygon: choose Resume, Finish, or Discard.".into(),
- );
- }
- }
- }
- ui.add_space(5.0);
- ui.separator();
- if ui
- .button(RichText::new("N").monospace().strong())
- .on_hover_text("New independent finding / segment (N)")
- .clicked()
- {
- runtime.begin_new_segment();
- }
- });
- });
- error
+) -> Option {
+ (runtime.document().object_count() > 0).then(|| show_pathology_workspace_panel(ui, runtime))
}
pub(in crate::app) fn show_pathology_workspace_panel(
@@ -105,6 +75,10 @@ pub(in crate::app) fn show_pathology_workspace_panel(
ui.horizontal(|ui| {
ui.heading(RichText::new("Pathology").color(theme::TEXT));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ actions.close_panel = ui
+ .small_button("X")
+ .on_hover_text("Close annotations")
+ .clicked();
let count = runtime.document().object_count();
ui.label(
RichText::new(format!("{count} tracked"))
@@ -181,714 +155,6 @@ fn show_draft_guard(
});
}
-fn show_scheme_and_palette(
- ui: &mut egui::Ui,
- runtime: &mut WorkspaceRuntime,
- actions: &mut PathologyWorkspaceActions,
-) {
- section_heading(ui, "Annotation scheme");
- let scheme = runtime.document().scheme();
- ui.horizontal(|ui| {
- ui.label(RichText::new(scheme.display_name()).strong());
- ui.label(
- RichText::new(format!("v{}", scheme.version()))
- .small()
- .color(theme::TEXT_DIM),
- );
- });
- ui.label(
- RichText::new(format!(
- "{} · {}…",
- scheme.id(),
- &scheme.content_digest()[..12]
- ))
- .monospace()
- .small()
- .color(theme::TEXT_DIM),
- );
- let palette = visible_palette(runtime);
- ui.add_space(4.0);
- ui.horizontal_wrapped(|ui| {
- for (id, label, color, selected) in palette {
- let color = Color32::from_rgb(color[0], color[1], color[2]);
- let response = ui
- .horizontal(|ui| {
- let (swatch, _) =
- ui.allocate_exact_size(egui::vec2(8.0, 16.0), egui::Sense::hover());
- ui.painter().rect_filled(swatch, 1.0, color);
- ui.selectable_label(selected, RichText::new(label).size(12.0))
- })
- .inner;
- if response.clicked() {
- if let Err(error) = runtime.set_active_class(id) {
- actions.error = Some(error.to_string());
- }
- }
- }
- });
-
- if runtime.editing_representation() == EditingRepresentation::Segmentation
- && matches!(
- runtime.active_tool(),
- ActiveTool::Polygon | ActiveTool::Brush
- )
- {
- ui.horizontal(|ui| {
- ui.label(RichText::new("Operation").small().color(theme::TEXT_MUTED));
- for (operation, label) in [
- (SegmentOperation::Add, "Add"),
- (SegmentOperation::Erase, "Erase"),
- ] {
- let enabled = operation == SegmentOperation::Add
- || runtime
- .selection()
- .iter()
- .any(|id| runtime.document().segment(*id).is_some());
- if ui
- .add_enabled(
- enabled,
- egui::Button::selectable(runtime.segment_operation() == operation, label),
- )
- .clicked()
- {
- runtime.set_segment_operation(operation);
- }
- }
- ui.label(RichText::new("Alt reverses").small().color(theme::TEXT_DIM));
- });
- if runtime.active_tool() == ActiveTool::Brush {
- ui.label(
- RichText::new(format!("Brush Ø {:.0} px", runtime.brush_diameter()))
- .small()
- .color(theme::TEXT_MUTED),
- );
- }
- }
-}
-
-fn show_findings(
- ui: &mut egui::Ui,
- runtime: &mut WorkspaceRuntime,
- actions: &mut PathologyWorkspaceActions,
-) {
- section_heading(ui, "Findings");
- let rows = finding_rows(runtime);
- if rows.is_empty() {
- ui.label(
- RichText::new("No tracked findings yet.")
- .small()
- .color(theme::TEXT_DIM),
- );
- return;
- }
- ScrollArea::vertical()
- .id_salt("tracked-findings")
- .max_height(210.0)
- .auto_shrink([false, true])
- .show_rows(ui, FINDING_ROW_HEIGHT, rows.len(), |ui, range| {
- for row in &rows[range] {
- let selected = runtime.selection().contains(&row.id);
- ui.horizontal(|ui| {
- let visible = runtime.document().presentation().object_visible(row.id);
- let eye = if visible { "●" } else { "○" };
- if ui
- .small_button(eye)
- .on_hover_text("Toggle visibility")
- .clicked()
- {
- if let Err(error) =
- runtime.set_object_visibility_without_history(row.id, !visible)
- {
- actions.error = Some(error.to_string());
- }
- }
- let response = ui.selectable_label(
- selected,
- RichText::new(format!("#{:04} {}", row.ordinal, row.label)).size(12.0),
- );
- if response.clicked() {
- let shift = ui.input(|input| input.modifiers.shift);
- if shift {
- runtime.toggle_selection(row.id);
- } else {
- runtime.select_only(row.id);
- }
- }
- if response.double_clicked() {
- actions.jump_to = Some(row.id);
- }
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if ui
- .small_button("⌖")
- .on_hover_text("Jump to finding")
- .clicked()
- {
- actions.jump_to = Some(row.id);
- }
- ui.label(
- RichText::new(&row.metric)
- .monospace()
- .small()
- .color(theme::TEXT_DIM),
- );
- });
- });
- }
- });
-}
-
-fn show_layers(
- ui: &mut egui::Ui,
- runtime: &mut WorkspaceRuntime,
- actions: &mut PathologyWorkspaceActions,
-) {
- section_heading(ui, "Layers");
- let vectors = runtime
- .document()
- .vector_layers()
- .iter()
- .map(|layer| (layer.id(), layer.name().to_owned(), layer.findings().len()))
- .collect::>();
- let segments = runtime
- .document()
- .segmentation_layers()
- .iter()
- .map(|layer| (layer.id(), layer.name().to_owned(), layer.segments().len()))
- .collect::>();
- for (id, name, count) in vectors {
- layer_row(
- ui,
- runtime,
- actions,
- id,
- &name,
- count,
- EditingRepresentation::Vector,
- );
- }
- for (id, name, count) in segments {
- layer_row(
- ui,
- runtime,
- actions,
- id,
- &name,
- count,
- EditingRepresentation::Segmentation,
- );
- }
- let external = runtime
- .document()
- .external_layers()
- .iter()
- .map(|layer| {
- (
- layer.id(),
- layer.name().to_owned(),
- layer.kind().clone(),
- layer.source_path().map(std::path::Path::to_path_buf),
- layer.source_object_count(),
- layer.class_mappings().clone(),
- )
- })
- .collect::>();
- for (id, name, kind, source_path, count, mappings) in external {
- let presentation = runtime.document().presentation().layer(id);
- let mut remove_layer = false;
- let mut load_layer = false;
- let payload_loaded = runtime.external_payload(id).is_some();
- ui.horizontal(|ui| {
- let eye = if presentation.visible { "●" } else { "○" };
- if ui.small_button(eye).clicked() {
- if let Err(error) = runtime.set_layer_visibility(id, !presentation.visible) {
- actions.error = Some(error.to_string());
- }
- }
- ui.label(RichText::new("◇").color(theme::TEXT_DIM));
- ui.label(format!("{name} · {count}"));
- ui.label(RichText::new("LOCKED").small().color(theme::TEXT_DIM));
- if !payload_loaded
- && source_path.is_some()
- && sidecar_kind_for_external(&kind).is_some()
- && ui.small_button("Load").clicked()
- {
- load_layer = true;
- }
- if ui.small_button("Remove").clicked() {
- remove_layer = true;
- }
- });
- ui.horizontal(|ui| {
- edit_layer_opacity(ui, runtime, actions, id, presentation);
- let status = source_path.as_deref().map_or("embedded reference", |path| {
- if path.exists() {
- "linked source"
- } else {
- "missing linked source"
- }
- });
- ui.label(RichText::new(format!("{kind:?} · {status}")).small().color(
- if status.starts_with("missing") {
- theme::AMBER
- } else {
- theme::TEXT_DIM
- },
- ));
- });
- if remove_layer {
- match runtime.remove_external_layer(id) {
- Ok(true) => {
- actions.status =
- Some(format!("Removed source layer {name}; Undo restores it."));
- }
- Ok(false) => {}
- Err(error) => actions.error = Some(error.to_string()),
- }
- continue;
- }
- if load_layer {
- actions.load_sidecar = source_path.clone().zip(sidecar_kind_for_external(&kind));
- }
- let classes = runtime.external_classes(id);
- ui.indent(("external-controls", id), |ui| match classes {
- Err(error) => {
- ui.label(RichText::new(error.to_string()).small().color(theme::AMBER));
- }
- Ok(classes) if classes.is_empty() => {
- let message = if kind == dicom_viewer_core::ExternalLayerKind::Heatmap
- && payload_loaded
- {
- "Heatmap source result; export it through DICOM PM."
- } else {
- "Source payload is unloaded or has no lossless editable objects."
- };
- ui.label(
- RichText::new(message)
- .small()
- .color(theme::TEXT_DIM),
- );
- }
- Ok(classes) => {
- ui.collapsing("Class mapping & promotion", |ui| {
- let scheme_options = runtime
- .document()
- .scheme()
- .classes()
- .iter()
- .map(|class| {
- (
- class.id().to_owned(),
- class.label().to_owned(),
- class.geometry(),
- )
- })
- .collect::>();
- for class in &classes {
- ui.horizontal_wrapped(|ui| {
- ui.label(format!(
- "{} · {} {}",
- class.label,
- class.object_count,
- class.geometry.label().to_lowercase()
- ));
- if !class.editable {
- ui.label(RichText::new("read-only geometry").small().color(theme::AMBER));
- }
- });
- let current = mappings.get(&class.key).cloned();
- let selected_text = current
- .as_deref()
- .and_then(|id| {
- scheme_options
- .iter()
- .find(|(candidate, _, _)| candidate == id)
- .map(|(_, label, _)| label.as_str())
- })
- .unwrap_or("Unmapped");
- ui.horizontal(|ui| {
- egui::ComboBox::from_id_salt(("external-class-map", id, &class.key))
- .selected_text(selected_text)
- .show_ui(ui, |ui| {
- for (target_id, label, geometry) in &scheme_options {
- if *geometry != class.geometry {
- continue;
- }
- if ui
- .selectable_label(
- current.as_deref() == Some(target_id.as_str()),
- label,
- )
- .clicked()
- {
- if let Err(error) = runtime.set_external_class_mapping(
- id,
- &class.key,
- target_id,
- ) {
- actions.error = Some(error.to_string());
- }
- }
- }
- });
- if current.is_none() {
- if let Some(suggested) = class.exact_scheme_class_id.as_deref() {
- let label = scheme_options
- .iter()
- .find(|(id, _, _)| id == suggested)
- .map_or(suggested, |(_, label, _)| label.as_str());
- if ui
- .small_button(format!("Use exact: {label}"))
- .clicked()
- {
- if let Err(error) = runtime.set_external_class_mapping(
- id,
- &class.key,
- suggested,
- ) {
- actions.error = Some(error.to_string());
- }
- }
- }
- }
- });
- }
-
- let complete = classes.iter().all(|class| {
- class.editable && mappings.contains_key(&class.key)
- });
- if ui
- .add_enabled(complete, egui::Button::new("Make editable"))
- .on_hover_text(
- "Convert every source object only after every source class is mapped",
- )
- .clicked()
- {
- match runtime.make_external_layer_editable(id) {
- Ok(ids) => {
- actions.status = Some(format!(
- "Converted {} source object(s) as independently tracked findings.",
- ids.len()
- ));
- }
- Err(error) => actions.error = Some(error.to_string()),
- }
- }
-
- ui.collapsing("Source objects", |ui| match runtime.external_objects(id) {
- Err(error) => {
- ui.label(RichText::new(error.to_string()).small().color(theme::AMBER));
- }
- Ok(objects) => {
- ScrollArea::vertical()
- .id_salt(("external-objects", id))
- .max_height(180.0)
- .show_rows(ui, 24.0, objects.len(), |ui, range| {
- for object in &objects[range] {
- ui.horizontal(|ui| {
- ui.label(
- RichText::new(&object.label)
- .small()
- .color(theme::TEXT_MUTED),
- );
- let mapped = mappings.contains_key(&object.class_key);
- let enabled = object.promotable
- && mapped
- && !object.promoted;
- if ui
- .add_enabled(
- enabled,
- egui::Button::new(if object.promoted {
- "Tracked"
- } else {
- "Promote"
- }),
- )
- .clicked()
- {
- match runtime.promote_external_object(
- id,
- &object.source_object_id,
- ) {
- Ok(_) => {
- actions.status = Some(
- "Promoted one source object as a tracked finding."
- .into(),
- );
- }
- Err(error) => {
- actions.error = Some(error.to_string());
- }
- }
- }
- });
- }
- });
- }
- });
- });
- }
- });
- }
-}
-
-fn sidecar_kind_for_external(
- kind: &dicom_viewer_core::ExternalLayerKind,
-) -> Option {
- match kind {
- dicom_viewer_core::ExternalLayerKind::DicomAnn => {
- Some(dicom_viewer_core::SidecarKind::Annotation)
- }
- dicom_viewer_core::ExternalLayerKind::DicomSeg => {
- Some(dicom_viewer_core::SidecarKind::BinarySegmentation)
- }
- dicom_viewer_core::ExternalLayerKind::DicomSr => {
- Some(dicom_viewer_core::SidecarKind::StructuredReport)
- }
- dicom_viewer_core::ExternalLayerKind::ProfiledGeoJson
- | dicom_viewer_core::ExternalLayerKind::Heatmap => None,
- }
-}
-
-fn edit_layer_opacity(
- ui: &mut egui::Ui,
- runtime: &mut WorkspaceRuntime,
- actions: &mut PathologyWorkspaceActions,
- id: Uuid,
- presentation: dicom_viewer_core::LayerPresentation,
-) {
- let mut opacity = presentation.opacity;
- if ui
- .add(egui::Slider::new(&mut opacity, 0.05..=1.0).text("opacity"))
- .changed()
- {
- let mut updated = presentation;
- updated.opacity = opacity;
- if let Err(error) = runtime.set_layer_presentation_without_history(id, updated) {
- actions.error = Some(error.to_string());
- }
- }
-}
-
-fn layer_row(
- ui: &mut egui::Ui,
- runtime: &mut WorkspaceRuntime,
- actions: &mut PathologyWorkspaceActions,
- id: Uuid,
- name: &str,
- count: usize,
- representation: EditingRepresentation,
-) {
- let presentation = runtime.document().presentation().layer(id);
- ui.horizontal(|ui| {
- let eye = if presentation.visible { "●" } else { "○" };
- if ui.small_button(eye).clicked() {
- if let Err(error) = runtime.set_layer_visibility(id, !presentation.visible) {
- actions.error = Some(error.to_string());
- }
- }
- let active = runtime.editing_representation() == representation;
- if ui
- .selectable_label(active, format!("{name} {count}"))
- .clicked()
- {
- let result = match representation {
- EditingRepresentation::Vector => runtime.use_vector_layer(id),
- EditingRepresentation::Segmentation => runtime.use_segmentation_layer(id),
- };
- if let Err(error) = result {
- actions.error = Some(error.to_string());
- }
- }
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- ui.label(
- RichText::new(if presentation.locked {
- "locked"
- } else {
- "editable"
- })
- .small()
- .color(theme::TEXT_DIM),
- );
- });
- });
- ui.horizontal(|ui| {
- edit_layer_opacity(ui, runtime, actions, id, presentation);
- let mut locked = presentation.locked;
- if ui.checkbox(&mut locked, "Lock").changed() {
- let mut updated = presentation;
- updated.locked = locked;
- if let Err(error) = runtime.set_layer_presentation_without_history(id, updated) {
- actions.error = Some(error.to_string());
- }
- }
- });
-}
-
-fn show_inspector(
- ui: &mut egui::Ui,
- runtime: &mut WorkspaceRuntime,
- actions: &mut PathologyWorkspaceActions,
-) {
- section_heading(ui, "Selection inspector");
- match runtime.selection().len() {
- 0 => {
- ui.label(
- RichText::new("Select a finding or segment to inspect it.")
- .small()
- .color(theme::TEXT_DIM),
- );
- }
- count @ 2.. => {
- ui.label(format!("{count} objects selected"));
- actions.delete_selection = ui.button("Delete selected").clicked();
- }
- 1 => {
- let id = *runtime
- .selection()
- .iter()
- .next()
- .expect("one selection exists");
- if let Some(row) = finding_rows(runtime).into_iter().find(|row| row.id == id) {
- let details = selected_details(runtime, id);
- ui.label(RichText::new(format!("#{:04} {}", row.ordinal, row.label)).strong());
- ui.label(
- RichText::new(row.metric)
- .monospace()
- .color(theme::TEXT_MUTED),
- );
- if let Some(details) = details {
- let compatible_classes = runtime
- .document()
- .scheme()
- .classes()
- .iter()
- .filter(|class| class.geometry() == details.geometry)
- .map(|class| (class.id().to_owned(), class.label().to_owned()))
- .collect::>();
- egui::ComboBox::from_id_salt(("inspector-class", id))
- .selected_text(
- runtime
- .document()
- .scheme()
- .class(&details.class_id)
- .map_or(details.class_id.as_str(), |class| class.label()),
- )
- .show_ui(ui, |ui| {
- for (class_id, label) in compatible_classes {
- if ui
- .selectable_label(class_id == details.class_id, label)
- .clicked()
- {
- if let Err(error) = runtime.reclassify_selection(&class_id) {
- actions.error = Some(error.to_string());
- }
- }
- }
- });
-
- let finding_sites = runtime.document().scheme().finding_sites().to_vec();
- if !finding_sites.is_empty() {
- let selected_site = details.finding_site.as_ref().and_then(|selected| {
- finding_sites.iter().find(|site| selected.matches(site))
- });
- egui::ComboBox::from_id_salt(("inspector-finding-site", id))
- .selected_text(
- selected_site.map_or("No finding site", |site| site.meaning()),
- )
- .show_ui(ui, |ui| {
- if ui
- .selectable_label(
- details.finding_site.is_none(),
- "No finding site",
- )
- .clicked()
- {
- if let Err(error) = runtime.set_selected_finding_site(None) {
- actions.error = Some(error.to_string());
- }
- }
- for site in &finding_sites {
- let selected = details
- .finding_site
- .as_ref()
- .is_some_and(|current| current.matches(site));
- if ui.selectable_label(selected, site.meaning()).clicked() {
- if let Err(error) =
- runtime.set_selected_finding_site(Some(site))
- {
- actions.error = Some(error.to_string());
- }
- }
- }
- });
- }
-
- let name_id = egui::Id::new(("finding-name", id));
- let mut name = ui.ctx().data_mut(|data| {
- data.get_temp::(name_id)
- .unwrap_or_else(|| details.name.clone().unwrap_or_default())
- });
- let name_response = ui.add(
- egui::TextEdit::singleline(&mut name)
- .id(name_id)
- .hint_text("Optional finding name"),
- );
- ui.ctx()
- .data_mut(|data| data.insert_temp(name_id, name.clone()));
- if name_response.lost_focus() {
- let value = (!name.trim().is_empty()).then_some(name.trim());
- if let Err(error) = runtime.set_selected_name(value) {
- actions.error = Some(error.to_string());
- }
- }
-
- let comment_id = egui::Id::new(("finding-comment", id));
- let mut comment = ui.ctx().data_mut(|data| {
- data.get_temp::(comment_id)
- .unwrap_or_else(|| details.comment.clone().unwrap_or_default())
- });
- let comment_response = ui.add(
- egui::TextEdit::multiline(&mut comment)
- .id(comment_id)
- .desired_rows(2)
- .hint_text("Optional comment"),
- );
- ui.ctx()
- .data_mut(|data| data.insert_temp(comment_id, comment.clone()));
- if comment_response.lost_focus() {
- let value = (!comment.trim().is_empty()).then_some(comment.trim());
- if let Err(error) = runtime.set_selected_comment(value) {
- actions.error = Some(error.to_string());
- }
- }
-
- ui.label(
- RichText::new(format!("{} · {}", details.kind, details.source_status))
- .small()
- .color(theme::TEXT_MUTED),
- );
- ui.label(
- RichText::new(format!(
- "Tracking ID {}\nTracking UID {}",
- details.tracking_id, details.tracking_uid
- ))
- .monospace()
- .small()
- .color(theme::TEXT_DIM),
- );
- }
- ui.label(
- RichText::new(format!("Object {id}"))
- .monospace()
- .small()
- .color(theme::TEXT_DIM),
- );
- actions.delete_selection = ui.button("Delete finding").clicked();
- }
- }
- }
-}
-
fn show_expert(
ui: &mut egui::Ui,
runtime: &WorkspaceRuntime,
@@ -920,227 +186,5 @@ fn show_expert(
});
}
-struct FindingRow {
- id: Uuid,
- ordinal: u64,
- label: String,
- metric: String,
-}
-
-struct SelectedDetails {
- class_id: String,
- geometry: AnnotationClassGeometry,
- name: Option,
- comment: Option,
- tracking_id: String,
- tracking_uid: String,
- kind: &'static str,
- source_status: &'static str,
- finding_site: Option,
-}
-
-fn selected_details(runtime: &WorkspaceRuntime, id: Uuid) -> Option {
- let document = runtime.document();
- if let Some(finding) = document.finding(id) {
- return Some(SelectedDetails {
- class_id: finding.class_id().to_owned(),
- geometry: match finding.geometry() {
- VectorFindingGeometry::Point(_) => AnnotationClassGeometry::Point,
- VectorFindingGeometry::Regions(_) => AnnotationClassGeometry::Region,
- },
- name: finding.name().map(str::to_owned),
- comment: finding.comment().map(str::to_owned),
- tracking_id: finding.tracking().id().to_owned(),
- tracking_uid: finding.tracking().uid().to_owned(),
- kind: "Vector finding",
- source_status: provenance_label(finding.provenance()),
- finding_site: finding.finding_site().cloned(),
- });
- }
- if let Some(segment) = document.segment(id) {
- return Some(SelectedDetails {
- class_id: segment.class_id().to_owned(),
- geometry: AnnotationClassGeometry::Region,
- name: segment.name().map(str::to_owned),
- comment: segment.comment().map(str::to_owned),
- tracking_id: segment.tracking().id().to_owned(),
- tracking_uid: segment.tracking().uid().to_owned(),
- kind: "Segmentation segment",
- source_status: provenance_label(segment.provenance()),
- finding_site: segment.finding_site().cloned(),
- });
- }
- let measurement = document.measurement(id)?;
- Some(SelectedDetails {
- class_id: measurement.class_id().to_owned(),
- geometry: AnnotationClassGeometry::Region,
- name: measurement.name().map(str::to_owned),
- comment: measurement.comment().map(str::to_owned),
- tracking_id: measurement.tracking().id().to_owned(),
- tracking_uid: measurement.tracking().uid().to_owned(),
- kind: "Linear measurement",
- source_status: provenance_label(measurement.provenance()),
- finding_site: measurement.finding_site().cloned(),
- })
-}
-
-fn provenance_label(provenance: &dicom_viewer_core::WorkspaceObjectProvenance) -> &'static str {
- match provenance {
- dicom_viewer_core::WorkspaceObjectProvenance::Manual => "manual",
- dicom_viewer_core::WorkspaceObjectProvenance::Promoted { .. } => "promoted",
- }
-}
-
-fn finding_rows(runtime: &WorkspaceRuntime) -> Vec {
- let document = runtime.document();
- let mut rows = document
- .vector_findings()
- .map(|finding| FindingRow {
- id: finding.object_id(),
- ordinal: finding.ordinal(),
- label: document.scheme().class(finding.class_id()).map_or_else(
- || finding.class_id().to_owned(),
- |class| class.label().to_owned(),
- ),
- metric: match finding.geometry() {
- VectorFindingGeometry::Point(_) => "point".into(),
- VectorFindingGeometry::Regions(components) => {
- format!(
- "{} region{}",
- components.len(),
- if components.len() == 1 { "" } else { "s" }
- )
- }
- },
- })
- .chain(document.segments().map(|segment| {
- let geometry = document.composite_segment(segment.object_id()).ok();
- FindingRow {
- id: segment.object_id(),
- ordinal: segment.ordinal(),
- label: document.scheme().class(segment.class_id()).map_or_else(
- || segment.class_id().to_owned(),
- |class| class.label().to_owned(),
- ),
- metric: format!(
- "{} component{}",
- geometry
- .as_ref()
- .map_or(0, |geometry| geometry.components().len()),
- if geometry
- .as_ref()
- .is_some_and(|geometry| geometry.components().len() == 1)
- {
- ""
- } else {
- "s"
- }
- ),
- }
- }))
- .chain(
- document
- .measurements()
- .iter()
- .map(|measurement| FindingRow {
- id: measurement.object_id(),
- ordinal: measurement.ordinal(),
- label: document.scheme().class(measurement.class_id()).map_or_else(
- || "Ruler".into(),
- |class| format!("{} length", class.label()),
- ),
- metric: measurement.physical_length_mm().map_or_else(
- || "unscaled".into(),
- |length| {
- if length >= 1.0 {
- format!("{length:.3} mm")
- } else {
- format!("{:.1} µm", length * 1_000.0)
- }
- },
- ),
- }),
- )
- .collect::>();
- rows.sort_by_key(|row| row.ordinal);
- rows
-}
-
-fn visible_palette(runtime: &WorkspaceRuntime) -> Vec<(String, String, [u8; 3], bool)> {
- let expected_geometry = match runtime.active_tool() {
- ActiveTool::Point => Some(AnnotationClassGeometry::Point),
- ActiveTool::Polygon | ActiveTool::Brush | ActiveTool::Ruler => {
- Some(AnnotationClassGeometry::Region)
- }
- ActiveTool::Pan | ActiveTool::Select => None,
- };
- runtime
- .document()
- .scheme()
- .classes()
- .iter()
- .filter(|class| expected_geometry.is_none_or(|geometry| class.geometry() == geometry))
- .map(|class| {
- (
- class.id().to_owned(),
- class.label().to_owned(),
- class.display_color(),
- runtime.active_class_id() == class.id(),
- )
- })
- .collect()
-}
-
-fn tool_glyph(tool: ActiveTool) -> &'static str {
- match tool {
- ActiveTool::Pan => "✥",
- ActiveTool::Select => "⌁",
- ActiveTool::Polygon => "△",
- ActiveTool::Brush => "●",
- ActiveTool::Point => "+",
- ActiveTool::Ruler => "╱",
- }
-}
-
#[cfg(test)]
-mod tests {
- use super::*;
- use crate::app::tests::run_ui;
- use dicom_viewer_core::{AnnotationScheme, ViewerSourceIdentity};
-
- fn runtime() -> WorkspaceRuntime {
- WorkspaceRuntime::new(
- ViewerSourceIdentity::new(1, 0, 0, 0, 0, 0, (1_000, 1_000)),
- AnnotationScheme::general_pathology_v1(),
- )
- .unwrap()
- }
-
- #[test]
- fn pathology_workspace_renders_as_one_dense_panel_and_tool_rail() {
- let mut runtime = runtime();
- let output = run_ui(|ui| {
- let _ = show_tool_rail(ui, &mut runtime);
- show_pathology_workspace_panel(ui, &mut runtime);
- });
- assert!(!output.shapes.is_empty());
- }
-
- #[test]
- fn common_palette_filters_classes_by_tool_geometry() {
- let mut runtime = runtime();
- runtime.set_active_tool(ActiveTool::Point).unwrap();
- let point_classes = visible_palette(&runtime);
- assert_eq!(
- point_classes
- .iter()
- .map(|class| class.0.as_str())
- .collect::>(),
- vec!["cell", "nucleus"]
- );
- runtime.set_active_tool(ActiveTool::Polygon).unwrap();
- assert!(visible_palette(&runtime)
- .iter()
- .all(|class| !matches!(class.0.as_str(), "cell" | "nucleus")));
- }
-}
+mod tests;
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace/findings.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace/findings.rs
new file mode 100644
index 0000000..6304b58
--- /dev/null
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace/findings.rs
@@ -0,0 +1,155 @@
+use super::*;
+use dicom_viewer_core::VectorFindingGeometry;
+
+pub(super) fn show_findings(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+) {
+ section_heading(ui, "Findings");
+ let rows = finding_rows(runtime);
+ if rows.is_empty() {
+ ui.label(
+ RichText::new("No tracked findings yet.")
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ return;
+ }
+ ScrollArea::vertical()
+ .id_salt("tracked-findings")
+ .max_height(210.0)
+ .auto_shrink([false, true])
+ .show_rows(ui, FINDING_ROW_HEIGHT, rows.len(), |ui, range| {
+ for row in &rows[range] {
+ let selected = runtime.selection().contains(&row.id);
+ ui.horizontal(|ui| {
+ let visible = runtime.document().presentation().object_visible(row.id);
+ let eye = if visible { "●" } else { "○" };
+ if ui
+ .small_button(eye)
+ .on_hover_text("Toggle visibility")
+ .clicked()
+ {
+ if let Err(error) =
+ runtime.set_object_visibility_without_history(row.id, !visible)
+ {
+ actions.error = Some(error.to_string());
+ }
+ }
+ let response = ui.selectable_label(
+ selected,
+ RichText::new(format!("#{:04} {}", row.ordinal, row.label)).size(12.0),
+ );
+ if response.clicked() {
+ let shift = ui.input(|input| input.modifiers.shift);
+ if shift {
+ runtime.toggle_selection(row.id);
+ } else {
+ runtime.select_only(row.id);
+ }
+ }
+ if response.double_clicked() {
+ actions.jump_to = Some(row.id);
+ }
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if ui
+ .small_button("⌖")
+ .on_hover_text("Jump to finding")
+ .clicked()
+ {
+ actions.jump_to = Some(row.id);
+ }
+ ui.label(
+ RichText::new(&row.metric)
+ .monospace()
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ });
+ });
+ }
+ });
+}
+
+pub(super) struct FindingRow {
+ pub(super) id: Uuid,
+ pub(super) ordinal: u64,
+ pub(super) label: String,
+ pub(super) metric: String,
+}
+
+pub(super) fn finding_rows(runtime: &WorkspaceRuntime) -> Vec {
+ let document = runtime.document();
+ let mut rows = document
+ .vector_findings()
+ .map(|finding| FindingRow {
+ id: finding.object_id(),
+ ordinal: finding.ordinal(),
+ label: document.scheme().class(finding.class_id()).map_or_else(
+ || finding.class_id().to_owned(),
+ |class| class.label().to_owned(),
+ ),
+ metric: match finding.geometry() {
+ VectorFindingGeometry::Point(_) => "point".into(),
+ VectorFindingGeometry::Regions(components) => {
+ format!(
+ "{} region{}",
+ components.len(),
+ if components.len() == 1 { "" } else { "s" }
+ )
+ }
+ },
+ })
+ .chain(document.segments().map(|segment| {
+ let geometry = document.composite_segment(segment.object_id()).ok();
+ FindingRow {
+ id: segment.object_id(),
+ ordinal: segment.ordinal(),
+ label: document.scheme().class(segment.class_id()).map_or_else(
+ || segment.class_id().to_owned(),
+ |class| class.label().to_owned(),
+ ),
+ metric: format!(
+ "{} component{}",
+ geometry
+ .as_ref()
+ .map_or(0, |geometry| geometry.components().len()),
+ if geometry
+ .as_ref()
+ .is_some_and(|geometry| geometry.components().len() == 1)
+ {
+ ""
+ } else {
+ "s"
+ }
+ ),
+ }
+ }))
+ .chain(
+ document
+ .measurements()
+ .iter()
+ .map(|measurement| FindingRow {
+ id: measurement.object_id(),
+ ordinal: measurement.ordinal(),
+ label: document.scheme().class(measurement.class_id()).map_or_else(
+ || "Ruler".into(),
+ |class| format!("{} length", class.label()),
+ ),
+ metric: measurement.physical_length_mm().map_or_else(
+ || "unscaled".into(),
+ |length| {
+ if length >= 1.0 {
+ format!("{length:.3} mm")
+ } else {
+ format!("{:.1} µm", length * 1_000.0)
+ }
+ },
+ ),
+ }),
+ )
+ .collect::>();
+ rows.sort_by_key(|row| row.ordinal);
+ rows
+}
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace/inspector.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace/inspector.rs
new file mode 100644
index 0000000..034b7cc
--- /dev/null
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace/inspector.rs
@@ -0,0 +1,210 @@
+use super::*;
+
+pub(super) fn show_inspector(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+) {
+ section_heading(ui, "Selection inspector");
+ match runtime.selection().len() {
+ 0 => {
+ ui.label(
+ RichText::new("Select a finding or segment to inspect it.")
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ }
+ count @ 2.. => {
+ ui.label(format!("{count} objects selected"));
+ actions.delete_selection = ui.button("Delete selected").clicked();
+ }
+ 1 => {
+ let id = *runtime
+ .selection()
+ .iter()
+ .next()
+ .expect("one selection exists");
+ if let Some(row) = finding_rows(runtime).into_iter().find(|row| row.id == id) {
+ let details = selected_details(runtime, id);
+ ui.label(RichText::new(format!("#{:04} {}", row.ordinal, row.label)).strong());
+ ui.label(
+ RichText::new(row.metric)
+ .monospace()
+ .color(theme::TEXT_MUTED),
+ );
+ if let Some(details) = details {
+ let compatible_classes = runtime
+ .document()
+ .scheme()
+ .classes()
+ .iter()
+ .filter(|class| class.geometry() == details.geometry)
+ .map(|class| (class.id().to_owned(), class.label().to_owned()))
+ .collect::>();
+ egui::ComboBox::from_id_salt(("inspector-class", id))
+ .selected_text(
+ runtime
+ .document()
+ .scheme()
+ .class(&details.class_id)
+ .map_or(details.class_id.as_str(), |class| class.label()),
+ )
+ .show_ui(ui, |ui| {
+ for (class_id, label) in compatible_classes {
+ if ui
+ .selectable_label(class_id == details.class_id, label)
+ .clicked()
+ {
+ if let Err(error) = runtime.reclassify_selection(&class_id) {
+ actions.error = Some(error.to_string());
+ }
+ }
+ }
+ });
+
+ let finding_sites = runtime.document().scheme().finding_sites().to_vec();
+ if !finding_sites.is_empty() {
+ let selected_site = details.finding_site.as_ref().and_then(|selected| {
+ finding_sites.iter().find(|site| selected.matches(site))
+ });
+ egui::ComboBox::from_id_salt(("inspector-finding-site", id))
+ .selected_text(
+ selected_site.map_or("No finding site", |site| site.meaning()),
+ )
+ .show_ui(ui, |ui| {
+ if ui
+ .selectable_label(
+ details.finding_site.is_none(),
+ "No finding site",
+ )
+ .clicked()
+ {
+ if let Err(error) = runtime.set_selected_finding_site(None) {
+ actions.error = Some(error.to_string());
+ }
+ }
+ for site in &finding_sites {
+ let selected = details
+ .finding_site
+ .as_ref()
+ .is_some_and(|current| current.matches(site));
+ if ui.selectable_label(selected, site.meaning()).clicked() {
+ if let Err(error) =
+ runtime.set_selected_finding_site(Some(site))
+ {
+ actions.error = Some(error.to_string());
+ }
+ }
+ }
+ });
+ }
+
+ let name_id = egui::Id::new(("finding-name", id));
+ let mut name = ui.ctx().data_mut(|data| {
+ data.get_temp::(name_id)
+ .unwrap_or_else(|| details.name.clone().unwrap_or_default())
+ });
+ let name_response = ui.add(
+ egui::TextEdit::singleline(&mut name)
+ .id(name_id)
+ .hint_text("Optional finding name"),
+ );
+ ui.ctx()
+ .data_mut(|data| data.insert_temp(name_id, name.clone()));
+ if name_response.lost_focus() {
+ let value = (!name.trim().is_empty()).then_some(name.trim());
+ if let Err(error) = runtime.set_selected_name(value) {
+ actions.error = Some(error.to_string());
+ }
+ }
+
+ let comment_id = egui::Id::new(("finding-comment", id));
+ let mut comment = ui.ctx().data_mut(|data| {
+ data.get_temp::(comment_id)
+ .unwrap_or_else(|| details.comment.clone().unwrap_or_default())
+ });
+ let comment_response = ui.add(
+ egui::TextEdit::multiline(&mut comment)
+ .id(comment_id)
+ .desired_rows(2)
+ .hint_text("Optional comment"),
+ );
+ ui.ctx()
+ .data_mut(|data| data.insert_temp(comment_id, comment.clone()));
+ if comment_response.lost_focus() {
+ let value = (!comment.trim().is_empty()).then_some(comment.trim());
+ if let Err(error) = runtime.set_selected_comment(value) {
+ actions.error = Some(error.to_string());
+ }
+ }
+
+ ui.label(
+ RichText::new(format!("{} · {}", details.kind, details.source_status))
+ .small()
+ .color(theme::TEXT_MUTED),
+ );
+ ui.label(
+ RichText::new(format!(
+ "Tracking ID {}\nTracking UID {}",
+ details.tracking_id, details.tracking_uid
+ ))
+ .monospace()
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ }
+ ui.label(
+ RichText::new(format!("Object {id}"))
+ .monospace()
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ actions.delete_selection = ui.button("Delete finding").clicked();
+ }
+ }
+ }
+}
+
+struct SelectedDetails {
+ class_id: String,
+ geometry: AnnotationClassGeometry,
+ name: Option,
+ comment: Option,
+ tracking_id: String,
+ tracking_uid: String,
+ kind: &'static str,
+ source_status: &'static str,
+ finding_site: Option,
+}
+
+fn selected_details(runtime: &WorkspaceRuntime, id: Uuid) -> Option {
+ let object = runtime.document().object(id)?;
+ let (geometry, kind) = match object.geometry_kind() {
+ WorkspaceObjectGeometryKind::Point => (AnnotationClassGeometry::Point, "Vector finding"),
+ WorkspaceObjectGeometryKind::Region => (AnnotationClassGeometry::Region, "Vector finding"),
+ WorkspaceObjectGeometryKind::Segmentation => {
+ (AnnotationClassGeometry::Region, "Segmentation segment")
+ }
+ WorkspaceObjectGeometryKind::Measurement => {
+ (AnnotationClassGeometry::Region, "Linear measurement")
+ }
+ };
+ Some(SelectedDetails {
+ class_id: object.class_id().to_owned(),
+ geometry,
+ name: object.name().map(str::to_owned),
+ comment: object.comment().map(str::to_owned),
+ tracking_id: object.tracking().id().to_owned(),
+ tracking_uid: object.tracking().uid().to_owned(),
+ kind,
+ source_status: provenance_label(object.provenance()),
+ finding_site: object.finding_site().cloned(),
+ })
+}
+
+fn provenance_label(provenance: &dicom_viewer_core::WorkspaceObjectProvenance) -> &'static str {
+ match provenance {
+ dicom_viewer_core::WorkspaceObjectProvenance::Manual => "manual",
+ dicom_viewer_core::WorkspaceObjectProvenance::Promoted { .. } => "promoted",
+ }
+}
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace/layers.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace/layers.rs
new file mode 100644
index 0000000..dace7b8
--- /dev/null
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace/layers.rs
@@ -0,0 +1,128 @@
+mod external;
+
+use super::PathologyWorkspaceActions;
+use crate::app::ui::section_heading;
+use crate::app::{
+ theme,
+ workspace::{EditingRepresentation, WorkspaceRuntime},
+};
+use eframe::egui::{self, RichText};
+use uuid::Uuid;
+
+pub(super) fn show_layers(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+) {
+ section_heading(ui, "Layers");
+ let vectors = runtime
+ .document()
+ .vector_layers()
+ .iter()
+ .map(|layer| (layer.id(), layer.name().to_owned(), layer.findings().len()))
+ .collect::>();
+ let segments = runtime
+ .document()
+ .segmentation_layers()
+ .iter()
+ .map(|layer| (layer.id(), layer.name().to_owned(), layer.segments().len()))
+ .collect::>();
+ for (id, name, count) in vectors {
+ layer_row(
+ ui,
+ runtime,
+ actions,
+ id,
+ &name,
+ count,
+ EditingRepresentation::Vector,
+ );
+ }
+ for (id, name, count) in segments {
+ layer_row(
+ ui,
+ runtime,
+ actions,
+ id,
+ &name,
+ count,
+ EditingRepresentation::Segmentation,
+ );
+ }
+ external::show_external_layers(ui, runtime, actions);
+}
+
+fn edit_layer_opacity(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+ id: Uuid,
+ presentation: dicom_viewer_core::LayerPresentation,
+) {
+ let mut opacity = presentation.opacity;
+ if ui
+ .add(egui::Slider::new(&mut opacity, 0.05..=1.0).text("opacity"))
+ .changed()
+ {
+ let mut updated = presentation;
+ updated.opacity = opacity;
+ if let Err(error) = runtime.set_layer_presentation_without_history(id, updated) {
+ actions.error = Some(error.to_string());
+ }
+ }
+}
+
+fn layer_row(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+ id: Uuid,
+ name: &str,
+ count: usize,
+ representation: EditingRepresentation,
+) {
+ let presentation = runtime.document().presentation().layer(id);
+ ui.horizontal(|ui| {
+ let eye = if presentation.visible { "●" } else { "○" };
+ if ui.small_button(eye).clicked() {
+ if let Err(error) = runtime.set_layer_visibility(id, !presentation.visible) {
+ actions.error = Some(error.to_string());
+ }
+ }
+ let active = runtime.editing_representation() == representation;
+ if ui
+ .selectable_label(active, format!("{name} {count}"))
+ .clicked()
+ {
+ let result = match representation {
+ EditingRepresentation::Vector => runtime.use_vector_layer(id),
+ EditingRepresentation::Segmentation => runtime.use_segmentation_layer(id),
+ };
+ if let Err(error) = result {
+ actions.error = Some(error.to_string());
+ }
+ }
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ ui.label(
+ RichText::new(if presentation.locked {
+ "locked"
+ } else {
+ "editable"
+ })
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ });
+ });
+ ui.horizontal(|ui| {
+ edit_layer_opacity(ui, runtime, actions, id, presentation);
+ let mut locked = presentation.locked;
+ if ui.checkbox(&mut locked, "Lock").changed() {
+ let mut updated = presentation;
+ updated.locked = locked;
+ if let Err(error) = runtime.set_layer_presentation_without_history(id, updated) {
+ actions.error = Some(error.to_string());
+ }
+ }
+ });
+}
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace/layers/external.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace/layers/external.rs
new file mode 100644
index 0000000..0328776
--- /dev/null
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace/layers/external.rs
@@ -0,0 +1,311 @@
+use super::{edit_layer_opacity, PathologyWorkspaceActions};
+use crate::app::{
+ theme,
+ workspace::{ExternalClassDescriptor, WorkspaceRuntime},
+};
+use dicom_viewer_core::{AnnotationClassGeometry, ExternalLayerReference};
+use eframe::egui::{self, RichText, ScrollArea};
+
+pub(super) fn show_external_layers(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+) {
+ let layers = runtime.document().external_layers().to_vec();
+ for layer in &layers {
+ show_external_layer(ui, runtime, actions, layer);
+ }
+}
+
+fn show_external_layer(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+ layer: &ExternalLayerReference,
+) {
+ let id = layer.id();
+ let name = layer.name();
+ let kind = layer.kind();
+ let source_path = layer.source_path();
+ let count = layer.source_object_count();
+ let presentation = runtime.document().presentation().layer(id);
+ let mut remove_layer = false;
+ let mut load_layer = false;
+ let payload_loaded = runtime.external_payload(id).is_some();
+ ui.horizontal(|ui| {
+ let eye = if presentation.visible { "●" } else { "○" };
+ if ui.small_button(eye).clicked() {
+ if let Err(error) = runtime.set_layer_visibility(id, !presentation.visible) {
+ actions.error = Some(error.to_string());
+ }
+ }
+ ui.label(RichText::new("◇").color(theme::TEXT_DIM));
+ ui.label(format!("{name} · {count}"));
+ ui.label(RichText::new("LOCKED").small().color(theme::TEXT_DIM));
+ if !payload_loaded
+ && source_path.is_some()
+ && sidecar_kind_for_external(kind).is_some()
+ && ui.small_button("Load").clicked()
+ {
+ load_layer = true;
+ }
+ if ui.small_button("Remove").clicked() {
+ remove_layer = true;
+ }
+ });
+ ui.horizontal(|ui| {
+ edit_layer_opacity(ui, runtime, actions, id, presentation);
+ let status = source_path.map_or("embedded reference", |path| {
+ if path.exists() {
+ "linked source"
+ } else {
+ "missing linked source"
+ }
+ });
+ ui.label(RichText::new(format!("{kind:?} · {status}")).small().color(
+ if status.starts_with("missing") {
+ theme::AMBER
+ } else {
+ theme::TEXT_DIM
+ },
+ ));
+ });
+ if remove_layer {
+ match runtime.remove_external_layer(id) {
+ Ok(true) => {
+ actions.status = Some(format!("Removed source layer {name}; Undo restores it."));
+ }
+ Ok(false) => {}
+ Err(error) => actions.error = Some(error.to_string()),
+ }
+ return;
+ }
+ if load_layer {
+ actions.load_sidecar = source_path
+ .map(std::path::Path::to_path_buf)
+ .zip(sidecar_kind_for_external(kind));
+ }
+ let classes = runtime.external_classes(id);
+ ui.indent(("external-controls", id), |ui| match classes {
+ Err(error) => {
+ ui.label(RichText::new(error.to_string()).small().color(theme::AMBER));
+ }
+ Ok(classes) if classes.is_empty() => {
+ let message =
+ if *kind == dicom_viewer_core::ExternalLayerKind::Heatmap && payload_loaded {
+ "Heatmap source result; export it through DICOM PM."
+ } else {
+ "Source payload is unloaded or has no lossless editable objects."
+ };
+ ui.label(RichText::new(message).small().color(theme::TEXT_DIM));
+ }
+ Ok(classes) => {
+ ui.collapsing("Class mapping & promotion", |ui| {
+ show_promotion(ui, runtime, actions, layer, &classes);
+ });
+ }
+ });
+}
+
+fn sidecar_kind_for_external(
+ kind: &dicom_viewer_core::ExternalLayerKind,
+) -> Option {
+ match kind {
+ dicom_viewer_core::ExternalLayerKind::DicomAnn => {
+ Some(dicom_viewer_core::SidecarKind::Annotation)
+ }
+ dicom_viewer_core::ExternalLayerKind::DicomSeg => {
+ Some(dicom_viewer_core::SidecarKind::BinarySegmentation)
+ }
+ dicom_viewer_core::ExternalLayerKind::DicomSr => {
+ Some(dicom_viewer_core::SidecarKind::StructuredReport)
+ }
+ dicom_viewer_core::ExternalLayerKind::ProfiledGeoJson
+ | dicom_viewer_core::ExternalLayerKind::Heatmap => None,
+ }
+}
+
+fn show_promotion(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+ layer: &ExternalLayerReference,
+ classes: &[ExternalClassDescriptor],
+) {
+ let id = layer.id();
+ let mappings = layer.class_mappings();
+ let scheme_options = runtime
+ .document()
+ .scheme()
+ .classes()
+ .iter()
+ .map(|class| {
+ (
+ class.id().to_owned(),
+ class.label().to_owned(),
+ class.geometry(),
+ )
+ })
+ .collect::>();
+ for class in classes {
+ show_class_mapping(ui, runtime, actions, layer, class, &scheme_options);
+ }
+
+ let complete = classes
+ .iter()
+ .all(|class| class.editable && mappings.contains_key(&class.key));
+ if ui
+ .add_enabled(complete, egui::Button::new("Make editable"))
+ .on_hover_text("Convert every source object only after every source class is mapped")
+ .clicked()
+ {
+ match runtime.make_external_layer_editable(id) {
+ Ok(ids) => {
+ actions.status = Some(format!(
+ "Converted {} source object(s) as independently tracked findings.",
+ ids.len()
+ ));
+ }
+ Err(error) => actions.error = Some(error.to_string()),
+ }
+ }
+
+ ui.collapsing("Source objects", |ui| {
+ show_source_objects(ui, runtime, actions, layer)
+ });
+}
+
+fn show_class_mapping(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+ layer: &ExternalLayerReference,
+ class: &ExternalClassDescriptor,
+ scheme_options: &[(String, String, AnnotationClassGeometry)],
+) {
+ let id = layer.id();
+ let mappings = layer.class_mappings();
+
+ ui.horizontal_wrapped(|ui| {
+ ui.label(format!(
+ "{} · {} {}",
+ class.label,
+ class.object_count,
+ class.geometry.label().to_lowercase()
+ ));
+ if !class.editable {
+ ui.label(
+ RichText::new("read-only geometry")
+ .small()
+ .color(theme::AMBER),
+ );
+ }
+ });
+ let current = mappings.get(&class.key).cloned();
+ let selected_text = current
+ .as_deref()
+ .and_then(|id| {
+ scheme_options
+ .iter()
+ .find(|(candidate, _, _)| candidate == id)
+ .map(|(_, label, _)| label.as_str())
+ })
+ .unwrap_or("Unmapped");
+ ui.horizontal(|ui| {
+ egui::ComboBox::from_id_salt(("external-class-map", id, &class.key))
+ .selected_text(selected_text)
+ .show_ui(ui, |ui| {
+ for (target_id, label, geometry) in scheme_options {
+ if *geometry != class.geometry {
+ continue;
+ }
+ if ui
+ .selectable_label(current.as_deref() == Some(target_id.as_str()), label)
+ .clicked()
+ {
+ if let Err(error) =
+ runtime.set_external_class_mapping(id, &class.key, target_id)
+ {
+ actions.error = Some(error.to_string());
+ }
+ }
+ }
+ });
+ if current.is_none() {
+ if let Some(suggested) = class.exact_scheme_class_id.as_deref() {
+ let label = scheme_options
+ .iter()
+ .find(|(id, _, _)| id == suggested)
+ .map_or(suggested, |(_, label, _)| label.as_str());
+ if ui.small_button(format!("Use exact: {label}")).clicked() {
+ if let Err(error) =
+ runtime.set_external_class_mapping(id, &class.key, suggested)
+ {
+ actions.error = Some(error.to_string());
+ }
+ }
+ }
+ }
+ });
+}
+
+fn show_source_objects(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+ layer: &ExternalLayerReference,
+) {
+ let id = layer.id();
+ let mappings = layer.class_mappings();
+ match runtime.external_objects(id) {
+ Err(error) => {
+ ui.label(RichText::new(error.to_string()).small().color(theme::AMBER));
+ }
+ Ok(objects) => {
+ ScrollArea::vertical()
+ .id_salt(("external-objects", id))
+ .max_height(180.0)
+ .show_rows(ui, 24.0, objects.len(), |ui, range| {
+ for object in &objects[range] {
+ ui.horizontal(|ui| {
+ ui.label(
+ RichText::new(&object.label)
+ .small()
+ .color(theme::TEXT_MUTED),
+ );
+ let mapped = mappings.contains_key(&object.class_key);
+ let enabled = object.promotable && mapped && !object.promoted;
+ let response = ui.add_enabled(
+ enabled,
+ egui::Button::new(if object.promoted {
+ "Tracked"
+ } else {
+ "Promote"
+ }),
+ );
+ let response =
+ if let Some(reason) = object.promotion_block_reason.as_deref() {
+ response.on_disabled_hover_text(reason)
+ } else {
+ response
+ };
+ if response.clicked() {
+ match runtime.promote_external_object(id, &object.source_object_id)
+ {
+ Ok(_) => {
+ actions.status = Some(
+ "Promoted one source object as a tracked finding."
+ .into(),
+ );
+ }
+ Err(error) => {
+ actions.error = Some(error.to_string());
+ }
+ }
+ }
+ });
+ }
+ });
+ }
+ }
+}
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace/palette.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace/palette.rs
new file mode 100644
index 0000000..8195da0
--- /dev/null
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace/palette.rs
@@ -0,0 +1,111 @@
+use super::*;
+
+pub(super) fn show_scheme_and_palette(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+ actions: &mut PathologyWorkspaceActions,
+) {
+ section_heading(ui, "Annotation scheme");
+ let scheme = runtime.document().scheme();
+ ui.horizontal(|ui| {
+ ui.label(RichText::new(scheme.display_name()).strong());
+ ui.label(
+ RichText::new(format!("v{}", scheme.version()))
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ });
+ ui.label(
+ RichText::new(format!(
+ "{} · {}…",
+ scheme.id(),
+ &scheme.content_digest()[..12]
+ ))
+ .monospace()
+ .small()
+ .color(theme::TEXT_DIM),
+ );
+ let palette = visible_palette(runtime);
+ ui.add_space(4.0);
+ ui.horizontal_wrapped(|ui| {
+ for (id, label, color, selected) in palette {
+ let color = Color32::from_rgb(color[0], color[1], color[2]);
+ let response = ui
+ .horizontal(|ui| {
+ let (swatch, _) =
+ ui.allocate_exact_size(egui::vec2(8.0, 16.0), egui::Sense::hover());
+ ui.painter().rect_filled(swatch, 1.0, color);
+ ui.selectable_label(selected, RichText::new(label).size(12.0))
+ })
+ .inner;
+ if response.clicked() {
+ if let Err(error) = runtime.set_active_class(id) {
+ actions.error = Some(error.to_string());
+ }
+ }
+ }
+ });
+
+ if runtime.editing_representation() == EditingRepresentation::Segmentation
+ && matches!(
+ runtime.active_tool(),
+ ActiveTool::Polygon | ActiveTool::Brush
+ )
+ {
+ ui.horizontal(|ui| {
+ ui.label(RichText::new("Operation").small().color(theme::TEXT_MUTED));
+ for (operation, label) in [
+ (SegmentOperation::Add, "Add"),
+ (SegmentOperation::Erase, "Erase"),
+ ] {
+ let enabled = operation == SegmentOperation::Add
+ || runtime
+ .selection()
+ .iter()
+ .any(|id| runtime.document().segment(*id).is_some());
+ if ui
+ .add_enabled(
+ enabled,
+ egui::Button::selectable(runtime.segment_operation() == operation, label),
+ )
+ .clicked()
+ {
+ runtime.set_segment_operation(operation);
+ }
+ }
+ ui.label(RichText::new("Alt reverses").small().color(theme::TEXT_DIM));
+ });
+ if runtime.active_tool() == ActiveTool::Brush {
+ ui.label(
+ RichText::new(format!("Brush Ø {:.0} px", runtime.brush_diameter()))
+ .small()
+ .color(theme::TEXT_MUTED),
+ );
+ }
+ }
+}
+
+pub(super) fn visible_palette(runtime: &WorkspaceRuntime) -> Vec<(String, String, [u8; 3], bool)> {
+ let expected_geometry = match runtime.active_tool() {
+ ActiveTool::Point => Some(AnnotationClassGeometry::Point),
+ ActiveTool::Polygon | ActiveTool::Brush | ActiveTool::Ruler => {
+ Some(AnnotationClassGeometry::Region)
+ }
+ ActiveTool::Pan | ActiveTool::Select => None,
+ };
+ runtime
+ .document()
+ .scheme()
+ .classes()
+ .iter()
+ .filter(|class| expected_geometry.is_none_or(|geometry| class.geometry() == geometry))
+ .map(|class| {
+ (
+ class.id().to_owned(),
+ class.label().to_owned(),
+ class.display_color(),
+ runtime.active_class_id() == class.id(),
+ )
+ })
+ .collect()
+}
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace/tests.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace/tests.rs
new file mode 100644
index 0000000..fde4610
--- /dev/null
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace/tests.rs
@@ -0,0 +1,58 @@
+use super::*;
+use crate::app::tests::run_ui;
+use dicom_viewer_core::{AnnotationScheme, ViewerSourceIdentity};
+
+fn runtime() -> WorkspaceRuntime {
+ WorkspaceRuntime::new(
+ ViewerSourceIdentity::new(1, 0, 0, 0, 0, 0, (1_000, 1_000)),
+ AnnotationScheme::general_pathology_v1(),
+ )
+ .unwrap()
+}
+
+#[test]
+fn pathology_workspace_renders_as_one_dense_panel_and_tool_rail() {
+ let mut runtime = runtime();
+ let output = run_ui(|ui| {
+ let _ = show_tool_rail(ui, &mut runtime);
+ show_pathology_workspace_panel(ui, &mut runtime);
+ });
+ assert!(!output.shapes.is_empty());
+}
+
+#[test]
+fn pathology_panel_stays_closed_until_the_document_has_a_tracked_object() {
+ let mut runtime = runtime();
+ let mut panel_rendered = true;
+ let _ = run_ui(|ui| {
+ panel_rendered = show_populated_pathology_workspace_panel(ui, &mut runtime).is_some();
+ });
+ assert!(!panel_rendered);
+
+ runtime.set_active_tool(ActiveTool::Point).unwrap();
+ runtime
+ .add_point_finding(dicom_viewer_core::Point2::new(10.0, 20.0))
+ .unwrap();
+ let _ = run_ui(|ui| {
+ panel_rendered = show_populated_pathology_workspace_panel(ui, &mut runtime).is_some();
+ });
+ assert!(panel_rendered);
+}
+
+#[test]
+fn common_palette_filters_classes_by_tool_geometry() {
+ let mut runtime = runtime();
+ runtime.set_active_tool(ActiveTool::Point).unwrap();
+ let point_classes = visible_palette(&runtime);
+ assert_eq!(
+ point_classes
+ .iter()
+ .map(|class| class.0.as_str())
+ .collect::>(),
+ vec!["cell", "nucleus"]
+ );
+ runtime.set_active_tool(ActiveTool::Polygon).unwrap();
+ assert!(visible_palette(&runtime)
+ .iter()
+ .all(|class| !matches!(class.0.as_str(), "cell" | "nucleus")));
+}
diff --git a/apps/dicom-viewer/src/app/ui/pathology_workspace/tool_rail.rs b/apps/dicom-viewer/src/app/ui/pathology_workspace/tool_rail.rs
new file mode 100644
index 0000000..2546ec9
--- /dev/null
+++ b/apps/dicom-viewer/src/app/ui/pathology_workspace/tool_rail.rs
@@ -0,0 +1,130 @@
+use super::*;
+
+pub(in crate::app) fn show_tool_rail(
+ ui: &mut egui::Ui,
+ runtime: &mut WorkspaceRuntime,
+) -> Option {
+ let mut error = None;
+ Panel::left("pathology-tool-rail")
+ .exact_size(TOOL_RAIL_WIDTH)
+ .frame(chrome_frame(theme::CHROME, Margin::symmetric(6, 8)))
+ .show_inside(ui, |ui| {
+ ui.vertical_centered(|ui| {
+ ui.label(
+ RichText::new("TOOLS")
+ .size(9.0)
+ .color(theme::TEXT_DIM)
+ .strong(),
+ );
+ ui.add_space(3.0);
+ for tool in ActiveTool::ALL {
+ let selected = runtime.active_tool() == tool;
+ let response = ui
+ .add_sized([40.0, 34.0], egui::Button::selectable(selected, ""))
+ .on_hover_text(format!("{} ({})", tool.label(), tool.shortcut()));
+ let icon_color = if selected {
+ theme::CANVAS_EDGE
+ } else if response.hovered() {
+ theme::AMBER_BRIGHT
+ } else {
+ theme::TEXT
+ };
+ ui.painter().extend(tool_icon_shapes(
+ tool,
+ response.rect.shrink(7.0),
+ icon_color,
+ ));
+ if response.clicked() {
+ if tool == ActiveTool::Brush {
+ if let Err(err) = runtime.ensure_segmentation_layer() {
+ error = Some(err.to_string());
+ continue;
+ }
+ }
+ if runtime.request_tool(tool) == ToolTransitionOutcome::BlockedByDraft {
+ error = Some(
+ "Unfinished polygon: choose Resume, Finish, or Discard.".into(),
+ );
+ }
+ }
+ }
+ ui.add_space(5.0);
+ ui.separator();
+ if ui
+ .button(RichText::new("N").monospace().strong())
+ .on_hover_text("New independent finding / segment (N)")
+ .clicked()
+ {
+ runtime.begin_new_segment();
+ }
+ });
+ });
+ error
+}
+
+fn tool_icon_shapes(tool: ActiveTool, rect: egui::Rect, color: Color32) -> Vec {
+ let center = rect.center();
+ let radius = rect.width().min(rect.height()) * 0.42;
+ let stroke = Stroke::new(1.8, color);
+ let point = |x: f32, y: f32| center + egui::vec2(x * radius, y * radius);
+
+ match tool {
+ ActiveTool::Pan => vec![
+ egui::Shape::line_segment([point(-1.0, 0.0), point(1.0, 0.0)], stroke),
+ egui::Shape::line_segment([point(0.0, -1.0), point(0.0, 1.0)], stroke),
+ egui::Shape::line_segment([point(-1.0, 0.0), point(-0.65, -0.28)], stroke),
+ egui::Shape::line_segment([point(-1.0, 0.0), point(-0.65, 0.28)], stroke),
+ egui::Shape::line_segment([point(1.0, 0.0), point(0.65, -0.28)], stroke),
+ egui::Shape::line_segment([point(1.0, 0.0), point(0.65, 0.28)], stroke),
+ egui::Shape::line_segment([point(0.0, -1.0), point(-0.28, -0.65)], stroke),
+ egui::Shape::line_segment([point(0.0, -1.0), point(0.28, -0.65)], stroke),
+ egui::Shape::line_segment([point(0.0, 1.0), point(-0.28, 0.65)], stroke),
+ egui::Shape::line_segment([point(0.0, 1.0), point(0.28, 0.65)], stroke),
+ ],
+ ActiveTool::Select => vec![egui::Shape::closed_line(
+ vec![
+ point(-0.78, -0.92),
+ point(-0.66, 0.78),
+ point(-0.18, 0.31),
+ point(0.29, 0.95),
+ point(0.65, 0.69),
+ point(0.19, 0.08),
+ point(0.83, -0.02),
+ ],
+ stroke,
+ )],
+ ActiveTool::Polygon => vec![egui::Shape::closed_line(
+ vec![point(0.0, -0.9), point(0.9, 0.75), point(-0.9, 0.75)],
+ stroke,
+ )],
+ ActiveTool::Brush => vec![egui::Shape::circle_filled(center, radius * 0.66, color)],
+ ActiveTool::Point => vec![
+ egui::Shape::line_segment([point(-0.9, 0.0), point(0.9, 0.0)], stroke),
+ egui::Shape::line_segment([point(0.0, -0.9), point(0.0, 0.9)], stroke),
+ egui::Shape::circle_filled(center, radius * 0.16, color),
+ ],
+ ActiveTool::Ruler => vec![
+ egui::Shape::line_segment([point(-0.75, 0.75), point(0.75, -0.75)], stroke),
+ egui::Shape::line_segment([point(-0.98, 0.5), point(-0.5, 0.98)], stroke),
+ egui::Shape::line_segment([point(0.5, -0.98), point(0.98, -0.5)], stroke),
+ ],
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_tool_icon_is_vector_geometry_not_font_text() {
+ let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(24.0, 24.0));
+
+ for tool in ActiveTool::ALL {
+ let shapes = tool_icon_shapes(tool, rect, theme::TEXT);
+ assert!(!shapes.is_empty(), "{} needs a visible icon", tool.label());
+ assert!(shapes
+ .iter()
+ .all(|shape| !matches!(shape, egui::Shape::Text(_))));
+ }
+ }
+}
diff --git a/apps/dicom-viewer/src/app/viewport_export.rs b/apps/dicom-viewer/src/app/viewport_export.rs
new file mode 100644
index 0000000..557d9ab
--- /dev/null
+++ b/apps/dicom-viewer/src/app/viewport_export.rs
@@ -0,0 +1,305 @@
+use std::fs::File;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use eframe::egui;
+
+use super::export_job::WorkspaceExportKind;
+use super::workspace_actions::ensure_export_not_cancelled;
+use super::DicomViewerApp;
+
+#[derive(Debug)]
+struct ScreenshotRequest;
+
+pub(super) struct PendingViewportExport {
+ destination: PathBuf,
+ canvas_rect: egui::Rect,
+ viewport_rect: egui::Rect,
+ screenshot_request: egui::UserData,
+ request_sent: bool,
+}
+
+impl PendingViewportExport {
+ fn new(destination: PathBuf, canvas_rect: egui::Rect, viewport_rect: egui::Rect) -> Self {
+ Self {
+ destination,
+ canvas_rect,
+ viewport_rect,
+ screenshot_request: egui::UserData::new(ScreenshotRequest),
+ request_sent: false,
+ }
+ }
+
+ pub(super) fn update_canvas(&mut self, canvas_rect: egui::Rect, viewport_rect: egui::Rect) {
+ self.canvas_rect = canvas_rect;
+ self.viewport_rect = viewport_rect;
+ }
+
+ fn request_if_needed(&mut self, ctx: &egui::Context) {
+ if self.request_sent {
+ return;
+ }
+ ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(
+ self.screenshot_request.clone(),
+ ));
+ self.request_sent = true;
+ }
+
+ fn matching_image(&self, ctx: &egui::Context) -> Option> {
+ ctx.input(|input| {
+ input.events.iter().find_map(|event| match event {
+ egui::Event::Screenshot {
+ viewport_id,
+ user_data,
+ image,
+ } if *viewport_id == egui::ViewportId::ROOT
+ && user_data == &self.screenshot_request =>
+ {
+ Some(Arc::clone(image))
+ }
+ _ => None,
+ })
+ })
+ }
+}
+
+pub(super) fn should_draw_canvas_hud(capture_pending: bool) -> bool {
+ !capture_pending
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct CapturedView {
+ width: u32,
+ height: u32,
+ rgb: Vec,
+}
+
+impl DicomViewerApp {
+ pub(super) fn begin_current_view_tiff_export(&mut self, ctx: &egui::Context) {
+ if self.workspace_export_job.is_some() || self.pending_viewport_export.is_some() {
+ self.status = "Another export is already running.".into();
+ return;
+ }
+ let Some(canvas_rect) = self.last_canvas_rect else {
+ self.status = "The current slide view is not ready to capture.".into();
+ return;
+ };
+ let default_name = self
+ .active_path
+ .as_deref()
+ .and_then(Path::file_stem)
+ .and_then(|stem| stem.to_str())
+ .map_or_else(
+ || "current-view.tiff".to_owned(),
+ |stem| format!("{stem}-current-view.tiff"),
+ );
+ let Some(destination) =
+ self.choose_export_path("TIFF image", &["tif", "tiff"], &default_name)
+ else {
+ return;
+ };
+ self.pending_viewport_export = Some(PendingViewportExport::new(
+ destination,
+ canvas_rect,
+ ctx.viewport_rect(),
+ ));
+ self.status = "Preparing the current slide view for TIFF export…".into();
+ ctx.request_repaint();
+ }
+
+ pub(super) fn poll_current_view_tiff_export(&mut self, ctx: &egui::Context) {
+ let image = self.pending_viewport_export.as_mut().and_then(|pending| {
+ let image = pending.matching_image(ctx);
+ pending.request_if_needed(ctx);
+ image
+ });
+ let Some(image) = image else {
+ return;
+ };
+ let pending = self
+ .pending_viewport_export
+ .take()
+ .expect("a matching screenshot requires a pending export");
+ let view = match crop_screenshot(image.as_ref(), pending.viewport_rect, pending.canvas_rect)
+ {
+ Ok(view) => view,
+ Err(error) => {
+ self.status = format!("Could not capture the current slide view: {error}");
+ return;
+ }
+ };
+ self.start_workspace_export(
+ pending.destination,
+ WorkspaceExportKind::CurrentViewTiff,
+ ctx,
+ move |temporary, cancellation| {
+ ensure_export_not_cancelled(cancellation)?;
+ write_tiff(temporary, &view)?;
+ ensure_export_not_cancelled(cancellation)
+ },
+ );
+ }
+}
+
+fn crop_screenshot(
+ screenshot: &egui::ColorImage,
+ viewport_rect: egui::Rect,
+ canvas_rect: egui::Rect,
+) -> Result {
+ if screenshot.size[0].checked_mul(screenshot.size[1]) != Some(screenshot.pixels.len()) {
+ return Err("the screenshot pixel count does not match its dimensions".into());
+ }
+ if [
+ viewport_rect.min.x,
+ viewport_rect.min.y,
+ viewport_rect.max.x,
+ viewport_rect.max.y,
+ canvas_rect.min.x,
+ canvas_rect.min.y,
+ canvas_rect.max.x,
+ canvas_rect.max.y,
+ ]
+ .iter()
+ .any(|value| !value.is_finite())
+ {
+ return Err("the viewport or canvas bounds are invalid".into());
+ }
+ if viewport_rect.width() <= 0.0 || viewport_rect.height() <= 0.0 {
+ return Err("the viewport dimensions are invalid".into());
+ }
+
+ let image_width = screenshot.size[0];
+ let image_height = screenshot.size[1];
+ let scale_x = image_width as f32 / viewport_rect.width();
+ let scale_y = image_height as f32 / viewport_rect.height();
+ let min_x = ((canvas_rect.min.x - viewport_rect.min.x) * scale_x)
+ .floor()
+ .clamp(0.0, image_width as f32) as usize;
+ let min_y = ((canvas_rect.min.y - viewport_rect.min.y) * scale_y)
+ .floor()
+ .clamp(0.0, image_height as f32) as usize;
+ let max_x = ((canvas_rect.max.x - viewport_rect.min.x) * scale_x)
+ .ceil()
+ .clamp(0.0, image_width as f32) as usize;
+ let max_y = ((canvas_rect.max.y - viewport_rect.min.y) * scale_y)
+ .ceil()
+ .clamp(0.0, image_height as f32) as usize;
+ if max_x <= min_x || max_y <= min_y {
+ return Err("the slide canvas is outside the captured window".into());
+ }
+
+ let width = max_x - min_x;
+ let height = max_y - min_y;
+ let rgb_capacity = width
+ .checked_mul(height)
+ .and_then(|pixels| pixels.checked_mul(3))
+ .ok_or_else(|| "the TIFF dimensions overflow memory limits".to_owned())?;
+ let mut rgb = Vec::with_capacity(rgb_capacity);
+ for y in min_y..max_y {
+ let row_start = y * image_width + min_x;
+ for pixel in &screenshot.pixels[row_start..row_start + width] {
+ let [red, green, blue, _] = pixel.to_array();
+ rgb.extend_from_slice(&[red, green, blue]);
+ }
+ }
+ Ok(CapturedView {
+ width: u32::try_from(width)
+ .map_err(|_| "the TIFF width exceeds the supported range".to_owned())?,
+ height: u32::try_from(height)
+ .map_err(|_| "the TIFF height exceeds the supported range".to_owned())?,
+ rgb,
+ })
+}
+
+fn write_tiff(path: &Path, view: &CapturedView) -> Result<(), String> {
+ let expected = usize::try_from(view.width)
+ .ok()
+ .and_then(|width| {
+ usize::try_from(view.height)
+ .ok()
+ .and_then(|height| width.checked_mul(height))
+ })
+ .and_then(|pixels| pixels.checked_mul(3))
+ .ok_or_else(|| "the TIFF dimensions overflow memory limits".to_owned())?;
+ if view.rgb.len() != expected {
+ return Err("the TIFF pixel count does not match its dimensions".into());
+ }
+ let mut file = File::create(path)
+ .map_err(|error| format!("Could not create TIFF temporary file: {error}"))?;
+ {
+ let mut encoder = tiff::encoder::TiffEncoder::new(&mut file)
+ .map_err(|error| format!("Could not initialize TIFF encoder: {error}"))?;
+ encoder
+ .write_image::(view.width, view.height, &view.rgb)
+ .map_err(|error| format!("Could not encode TIFF pixels: {error}"))?;
+ }
+ file.sync_all()
+ .map_err(|error| format!("Could not flush TIFF output: {error}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use eframe::egui::{self, Color32};
+
+ use super::*;
+
+ #[test]
+ fn screenshot_crop_uses_physical_pixels_and_excludes_surrounding_ui() {
+ let pixels = (0_u8..48)
+ .map(|value| Color32::from_rgb(value, value.saturating_add(1), value.saturating_add(2)))
+ .collect();
+ let screenshot = egui::ColorImage::new([8, 6], pixels);
+ let viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(4.0, 3.0));
+ let canvas = egui::Rect::from_min_max(egui::pos2(1.0, 1.0), egui::pos2(3.0, 2.0));
+
+ let cropped = crop_screenshot(&screenshot, viewport, canvas).unwrap();
+
+ assert_eq!((cropped.width, cropped.height), (4, 2));
+ assert_eq!(cropped.rgb.len(), 4 * 2 * 3);
+ assert_eq!(cropped.rgb[0..3], [18, 19, 20]);
+ assert_eq!(cropped.rgb[cropped.rgb.len() - 3..], [29, 30, 31]);
+ }
+
+ #[test]
+ fn screenshot_crop_is_relative_to_the_native_viewport_origin() {
+ let pixels = (0_u8..48)
+ .map(|value| Color32::from_rgb(value, value, value))
+ .collect();
+ let screenshot = egui::ColorImage::new([8, 6], pixels);
+ let viewport = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(4.0, 3.0));
+ let canvas = egui::Rect::from_min_max(egui::pos2(101.0, 51.0), egui::pos2(103.0, 52.0));
+
+ let cropped = crop_screenshot(&screenshot, viewport, canvas).unwrap();
+
+ assert_eq!((cropped.width, cropped.height), (4, 2));
+ assert_eq!(cropped.rgb[0..3], [18, 18, 18]);
+ assert_eq!(cropped.rgb[cropped.rgb.len() - 3..], [29, 29, 29]);
+ }
+
+ #[test]
+ fn current_view_capture_hides_the_diagnostic_canvas_hud() {
+ assert!(should_draw_canvas_hud(false));
+ assert!(!should_draw_canvas_hud(true));
+ }
+
+ #[test]
+ fn current_view_writer_emits_a_readable_rgb_tiff() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("view.tiff");
+ let view = CapturedView {
+ width: 2,
+ height: 1,
+ rgb: vec![255, 0, 0, 0, 127, 255],
+ };
+
+ write_tiff(&path, &view).unwrap();
+
+ let mut decoder = tiff::decoder::Decoder::new(std::fs::File::open(path).unwrap()).unwrap();
+ assert_eq!(decoder.dimensions().unwrap(), (2, 1));
+ assert_eq!(decoder.colortype().unwrap(), tiff::ColorType::RGB(8));
+ let tiff::decoder::DecodingResult::U8(decoded) = decoder.read_image().unwrap() else {
+ panic!("RGB8 TIFF should decode to eight-bit samples");
+ };
+ assert_eq!(decoded, view.rgb);
+ }
+}
diff --git a/apps/dicom-viewer/src/app/workspace.rs b/apps/dicom-viewer/src/app/workspace.rs
index 0970487..5e4ce71 100644
--- a/apps/dicom-viewer/src/app/workspace.rs
+++ b/apps/dicom-viewer/src/app/workspace.rs
@@ -1,9 +1,14 @@
+mod external;
mod external_overlay;
+mod geometry;
mod history;
mod overlay;
mod persistence;
mod scheme_library;
+mod selection;
mod spatial;
+mod tools;
+mod transaction;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
@@ -83,6 +88,7 @@ pub(super) struct ExternalObjectDescriptor {
pub(in crate::app) class_key: String,
pub(in crate::app) promoted: bool,
pub(in crate::app) promotable: bool,
+ pub(in crate::app) promotion_block_reason: Option,
}
#[derive(Debug, Clone)]
@@ -163,6 +169,7 @@ struct PreparedExternalObject {
class_key: String,
geometry: Option,
promotion: Option,
+ promotion_block_reason: Option,
}
impl ActiveTool {
@@ -337,455 +344,6 @@ pub(super) struct WorkspaceRuntime {
}
impl WorkspaceRuntime {
- pub(super) fn new(
- source_identity: ViewerSourceIdentity,
- scheme: AnnotationScheme,
- ) -> ViewerResult {
- Self::from_document(WorkspaceDocument::new(source_identity, scheme)?)
- }
-
- #[cfg(test)]
- pub(super) fn with_history_limits(
- source_identity: ViewerSourceIdentity,
- scheme: AnnotationScheme,
- max_commands: usize,
- max_retained_bytes: usize,
- ) -> ViewerResult {
- let mut runtime = Self::new(source_identity, scheme)?;
- runtime.history = WorkspaceHistory::with_limits(max_commands, max_retained_bytes);
- Ok(runtime)
- }
-
- pub(super) fn from_document(document: WorkspaceDocument) -> ViewerResult {
- document.validate()?;
- let active_vector_layer = document.vector_layers()[0].id();
- let document = Arc::new(document);
- let spatial_index = WorkspaceSpatialIndex::build(&document)?;
- let spatial_revision = document.revision();
- let region_class_id = document
- .scheme()
- .classes()
- .iter()
- .find(|class| class.geometry() == dicom_viewer_core::AnnotationClassGeometry::Region)
- .map(|class| class.id().to_owned())
- .ok_or_else(|| ViewerError::InvalidInput("scheme has no region class".into()))?;
- let point_class_id = document
- .scheme()
- .classes()
- .iter()
- .find(|class| class.geometry() == dicom_viewer_core::AnnotationClassGeometry::Point)
- .map(|class| class.id().to_owned())
- .unwrap_or_else(|| region_class_id.clone());
- Ok(Self {
- document,
- history: WorkspaceHistory::default(),
- active_tool: ActiveTool::Select,
- region_class_id,
- point_class_id,
- active_vector_layer,
- active_segmentation_layer: None,
- editing_representation: EditingRepresentation::Vector,
- segment_operation: SegmentOperation::Add,
- brush_diameter: 40.0,
- selection: HashSet::new(),
- draft: None,
- draft_undo: Vec::new(),
- pending_tool: None,
- brush_stroke: None,
- brush_operation: None,
- ruler_start: None,
- spatial_index,
- spatial_revision,
- external_payloads: HashMap::new(),
- handle_drag: None,
- })
- }
-
- #[must_use]
- pub(super) fn document(&self) -> &WorkspaceDocument {
- &self.document
- }
-
- #[must_use]
- pub(super) fn document_snapshot(&self) -> Arc {
- Arc::clone(&self.document)
- }
-
- pub(super) fn edit(
- &mut self,
- label: impl Into,
- edit: impl FnOnce(&mut WorkspaceDocument) -> ViewerResult,
- ) -> ViewerResult {
- let before = Arc::clone(&self.document);
- let mut candidate = (*before).clone();
- let result = edit(&mut candidate)?;
- candidate.validate()?;
- if candidate.revision() != before.revision() {
- let after = Arc::new(candidate);
- self.history
- .record(label, Arc::clone(&before), Arc::clone(&after));
- self.document = after;
- self.draft_undo.clear();
- self.invalidate_spatial_index();
- }
- Ok(result)
- }
-
- pub(super) fn undo(&mut self) -> bool {
- let Some(document) = self.history.undo() else {
- return false;
- };
- self.document = document;
- self.selection.retain(|id| {
- self.document.finding(*id).is_some()
- || self.document.segment(*id).is_some()
- || self.document.measurement(*id).is_some()
- });
- self.invalidate_spatial_index();
- true
- }
-
- pub(super) fn redo(&mut self) -> bool {
- let Some(document) = self.history.redo() else {
- return false;
- };
- self.document = document;
- self.invalidate_spatial_index();
- true
- }
-
- #[must_use]
- pub(super) fn can_undo(&self) -> bool {
- self.history.can_undo()
- }
-
- #[must_use]
- pub(super) fn can_redo(&self) -> bool {
- self.history.can_redo()
- }
-
- #[must_use]
- pub(super) fn history_truncated(&self) -> bool {
- self.history.truncated()
- }
-
- #[must_use]
- pub(super) fn undo_label(&self) -> Option<&str> {
- self.history.undo_label()
- }
-
- #[must_use]
- pub(super) fn redo_label(&self) -> Option<&str> {
- self.history.redo_label()
- }
-
- #[must_use]
- pub(super) const fn active_tool(&self) -> ActiveTool {
- self.active_tool
- }
-
- #[cfg(test)]
- pub(super) fn set_active_tool(&mut self, tool: ActiveTool) -> ViewerResult<()> {
- match self.request_tool(tool) {
- ToolTransitionOutcome::Applied => Ok(()),
- ToolTransitionOutcome::BlockedByDraft => Err(ViewerError::InvalidInput(
- "finish, resume, or discard the current polygon before switching tools".into(),
- )),
- }
- }
-
- pub(super) fn request_tool(&mut self, tool: ActiveTool) -> ToolTransitionOutcome {
- if self.active_tool == tool {
- return ToolTransitionOutcome::Applied;
- }
- if self.draft.is_some() {
- self.pending_tool = Some(tool);
- return ToolTransitionOutcome::BlockedByDraft;
- }
- self.active_tool = tool;
- self.draft_undo.clear();
- if matches!(tool, ActiveTool::Point | ActiveTool::Ruler) {
- self.editing_representation = EditingRepresentation::Vector;
- }
- self.pending_tool = None;
- ToolTransitionOutcome::Applied
- }
-
- pub(super) fn resolve_draft_transition(
- &mut self,
- resolution: DraftResolution,
- ) -> ViewerResult<()> {
- match resolution {
- DraftResolution::Resume => {
- self.pending_tool = None;
- }
- DraftResolution::Finish => {
- self.finish_draft()?;
- if let Some(tool) = self.pending_tool.take() {
- self.active_tool = tool;
- }
- }
- DraftResolution::Discard => {
- self.draft = None;
- self.draft_undo.clear();
- if let Some(tool) = self.pending_tool.take() {
- self.active_tool = tool;
- }
- }
- }
- Ok(())
- }
-
- pub(super) fn finish_draft(&mut self) -> ViewerResult {
- let draft = self
- .draft
- .clone()
- .ok_or_else(|| ViewerError::InvalidInput("there is no polygon draft".into()))?;
- let target_layer = match draft.target {
- DraftTarget::Vector { layer_id } | DraftTarget::Segment { layer_id, .. } => layer_id,
- };
- self.ensure_layer_editable(target_layer)?;
- let result = match draft.target {
- DraftTarget::Vector { layer_id } => self.edit("Add polygon finding", |document| {
- document.add_vector_finding(
- layer_id,
- &draft.class_id,
- dicom_viewer_core::VectorFindingGeometry::regions(vec![draft.points]),
- )
- })?,
- DraftTarget::Segment {
- layer_id,
- segment_id,
- operation,
- } => {
- let primitive = SegmentationPrimitive::polygon(operation, draft.points);
- if let Some(segment_id) = segment_id {
- self.edit("Edit segment", |document| {
- document.apply_segment_primitive(segment_id, primitive)?;
- Ok(segment_id)
- })?
- } else {
- self.edit("Add segment", |document| {
- document.add_segment(layer_id, &draft.class_id, primitive)
- })?
- }
- }
- };
- self.draft = None;
- self.select_only(result);
- Ok(result)
- }
-
- #[must_use]
- pub(super) fn draft(&self) -> Option<&DraftInteraction> {
- self.draft.as_ref()
- }
-
- pub(super) fn set_draft(&mut self, draft: DraftInteraction) {
- self.draft = Some(draft);
- self.draft_undo.clear();
- }
-
- pub(super) fn add_polygon_point(&mut self, point: Point2) -> ViewerResult<()> {
- self.draft_undo.clear();
- if let Some(draft) = &mut self.draft {
- draft.push_point(point);
- return Ok(());
- }
- let draft = match self.editing_representation {
- EditingRepresentation::Vector => {
- self.ensure_layer_editable(self.active_vector_layer)?;
- DraftInteraction::vector_polygon(
- self.active_vector_layer,
- self.region_class_id.clone(),
- vec![point],
- )
- }
- EditingRepresentation::Segmentation => {
- let layer_id = self.ensure_segmentation_layer()?;
- self.ensure_layer_editable(layer_id)?;
- let segment_id = self.selected_segment();
- if self.segment_operation == SegmentOperation::Erase && segment_id.is_none() {
- return Err(ViewerError::InvalidInput(
- "select a segment before using Erase".into(),
- ));
- }
- DraftInteraction::segment_polygon(
- layer_id,
- segment_id,
- self.segment_operation,
- self.region_class_id.clone(),
- vec![point],
- )
- }
- };
- self.draft = Some(draft);
- Ok(())
- }
-
- pub(super) fn add_point_finding(&mut self, point: Point2) -> ViewerResult {
- let layer = self.active_vector_layer;
- self.ensure_layer_editable(layer)?;
- let class_id = self.point_class_id.clone();
- let id = self.edit("Add point finding", |document| {
- document.add_vector_finding(
- layer,
- &class_id,
- dicom_viewer_core::VectorFindingGeometry::Point(point),
- )
- })?;
- self.select_only(id);
- Ok(id)
- }
-
- pub(super) fn begin_brush_stroke_with_operation(
- &mut self,
- point: Point2,
- operation: SegmentOperation,
- ) -> ViewerResult<()> {
- let layer = self.ensure_segmentation_layer()?;
- self.ensure_layer_editable(layer)?;
- if operation == SegmentOperation::Erase && self.selected_segment().is_none() {
- return Err(ViewerError::InvalidInput(
- "select a segment before using Erase".into(),
- ));
- }
- self.brush_stroke = Some(vec![point]);
- self.brush_operation = Some(operation);
- Ok(())
- }
-
- pub(super) fn extend_brush_stroke(&mut self, point: Point2) {
- if let Some(stroke) = &mut self.brush_stroke {
- let minimum_step = (self.brush_diameter * 0.08).max(0.5);
- if stroke.last().is_none_or(|last| {
- let dx = point.x - last.x;
- let dy = point.y - last.y;
- dx.hypot(dy) >= minimum_step
- }) {
- stroke.push(point);
- }
- }
- }
-
- pub(super) fn finish_brush_stroke(&mut self) -> ViewerResult