From 38c6df8a8ab668b36a6677a840b4bfce18c6f4ea Mon Sep 17 00:00:00 2001 From: GF Date: Fri, 4 Sep 2026 20:46:08 -0400 Subject: [PATCH 1/3] Refine pathology workspace and extract the headless annotation CLI --- .cargo/config.toml | 2 + .github/workflows/ci.yml | 16 - .gitignore | 52 + Cargo.lock | 61 +- Cargo.toml | 10 +- README.md | 42 +- apps/dicom-viewer/Cargo.toml | 8 +- apps/dicom-viewer/assets/app-icon.ico | Bin 0 -> 7905 bytes apps/dicom-viewer/assets/app-icon.png | Bin 0 -> 7365 bytes apps/dicom-viewer/assets/app-icon.svg | 7 + apps/dicom-viewer/build.rs | 27 + apps/dicom-viewer/src/app.rs | 331 +-- apps/dicom-viewer/src/app/camera.rs | 104 +- apps/dicom-viewer/src/app/canvas.rs | 42 + apps/dicom-viewer/src/app/canvas_frame.rs | 235 ++ apps/dicom-viewer/src/app/export_job.rs | 2 + apps/dicom-viewer/src/app/tests.rs | 97 +- apps/dicom-viewer/src/app/theme.rs | 41 +- apps/dicom-viewer/src/app/ui/chrome.rs | 29 +- .../src/app/ui/pathology_workspace.rs | 1006 +------ .../app/ui/pathology_workspace/findings.rs | 155 ++ .../app/ui/pathology_workspace/inspector.rs | 210 ++ .../src/app/ui/pathology_workspace/layers.rs | 128 + .../ui/pathology_workspace/layers/external.rs | 311 +++ .../src/app/ui/pathology_workspace/palette.rs | 111 + .../src/app/ui/pathology_workspace/tests.rs | 58 + .../app/ui/pathology_workspace/tool_rail.rs | 130 + apps/dicom-viewer/src/app/viewport_export.rs | 305 +++ apps/dicom-viewer/src/app/workspace.rs | 1721 +----------- .../src/app/workspace/external.rs | 724 +++++ .../src/app/workspace/geometry.rs | 295 ++ .../src/app/workspace/selection.rs | 132 + apps/dicom-viewer/src/app/workspace/tests.rs | 60 + apps/dicom-viewer/src/app/workspace/tools.rs | 453 +++ .../src/app/workspace/transaction.rs | 142 + .../dicom-viewer/src/app/workspace_actions.rs | 3 + .../dicom-viewer/src/app/workspace_dialogs.rs | 15 +- .../src/app/workspace_interaction.rs | 300 +- .../src/app/workspace_interaction/keyboard.rs | 109 + .../src/app/workspace_interaction/pointer.rs | 227 ++ .../src/app/workspace_interaction/tests.rs | 100 + apps/dicom-viewer/src/bin/annotation_probe.rs | 30 - .../src/bin/annotation_probe/command.rs | 142 - .../bin/annotation_probe/conversion_report.rs | 583 ---- .../conversion_report/checksum.rs | 139 - .../bin/annotation_probe/convert_geojson.rs | 586 ---- .../bin/annotation_probe/convert_raster.rs | 598 ---- .../src/bin/annotation_probe/legacy/mod.rs | 385 --- .../bin/annotation_probe/legacy/report/mod.rs | 514 ---- .../src/bin/annotation_probe/legacy/schema.rs | 247 -- .../src/bin/annotation_probe/legacy/tests.rs | 357 --- .../src/bin/annotation_probe/publication.rs | 48 - apps/dicom-viewer/src/main.rs | 19 +- .../tests/annotation_probe_cli.rs | 80 - crates/dicom-viewer-core/Cargo.toml | 5 +- .../src/annotation_test_support.rs | 39 +- .../dicom-viewer-core/src/annotations/mod.rs | 14 +- .../src/annotations/workspace.rs | 6 +- .../annotations/workspace/compatibility.rs | 36 +- .../src/annotations/workspace/document.rs | 569 +--- .../annotations/workspace/document/layers.rs | 148 + .../annotations/workspace/document/object.rs | 326 +++ .../workspace/document/validation.rs | 176 ++ .../src/annotations/workspace/export.rs | 184 +- .../src/annotations/workspace/export/ann.rs | 54 + .../annotations/workspace/export/bulk_ann.rs | 298 ++ .../src/annotations/workspace/export/seg.rs | 108 + .../annotations/workspace/export/shared.rs | 101 + .../src/annotations/workspace/model.rs | 32 + .../dicom-viewer-core/src/inspection/dicom.rs | 330 +-- .../src/inspection/dicom_tests.rs | 95 - crates/dicom-viewer-core/src/lib.rs | 13 +- .../src/workspace_export_tests.rs | 480 +++- .../dicom-viewer-core/src/workspace_tests.rs | 83 +- docs/DICOM_NATIVE_CONVERSION.md | 9 +- docs/PATHOLOGY_PERFORMANCE.md | 5 +- docs/RELEASE.md | 24 +- docs/WORKSPACE_STORAGE.md | 5 +- docs/refactor/BASELINE.md | 179 -- docs/refactor/DECISIONS.md | 64 - docs/refactor/DUPLICATION_INVENTORY.md | 33 - docs/refactor/FAILURE_MATRIX.md | 36 - docs/refactor/INVARIANTS.md | 51 - docs/refactor/MANUAL_ACCEPTANCE.md | 102 - docs/refactor/MASTER_PLAN.md | 322 --- docs/refactor/PERFORMANCE.md | 85 - docs/refactor/STATUS.md | 27 - docs/refactor/TILE_STATE_MODEL.md | 151 - reasonix.toml | 82 - vendor/epaint/Cargo.toml | 407 +++ vendor/epaint/LICENSE | 5 + vendor/epaint/PATCHES.md | 23 + vendor/epaint/README.md | 11 + vendor/epaint/benches/benchmark.rs | 293 ++ vendor/epaint/src/brush.rs | 19 + vendor/epaint/src/color.rs | 48 + vendor/epaint/src/corner_radius.rs | 250 ++ vendor/epaint/src/corner_radius_f32.rs | 236 ++ vendor/epaint/src/direction.rs | 27 + vendor/epaint/src/image.rs | 450 +++ vendor/epaint/src/lib.rs | 167 ++ vendor/epaint/src/margin.rs | 280 ++ vendor/epaint/src/margin_f32.rs | 302 ++ vendor/epaint/src/mesh.rs | 359 +++ vendor/epaint/src/mutex.rs | 277 ++ vendor/epaint/src/shadow.rs | 85 + vendor/epaint/src/shape_transform.rs | 136 + vendor/epaint/src/shapes/bezier_shape.rs | 1137 ++++++++ vendor/epaint/src/shapes/circle_shape.rs | 52 + vendor/epaint/src/shapes/ellipse_shape.rs | 78 + vendor/epaint/src/shapes/mod.rs | 19 + vendor/epaint/src/shapes/paint_callback.rs | 103 + vendor/epaint/src/shapes/path_shape.rs | 81 + vendor/epaint/src/shapes/rect_shape.rs | 223 ++ vendor/epaint/src/shapes/shape.rs | 586 ++++ vendor/epaint/src/shapes/text_shape.rs | 213 ++ vendor/epaint/src/stats.rs | 244 ++ vendor/epaint/src/stroke.rs | 242 ++ vendor/epaint/src/tessellator.rs | 2429 +++++++++++++++++ vendor/epaint/src/text/cursor.rs | 87 + vendor/epaint/src/text/font.rs | 876 ++++++ vendor/epaint/src/text/fonts.rs | 1311 +++++++++ vendor/epaint/src/text/mod.rs | 68 + vendor/epaint/src/text/text_layout.rs | 1281 +++++++++ vendor/epaint/src/text/text_layout_types.rs | 1342 +++++++++ vendor/epaint/src/text/windows_directwrite.rs | 341 +++ vendor/epaint/src/texture_atlas.rs | 278 ++ vendor/epaint/src/texture_handle.rs | 138 + vendor/epaint/src/textures.rs | 332 +++ vendor/epaint/src/util/mod.rs | 12 + vendor/epaint/src/viewport.rs | 54 + 131 files changed, 21438 insertions(+), 9265 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 apps/dicom-viewer/assets/app-icon.ico create mode 100644 apps/dicom-viewer/assets/app-icon.png create mode 100644 apps/dicom-viewer/assets/app-icon.svg create mode 100644 apps/dicom-viewer/build.rs create mode 100644 apps/dicom-viewer/src/app/canvas_frame.rs create mode 100644 apps/dicom-viewer/src/app/ui/pathology_workspace/findings.rs create mode 100644 apps/dicom-viewer/src/app/ui/pathology_workspace/inspector.rs create mode 100644 apps/dicom-viewer/src/app/ui/pathology_workspace/layers.rs create mode 100644 apps/dicom-viewer/src/app/ui/pathology_workspace/layers/external.rs create mode 100644 apps/dicom-viewer/src/app/ui/pathology_workspace/palette.rs create mode 100644 apps/dicom-viewer/src/app/ui/pathology_workspace/tests.rs create mode 100644 apps/dicom-viewer/src/app/ui/pathology_workspace/tool_rail.rs create mode 100644 apps/dicom-viewer/src/app/viewport_export.rs create mode 100644 apps/dicom-viewer/src/app/workspace/external.rs create mode 100644 apps/dicom-viewer/src/app/workspace/geometry.rs create mode 100644 apps/dicom-viewer/src/app/workspace/selection.rs create mode 100644 apps/dicom-viewer/src/app/workspace/tools.rs create mode 100644 apps/dicom-viewer/src/app/workspace/transaction.rs create mode 100644 apps/dicom-viewer/src/app/workspace_interaction/keyboard.rs create mode 100644 apps/dicom-viewer/src/app/workspace_interaction/pointer.rs create mode 100644 apps/dicom-viewer/src/app/workspace_interaction/tests.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/command.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/conversion_report.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/conversion_report/checksum.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/convert_geojson.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/convert_raster.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/legacy/mod.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/legacy/report/mod.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/legacy/schema.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/legacy/tests.rs delete mode 100644 apps/dicom-viewer/src/bin/annotation_probe/publication.rs delete mode 100644 apps/dicom-viewer/tests/annotation_probe_cli.rs create mode 100644 crates/dicom-viewer-core/src/annotations/workspace/document/layers.rs create mode 100644 crates/dicom-viewer-core/src/annotations/workspace/document/object.rs create mode 100644 crates/dicom-viewer-core/src/annotations/workspace/document/validation.rs create mode 100644 crates/dicom-viewer-core/src/annotations/workspace/export/ann.rs create mode 100644 crates/dicom-viewer-core/src/annotations/workspace/export/bulk_ann.rs create mode 100644 crates/dicom-viewer-core/src/annotations/workspace/export/seg.rs create mode 100644 crates/dicom-viewer-core/src/annotations/workspace/export/shared.rs delete mode 100644 docs/refactor/BASELINE.md delete mode 100644 docs/refactor/DECISIONS.md delete mode 100644 docs/refactor/DUPLICATION_INVENTORY.md delete mode 100644 docs/refactor/FAILURE_MATRIX.md delete mode 100644 docs/refactor/INVARIANTS.md delete mode 100644 docs/refactor/MANUAL_ACCEPTANCE.md delete mode 100644 docs/refactor/MASTER_PLAN.md delete mode 100644 docs/refactor/PERFORMANCE.md delete mode 100644 docs/refactor/STATUS.md delete mode 100644 docs/refactor/TILE_STATE_MODEL.md delete mode 100644 reasonix.toml create mode 100644 vendor/epaint/Cargo.toml create mode 100644 vendor/epaint/LICENSE create mode 100644 vendor/epaint/PATCHES.md create mode 100644 vendor/epaint/README.md create mode 100644 vendor/epaint/benches/benchmark.rs create mode 100644 vendor/epaint/src/brush.rs create mode 100644 vendor/epaint/src/color.rs create mode 100644 vendor/epaint/src/corner_radius.rs create mode 100644 vendor/epaint/src/corner_radius_f32.rs create mode 100644 vendor/epaint/src/direction.rs create mode 100644 vendor/epaint/src/image.rs create mode 100644 vendor/epaint/src/lib.rs create mode 100644 vendor/epaint/src/margin.rs create mode 100644 vendor/epaint/src/margin_f32.rs create mode 100644 vendor/epaint/src/mesh.rs create mode 100644 vendor/epaint/src/mutex.rs create mode 100644 vendor/epaint/src/shadow.rs create mode 100644 vendor/epaint/src/shape_transform.rs create mode 100644 vendor/epaint/src/shapes/bezier_shape.rs create mode 100644 vendor/epaint/src/shapes/circle_shape.rs create mode 100644 vendor/epaint/src/shapes/ellipse_shape.rs create mode 100644 vendor/epaint/src/shapes/mod.rs create mode 100644 vendor/epaint/src/shapes/paint_callback.rs create mode 100644 vendor/epaint/src/shapes/path_shape.rs create mode 100644 vendor/epaint/src/shapes/rect_shape.rs create mode 100644 vendor/epaint/src/shapes/shape.rs create mode 100644 vendor/epaint/src/shapes/text_shape.rs create mode 100644 vendor/epaint/src/stats.rs create mode 100644 vendor/epaint/src/stroke.rs create mode 100644 vendor/epaint/src/tessellator.rs create mode 100644 vendor/epaint/src/text/cursor.rs create mode 100644 vendor/epaint/src/text/font.rs create mode 100644 vendor/epaint/src/text/fonts.rs create mode 100644 vendor/epaint/src/text/mod.rs create mode 100644 vendor/epaint/src/text/text_layout.rs create mode 100644 vendor/epaint/src/text/text_layout_types.rs create mode 100644 vendor/epaint/src/text/windows_directwrite.rs create mode 100644 vendor/epaint/src/texture_atlas.rs create mode 100644 vendor/epaint/src/texture_handle.rs create mode 100644 vendor/epaint/src/textures.rs create mode 100644 vendor/epaint/src/util/mod.rs create mode 100644 vendor/epaint/src/viewport.rs 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..c5b09c8 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,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wsi-dicom-annotations" -version = "0.1.1" +version = "0.1.2" dependencies = [ "chrono", "dicom-core", diff --git a/Cargo.toml b/Cargo.toml index 1dc9b8c..7398e33 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 = "=0.1.2" # Temporary security patches for advisories without compatible upstream # releases. See each vendored crate's SECURITY-PATCH.md for 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..3308751 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,28 @@ 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 release gate + +The production manifest uses the exact registry version `wsi-dicom-annotations =0.1.2`. +CI checks out only the viewer. Version 0.1.2 must first be published with the shared +metadata reader, and Cargo.lock must then be refreshed from the registry and checked +with `cargo metadata --locked` and the full standalone CI matrix. This release gate +is currently pending; the local source validation does not prove a standalone build. + +For coordinated development before that release, use an explicit local Cargo overlay: + +```console +cargo --config 'patch.crates-io.wsi-dicom-annotations.path="../wsi-dicom-annotations"' test --workspace --all-targets --locked +``` + +Do not copy this source overlay into release CI or treat its path-based lock entry +as a published dependency checksum. + ### 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 +229,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/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 0000000000000000000000000000000000000000..298d13e46be918e3cfe79e54536eca7877233039 GIT binary patch literal 7905 zcmd6McU)7=v-e2|kbnh3kxm2zq=OWRlz<>0O+h*+y-4Vx34~Atln#PagCa`r0#c(O zQbej$6%YYM>Al~B&#yf1d&{5qb5D}bnX|JqJNup4J+l)4fPkNq3j&~kZVmuo1nCDi zbkt5$u~32JX$^H1y%X-q4^9Ez%sun%!5hp$Nm~g3D&naRtjNJcG1AuexS@k#NK!%E)v4*|2HXs1fZ~zkN1i(P*WDFMY2LNO_ z5P%H4|FJCx`cEzd$RYbTebUikfkgxC*6no57-y`lg|=~X5wWs$v$hlQb8$cE0Lb~F zLDIzzXNB@}!Mb{({p7j+^gx63NwX*y>Q5J(vpkov_6^ikH%~j1w1}997?%PS3WbvM zw6#a;sa*SqIQS&b<%q+%qeVr1eSJlIFNwH$I*5wP%F2q0Nr*~F2!lO@z5HEqR(`^+ zUfh2v`KKNgJ1-kgCwH8an=9%>ua&i%H%^|5>tvvRU4PkW=jZhAkzBq0F)eU{q9+_t zaS<`mf9VEAdSjEZ3&eaRFNI^nM z`X567Kj*)cH2H5O|Hk=;q@3u8di4ZSwg$dunuwF@Wy~JuHG*mhBxeDT0ilgc?st5VE zlou4Ej%Xhk=BXB{U}%xm&jmE{SK5BuXGE47yLidnVgF-oZNuQC z?{M_;)@-`}oV|ksM{_)`wqd>T!+PM8-Jp5@O#esExFJZ?fBNpo(YP1$fA3Jo*9|qb zcFN|K>Rr`)QKDB~l#)x4Qbb{)wkbG^>t1bS>ovV;&-hFj_I>Z7=3CXg&*bk7syt6? zcS}hMzgK)T^Zxr@x~Jw+o2~0~xd1(ndA)@I`P6u}P3AiDyr6Ywt@^vq-!3mjG5HzQ z7lppoJqPja_s$Mw>ciiRIehnUe{X*|j;LkC+;(>$Y1*a1XENcY#iS>j_sjfsS2c6f zwU2@0x+Dg^Ioyq-<)Zv|M0R=H%A({WbpJI;NeLhAuO3TAr2%E0SuRdMms}D-jpQBQ zCAF{I?@i1WhzU-(CGdcrFr6r0E12T1zO+%>8LN0I;HcD_F)Q%v-FAcp&TK^bUUS-#0U zFRmtg1sxwQiwfC7x~Bvf8+=w%h2O?5ryuAq=L1;I-EXmRU5DAH=D++V8bqKV4e$=a zG;68A;gtGxb}ibVG7e7a>l?)w5!dTZEy>e-a>VE9-JBhGCXjNyP*0@5Gf#-P#%Ces zh$vCw76RP(o0r|TNFGm2vb;FcGgycZ zxU`cctG*zWaThzH$t6>1-TUF` z&$ugx?{=xbe)%dSeKi?#0P7R@@zY}V)v3M|F`rTa`tIn-G<4ufl!tjM{Jf`wPsDU0 zYqLoA^0Ct8j(Z1FXCi`Ezhz|*uPkrQhP}5{N2*gmV%RsfN(|-YgDQDDJ`81{j^$+D*LPY|uA!#qCU#>oM zc#?@E&{&q&8#m^Aj)|nCq$<%+PS9?@dUHDS(x8J*GY3zcQ<|l$xTe%4x%%ykoja{c zxdygfO9!u+y{8X~W$eyLa_W`NgY zs=*7>XMJFE%+qre0FBtbw%OZ@@4AA&VR_1d9P(PJ@YA7llSx<(c@jgosE$rdu-aR- znwZN2j0E3#qmP{A7ilt7>t=d8IoT0`90R%@1aX{$Q{P%V=mvD&o05S?&g0Y3V z7xZ%GkHz!${eCIY!574ZM()Vb@pmp5Xn9duXz@r?$aIN^sKZFCi;p2KD2CVkMO*d? zvVvWaGNS#703wHh5VBkvs4xmVjX@Mol<#WMSCI)a5~eKzmQ#njy#~TpU0QQGF9{9? z3`{$eBm)Q`Zq(ppWtSA6{(TewuP8!;jr+_C-;Ymr9VIZMsY-#dHFjF7B&wED?1-85 zUUPI{?)&_A1$2Y)?M9Caf)rO32f|++8h_QC*)HPUOC~Uf zczNcwogVCcvt=1puG&&wa&VOz;P{QGtl{jz(?`X@)KQlqqo=U%M>^M~o(S(zU^!4j zOoVOqvM#xPxz27i@u~UET~W?&GDuVi0mf*Xda8;HFGy*3&wZJJRVFq#aPRqQ>(zD} z;A^+sy-(+v*9R4P;|rQ~6;tIIR&-R*px6yqxvBeNy%H_tkd#wgBwKO0%qPA1R$Co4 zL1uS{S&?x!D|0uzowKlu`2kFXj&!>EuI66)rP1fxsU_Vv$$~E3h1{B)ejKE)^CC9V z`_KeoVAkRlsz``#{C`*rQH)>m=IA!x_t*L;UDoL=dYco+HpWLOM>^up2% zO*zq~>Ems+a0sO)Y%jRxxfbeL{&ZU|P>9 zFdLLjXZ#Gw{B*xymmEpZobdkiO)^k9{UdSSnn)oghk?4F0JE^3pIOA7;ct6X81G!2 z!`ne$&2((9o;d+)i;h3Kgxpv_?p~+QE9Nx@!h%Rs$shsQV{v%|4FrMXaPa||>(1mm zV=kdYm%VwSrlEtJ1=0{;UPM(YAK`I!$Rh37AQG|HH*E&w{XTFo-ZOWD`i z{fo)J^C1#2La+m@MDAx%nQnD`NP^1)H&@0wpO~;%-6U@%Omk92|2iSosr;5eBEGan2y&xd68mM0&8^-6Bg`C3=fV1L>1h9HCXMoJo z@vSvE88{)?>)K;VlcOS`Oy)oQcmmNyx@wUEk^mu{x9Gnf_}eJ*1T1kVzGm!04J!=e z*|NQ*iQ2HQe!P&UOGIX#A>B{bgMorI1ue6<2@25>ZHyQv@-mK6! zFp2*a_c$VU{mK(lj@?++p;sd=$qs3zrB)9wY05n&hm~7wXBqdFuTZ*TPtG$4*|uNRm{fv#7?$NoiJ>h6g}?#3yA90o$P4VpH!Gg5?>G zTxB5UUUge~b1c{fCE)o!QCt71*f=PejY_C-E=zZjfn)eErQfsC7IL{mbZE+_$2FS( z%Z!LP7+Q%G;Vild?R)TWURHy|2&D(i?k3HmLTF(8!1)|;D#WNV0cxqKUCG8n6AF-{ zV3OC)L(Jee0BnIqY8XtJCI<*7wDa>=QbVW+!cv`{2^ct(oWhuq5^ zn0pV1Lc<9TEk4*Wt$)G1C<1DvBH=Z_Q)_klXscN1lYqeB6| z&y2bo@K&F4+?rtt0R23RF5;1VY(LhOPOj_fc&nrBjYnB%xwD|OYui^BY3$@9^M$SX zQ~E(ni|+}Xdk4~z`;Uh+q@Xth=>>lD9gp_Q6{S2N^7M&74RMfh^2nb_gvAq_M#Q?+ zpe&$IPP5%h{6E}O9KdJ!nL!$7cl+?jZL=sdVt?p`pl4xOX2$qObedUye!RlPf;+k& z3xap!O{BaFM?Vgl5T(gcwhZ%?*X0AZbN0eADR1%4glpaJ*Fs<{Bc*4XT9yyC2zx(S zFDt?lwwCM!BYa8nIqfxqk8yn(h^qi``yl;ya%WPR?_L{Luf3= zZl8vn_QkUV!`)pThSp=U`_k!h049m6MDVpBR5Og-PV+AzmQmWgnvi9R^p0v3`mW z9FYdjC+(8WDP(yBxOg%OZI#qXSs<0gY?DBc0w*zi>jJehbPlBcN7JkftliM{wn)n_ zX(QC+qy%Q^7tb|$A;Yo$O*ep3=~hOje`nS_FTG{nYJBReD>R>O=yWzSI3;FLH^qBc z4eiH5(EQ{A{dcx@0_3ls0^#{QztxV3%N2q&Pz$qXIU+asaDQ3T81$op^HA}4LEnc` zc_mtaD&sJpY)G|$&7hE2hO0|A?bvH4-`>$2TzI+f&}89MA%0=&#mv^N^C1*55d{h{ zTQ;P#L3rtXvQTH*7-%0$ zaxIM$8I=fbVAn?F7!W+MAmt?W_8KO&~Sr$Q%t$P08*U=qz3MW@174~0v%`_-lLz0833J{WXS#DETT;v-0Qfp zPaeWRbD#;;ELM1R)HQHVWU^Yn#giZjQvaiglL#j6-(oLWMm=N4w;E~-dK$L#4Z>J& z8R@#E*;+?e`FY&7NK?qw;gHLTJDMoEJGVnwk)6pW@h9XMHDk0yUbY{t=nid7EZx%K zl|L;7A2OvCK3J@q`L)ndv_G`7u4tHo+`bhv`&L{IK~ADEW_x3G(6W9mzCb&-Vdk>M z#7D?9OY@2NiP>I(XDkC7?f@N@V9UVO-!lBnse4Muk*^ zVe!AO@LD=-Yq;vun0Zb0Ff$04RAj7_i?{S+;S%p*6i})+Tg})W5olv%VV2!vr+GD7 z>Q9~&Nk=s3U7KJEI zVC^SPLvz@qO1kaaonoF*>neIdQ}i^Nwp!IbeHRpcS$~CKuh>PX%+bOvUYK&u)W%*J zNw{DH!wh!y+E!gpVPt)_im8TKin+<}bC+EqSa?@d@NNxj?roW-Ewnl+QXKTnJ5(u- z|5ARO;gDGmlrN%3z&uB9=V|+qfiyQ1bB-0l)VJe>Rd`P!_l|F^N_*5NXf@;2eEcU$ z)4eOnE`2e1Uk`MkJbmY(5bU~f&@c-Ovvj1t8IpSRah4y@^k2Dx;JN?L?9yFxDZCUj zX8!nNCLYY1*}0}1GJ8(aB3>`*$P`brxHUTo<`g@boT~;O29q#e&~lAYd*^u(Lze*m zsCK6NV09?_?V4qG@NE5B_t}+mV9u> z>N%}oa;&#^yq{@TQ29|@g0OxlLA!_8w5)YI{QXeoIR@_ zcF%ye^&<(w3wy`qv)1o|n#N2rYk;r8z6a0s;(WyK`TFWsZTQ822RVD_QOysw;&Gf! zdj=H{+Ibj!$7ZElt-c{Z^myvz*cnH=Ql*+LTuV=Q`0L2rh^zR@7OP+1yO*4Z1X34* zL~-~8k`ETgUZM3hA(!`-b|6j^Y%UD6?*r}`{cQZH1_EpU=e!cQ(f6j-%q}+%@S|m$ zzi&wE!NwhrU&xKtRRl*_fmsINbC0{;N4$cf1Vf*sK-yB5U%q8kzqfA~DGISk0ubT6 zz{Fq^IlP9NbvPR%2nC+2^W#@26+C0s{w=fa``5&+a=@fziya9 z@~;)ijiKv1!m6Qrh9&*vPeH>mZ7@r(`i9_5+`S1^raO={AvQnNw@yTw-;5W!*2Jd; zypH%nr0EDHZ{`9&w_TX{M6u2Qw59qs$d_u8U$4HmX=*{U2cAk)RDhZ}-|g+HF;hd^ zv0&6=W)612%Uxqnse@d%=w4Z_4ja~24#va2q58fI77cIQc`Bb$^tm;eZiX9~KMUZ6 zLomXC=+lS$j>39Hy#_`5K`8|W<{W${^#zgzr}W&f!kPu=19d(}YSNYYxc(+TtsUNF zwdi1K>h*=sANYg={nJ1n3WZ3K8RY}r_{lV(gArk+UfOE%sXQYO&9l>;To&Ita+Vi( zETAXoIHG{eH5ojU!%xc7iBgm?us`L%=ds5zyz;%&J&)KX*?&YnIO3s+0_|RcZ8ZOixS_BT{2}!@?>9fdpeqk2WgNY#92X^N`kEWmiFqxURaI3z^|>Rp z1RYxQJJ?y>wf0|SmH3_iMTc#=0!vGNQmbC!yCH?2%xm^@9udOQ)chdcU8i|U4s>d> zEBL7ttOX6gRxNMI4lJKRd!$8xTAY>m_!=E~&m= zR}^kPp1-Mt@EImJTnX4~Q%L9dd~q<$52^91+?Zc9fI7#l+}OM?B99L-dM&5MxU_fv z)osmgVcp#(e%;+KqVKtWDXIwo9iwA|e0P#k3B|N4ZipOr$D@snE&neeo)?F-D3YFX zhYZn>HaIBu>rY9KZ7v-c%*0nuvb_RwkSZ{pMmC%lKr04>QnJ_A*XL>8XroqlPm6;U za}kDJgOAb6s`b~pzZV7JCHJ`-)<;M+SAoWAao%#**11b-GiRKZV5VndAj*wDXoyU# zBqj(u;(0dE-nb&xyCCHpdpkR_K%s&_nai@`+%BGAB^}MQF}hOPatJYnCOS~8SJA|8{~VDW+xobM8d#I7 zw%Ohatoe0;7CQOn&7OoZTuj~1<0w?Xv%H++Y%pH}Q1k2U4~5~zG?r!~Hj}T>40u6{ z?{qM2A<#qEvC2H(Pve5b1m@6X%1+}dF-OGIn`h+yrNA%v4|UWh_)awsaRUDaf3;=Qd$FgBG5!^H3+yph8 z)kfV!fZ2Bq#o!A%ruBR)>dOIil(%ndpkH{{oC9p#9UZYbMZNy!=)JR1G%87yaAgyU zGnx`9#SVjRpkvnvfTHBB@*7TAx->Q z=8sD^=$^dkmU}DL+cF@hVjXC8sUqlmw2XZgn-9l8v!f(-FGdZD;9GHSbQs)_x_vXX zAQwEAcC3`jZ$}L&@MaW!@y%g;LgY!Xhx)m-BpLbYa-dhNUv?+LA$73x+{!B!PCnC3 z_PN;sy0m)4wRv* zvG0Ohw_nQGPFdYcLFcmu|IG+>qbGfK) zsW-m)3Lb?u^=Q|O1%&Q!)Ovlv*IJ_1 x-I#&OJT9Z2#XdrV00Z~Z|K~fkt}%5Cp*wV1BR!yQJo(>OLsdtmRM|50KLG7i$5{XX literal 0 HcmV?d00001 diff --git a/apps/dicom-viewer/assets/app-icon.png b/apps/dicom-viewer/assets/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4da3a7a94c0b40d68ee715fd6d09799d49397db9 GIT binary patch literal 7365 zcmd6McQjnz*Y}+$f<*5v>Lf(( zL`{NdA<;X7=lVYD_x_%Dz3+PdeActpz3c9K&pG?-^V$3Cv-iF)?&_#fQ?O6~06?vw zeoGGkARq|=;AG&#+%4Dc{LNiYO$8|KV_ODaZap&Au+!EC1V9=NK*Apbu=6h9%>v#4 zK#~aoNWdrL{97jUU#$=zljPs@c}M#d77YMk6VDFBKN+^ic-WW?F+*k)%;s%T4ZIm zfJQFMWy;G*$g8|LJ5C2nt=9s^zS2{<)^XKa_$vC5z)K0_cXFz&v<3wqpa zyh4p&lCS#OPEBXDV&Z+Do=b=W+PE-fRa3GO0RvyRa+eu~<_*_lJ`F&krGC$Utkaf* z7M|rAGIOYoB7@R7=WHF!zavqKcYh(~@$-rb(7LgHk9kR4P47K0JEM-)L+j9Fr0_KqGP&FGN_h*{KXrDp&<3}4%{qjvB1fU`fNsxoI!aEoG>sj zjyA$?*HW!1(0p^i=IGs97)N^*@>GJaV@X;Kts&{&bJv`qt!)J7;Pd3;qR{cA}*9CEOpi{qISx86QbE z40xG~E>ea(6cWjOPfTKr%on&!xJ z7jCk0G_Wy4A(Bi{GBL#_2_V$h??miO^D_xGH zk@vJ(9BFBf<-}i157THchFLnaudNjdyDD&u&Bu7g; zIt^|q$5I68G4|}tw3;zki*YZggh}HS0<7_`WQKTMXX@S21J*>FGoId4KWNzg?cM%< zY}XC!T}vu^QphLe{BI|YjV2+PqzMe6qB=TJfofmWs-vzCG2*$?NCLorvWf`1?_!gD-$f4tXF? z$KSbPpyf_(p~WLnCetMzqz)snuD*seqZmH%7wp+7$_jRc%ZLuf1BgrpT+n*4zv4LX zE(%dNReGpJUqK?sh?}+WTTdSCb{`7ebZW`$ye2s8H#BQsln5Y%xKYE?# zf1+^pHZF4?y!+lBI!K_$la>9Ws~>4?5-6IBF=J-dM@^Cb4W9y-|7L++ZGg@=Dg z{QT0qqFlZ4g9g_tf@C+9hC-`OjDKp*?HBMKCE}Qa+}*NTsfYVN?^%YFsy3Gv5pPlg z9Dfnz)ttRp`iK~qI_f%PoC?!E*10Y9R``ev!+{!M!tJY*Ul z(77cC4!a90HFa67Q>KNS5Hbr3WXrCXc%?SoZ>^=o$s8UqD={9vNk0s2VKFx#`DU!+7`8L6t zpIfiBeu_JcKs&B~;ya1rbWfGUIw=e$!lDp_50++fR~_B`*96p7QE^8EHo z(qARD55HuMClixLL!D57SxE2l8+`BRuOkYyXO_;%gMgoAI<_}2oP)JT#~)clYAhiC ztkbKCd5ZzJBGOnij6?QXTpyDI0>F8=_<;0nN791{5s4fI+`ITP$5PcAk$oRenYZ>I zK*NZL1uz$?KYFh@8L&FD-w_0Sb54!z7|p_hptFr99z1C1CLL~d*%VUxqhaoQNwI@sddF|&@9`T=k>8gcec?Pi1CTV`@ zzY}heg_ZQUs5$=%Rkn4p5%P5Q0dN<1!~mFq5{BZ6g=8p7lVW{@q5G*txroRKDExcw z8T&{%GiL!{Y>jP7DC?}=)1;O0$GKD)d^1lY*Vgj2WfrUQfE~LLqerl^1@7jQVE)c2 z>~CS-Rk;~ zIHwm+of&JrqCx_tkVKv!TbhlU&PC}T`~Ww1xfg+bkRh@+ke&S~9`wUR%uvPSvelzE zQgDJxo?mz+P`5%dip?qsx`Z79SH%+!VDw}z0O`e(dt34{a9pDM?bqZcrv*ak%>T5< z;_yz=6{}>BI0)gg#o!(PzeeHbV2MMq)e}9{tT41&vw=}CMf#bX{0?_e3@zL;-R#e` zvC=7A&uKR|p~ymZcIsKlgW;0mi6hb~t2y3pQ)5f3)suCSJC`8}K?xqdOG}F*sl%Lk z7h_IJQ7G^MkXnvH;ET38HBPMHwqD7p(Bc=2*cR)fF@ zr3cKOBrKqUXkh%nD|m9EQ1!hLq;;np{A_m2prSC`(oxP8j4{J;Kwi48068 zQ9;|#BTJtHq=e)&TUm7!3_^j1J4C5Kt3kfwdWk~hh)@(70*6H3Q~&=9huVeW8mE4% zXVSqiAOxbocy0W@mxZ%1TmCOi(MkR=v_g2 zf!_mX%=|W(K73`m8Ykkrb%5Nu8PZ_TZ}om*lBa% zjKFUC*#7f~UjBQuxDD)ax8pa@Aqu~ZR66<~8q0|X)DUWKEK4BV#pz{m9Xewml`a!t zlDLTnkM%lRT69LH^Y0#zTaS+~3HI+ct4ieM>npI3G7mctcDsg#L2L^@6$c%pr*$Be zHWNtM-1=}tl_ln*1rwvzw7(7Rh_=XWVpzj3sy->E#mcL|Vh>XYtnl34KM0u6AjSC5 z5Hb5UCLT*_a9ccZV?))`I{=NEIhT7~ce0Y8#ZJcr0~5 zsxL}hop7#q$NU7+%Mf>va<_|8hK_=}!dO2^2#!br*OPM1h6-6425z3Td|PF8!W)o! z!)z0WlL8kpdjATg3Um>q{#((k46L8Z)!8B~f2532k`m&Wr9WhA@%9|@3tdIyB&^88gh z!>^YK(m*ZD-e-#3<@5MYX&M54mvNpbovrA5k*jP(3Q(k-2C{9@{rsJT4QzviD(b@BZZ=vZ$~;#i%_S!o>ir^f5`WBW)CP0ID_6QVUEF zsqRG1CfvqZ;IhBLw{Jqlk(od%JpChAP&TMQo}$|+oXCiH(1G0^muEomM1$00sV~Gh zbQB1Y_?N{zb<|^!(kP%^42N0spdk^o(z>cp4Q@0<-85QEiHC+8^i5Hvz5+;f7LXcx z5_)(!hzV4nb!e}CJbDOJYJwqm&qYM5I_T@TF>hbOKyjdPl`K|Rb<}OpCo)-W-{*;w z1gZa4#AyT*_g}G(EaPs`lY8|wdA;@fxdtJu_liF|B3-OwG`n_9cC!>d3o1s^e`6(+9M&iz^GC^#ND*j6%3 zLhj#>TKFO^k02$`7_)u0A~tVdip|r`s-L@VG1Ui|WobGQkt$tyvO9T^GY4K7?Qsw~ z?Cywo*f#RDHA88&t(jh~D_COiN-T(@m1nS`Qrbzi>?_2hEKNobobr_x)(uU4yb{0S zD?~`DM<*AEqG5nf)(N6p&vxRo7MVr)IbH^wKkq;MQciE;B^Ip#8W26s_M6(Tr9-1& z!DUA#L%r(Cwj3b4YBA+0G6DzA1dY%Nh7H{TQ5{XQY0^MJ!4Ytj1Ollp2+nHi7<0%Q z1P2*&;W~KKQAMCW9~a3kro*_vGb94wN<;-!f@bkQ&+v9CY;UyU+k|;_doSDQajYn^aM^UEAZR_msLy?$9JX zjmEtewQuu+q95yS;OvyTaOIg=9;;Vo9MiQiH^$fo*8!!ga${heqj&JG?bJY; z8;ZWf3Sk;JaK|XNlgWSO+p5qW_X^ldt6GZvMs9j^BhhIfD(5Fr2g);W84AH{8wZTC zz|d=_`ny5Nr+o|jfTr)p4Fu2ge?^xrnrorO=n3=Jed$;*YG&t}u}|+k4~ux+DZ`W8 z%wo14NiZiplF7Vj&@-HXc88W~jN3Ubi5NQh`9`!cJqN2p8DF+6y8{>Mwz@BFTmoZe zHN?S0w*9d6)NU$<9uN&Zz8yfwY2H3u%TYSafT?G;fWfie(b;kOqq01plEPdwGZK3D z=4V}`It-$e{)&$nOiA!pJ*gKgHs~A}+i-NNeAzt*$~K6^39lTTl`dF+4QQM&Nv{Te z26_{-^!?DK_EMM3&{nGV^wncnvm;9YX^|WWNc0h zwC#S+jFuag)j(kF{>fMTcLqM!m_5qM0eooLmYy5Zy0US{Vpp;vb(O$bHei;1*sQaz z_OL1_N-+3s5~MYG{o@x_^=HS1;i3?m1OO4r3rr0+lESMgSw}O_f`I<>&5O3u=wq&$ zw{I0!=|k7%cfkA$t_BQaOBEX39~4cx{JCoi$-P}5KOtAw5mE^~GAtS-eFqATZiQL8 z*VPB^dOVv_WqJrn5n}UE{qh)3^Ox~T*OvIKfcq(5kTe~x=<}lg_tq;@-^jKZfYxO1 zdWB+5(mR#Uc1%ra~A-ta1dG;5PkRZ*g;sYpx>b2I3Ov{z?_5c zyuLuN;FMnclV82!NL1%@pd{Q_iWzM5(K_H=SBng!q}*Ql-oq#C@0$YZP$)=>#3&c& z#!jaQ5yym;`)MmlXL5{OH80L~a#{T9$Xs9Hv4EbVIOU;<05DhY4nM>UR}7-_3`v*~zKH?9Dz`I8AvH5bxDosM{oFA1!9gH>pWPnzpgw#aU8SCxf{ZH zF%Ce$P^fAWR*Eg;gK-|rHFSP=aOmXX(%dj#Y~ggRZ=x3EnnkkdkJv)_Lfj%H8QFPe z-vi&dSV+0k(fIuYFQ<2XqsA`J=w>()xV74A|FGM; zqbm(1q@7lkPl^&Wz0D13#oSlSDk>`8c|DX`gN|(Z5DzvFt$jCHCI06A&|#Y`!_bnR z*Qz)8?n+^&bDDe{$AmC6)xYsiwrSpx0-f6IiaxjUwgQG=n-(4bEjlsG2HGnatfm1} z2Pp>PJWs8OrJuF}3|Oi9MLaHLdUaLUE2fz@Z{|N{K0GsBH7?NgAm4z7Jn=@?zsWQ} z%IiQ4kpzb`!sKqy5fMO*ZfEWspSk9U843X%go$f>(=6XhWw|N53Lb`uae$_ibth8Q zAn!n$jhK+7gvfpikadw48*}=vM*TTmu(cwVWbym(Kygu8c-g}`uyog*jrXzxtTrV` zB;B)_v+m?1PtkDkvm=bP0YRczKfV)oP4&~Zl5pGE(miE_*C@{ZhTl=EVk*b?tHU8a zNR2Y@lbq^c)bq{}twsZYaQWF3=#wUjP z9wsE?3TZc}$k>_HOL%rn%j;@NU`xKzW`EDW`p*?w==A5$M-nP0sJIpoTCLZq%*QdJqR<*&x0cf7h2;!im(4t7THMYg^rh{SPNW^&%Yit4J& zCHd@>+b>jaIWT7$0OI_wWkYDfxaUH-acUk`JGE1OX7d_Kfmd`)>-aX**Zt_oA3V^I z``~JG39xl>aKL01^!u929bJr|xs^Z;S1}>Gped1LOe@ZB)b(>*Sv6V9#t>qGkVC@n z0IzenZ)e0a3w$4}D8I``lGQ$3B;gqy)X4wF{B`jT-P_OI@?Yfpn}_6YS^HaED+`#9 zl(Bom=EX78kXqPfZj}v-$6m9Ic3Bx@E!&A`R=AdBJBK(CuRi(0cr}>^ zauBt@(eZX_A^u&strWp&=u+f|id~Zxq6&1AHcq68NzQA$Sf1Ior0KxTP(h!IG?UEA z!S-(&Sa}oW_Sx@3cUDijViVB65VdRfKG{9wuUo6Ynt6Da3%y{(d*F$Gu5sZ%<+f(-|8Q1A`1O!V#$}){jdcWMx<&<{P^{3_Sa>nYgN8zmxY-4`30C zikc7C{Bh&Wn`;8^#duE5ax9Iyhvt-hu}=ne-`;eoTkv@hQ?m-m4fEebufRKYL&$Y) zQ}gO)EESrvJ}j`LCC_noZQ8h + + + + + + 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> { - let Some(centerline) = self.brush_stroke.take() else { - return Ok(None); - }; - let layer_id = self.ensure_segmentation_layer()?; - self.ensure_layer_editable(layer_id)?; - let operation = self - .brush_operation - .take() - .unwrap_or(self.segment_operation); - let segment_id = self.selected_segment(); - let primitive = SegmentationPrimitive::brush(operation, centerline, self.brush_diameter); - let id = if let Some(segment_id) = segment_id { - let outcome = self.edit("Brush stroke", |document| { - document.apply_segment_primitive(segment_id, primitive) - })?; - if outcome == dicom_viewer_core::SegmentEditOutcome::NoIntersection { - return Ok(None); - } - segment_id - } else { - let class_id = self.region_class_id.clone(); - self.edit("Add segment", |document| { - document.add_segment(layer_id, &class_id, primitive) - })? - }; - self.select_only(id); - Ok(Some(id)) - } - - pub(super) fn cancel_pointer_interaction(&mut self) { - self.brush_stroke = None; - self.brush_operation = None; - } - - #[must_use] - pub(super) fn brush_stroke(&self) -> Option<&[Point2]> { - self.brush_stroke.as_deref() - } - - pub(super) fn place_ruler_point( - &mut self, - point: Point2, - physical_length_mm: impl FnOnce(Point2, Point2) -> Option, - ) -> ViewerResult> { - let Some(start) = self.ruler_start.take() else { - self.ruler_start = Some(point); - return Ok(None); - }; - let class_id = self.region_class_id.clone(); - let length = physical_length_mm(start, point); - let id = self.edit("Add ruler", |document| { - document.add_linear_measurement(&class_id, [start, point], length) - })?; - self.select_only(id); - Ok(Some(id)) - } - - #[must_use] - pub(super) const fn ruler_start(&self) -> Option { - self.ruler_start - } - - pub(super) fn cancel_ruler(&mut self) -> bool { - self.ruler_start.take().is_some() - } - - pub(super) fn cancel_draft_step(&mut self) -> bool { - let Some(before) = self.draft.as_ref().cloned() else { - return false; - }; - if before.points().is_empty() { - self.draft = None; - return true; - } - if before.points().len() == 1 { - self.draft = None; - self.draft_undo.push(DraftUndoStep::RestoreDraft(before)); - return true; - } - let point = self - .draft - .as_mut() - .and_then(DraftInteraction::pop_point) - .expect("the non-empty polygon draft was checked"); - self.draft_undo.push(DraftUndoStep::AppendPoint(point)); - true - } - - pub(super) fn undo_draft_cancel(&mut self) -> bool { - let Some(step) = self.draft_undo.pop() else { - return false; - }; - match step { - DraftUndoStep::AppendPoint(point) => { - let Some(draft) = &mut self.draft else { - self.draft_undo.push(DraftUndoStep::AppendPoint(point)); - return false; - }; - draft.push_point(point); - } - DraftUndoStep::RestoreDraft(draft) => { - if self.draft.is_some() { - self.draft_undo.push(DraftUndoStep::RestoreDraft(draft)); - return false; - } - self.draft = Some(draft); - } - } - true - } - - pub(super) fn discard_draft(&mut self) { - self.draft = None; - self.draft_undo.clear(); - self.pending_tool = None; - } - pub(super) fn set_layer_visibility( &mut self, layer_id: Uuid, @@ -821,1271 +379,6 @@ impl WorkspaceRuntime { self.invalidate_spatial_index(); Ok(()) } - - #[must_use] - pub(super) fn selection(&self) -> &HashSet { - &self.selection - } - - pub(super) fn select_only(&mut self, object_id: Uuid) { - self.selection.clear(); - self.selection.insert(object_id); - } - - pub(super) fn toggle_selection(&mut self, object_id: Uuid) { - if !self.selection.remove(&object_id) { - self.selection.insert(object_id); - } - } - - pub(super) fn clear_selection(&mut self) { - self.selection.clear(); - } - - fn selected_segment(&self) -> Option { - self.selection - .iter() - .copied() - .find(|id| self.document.segment(*id).is_some()) - } - - pub(super) fn delete_selection(&mut self) -> ViewerResult { - let ids = self.selection.iter().copied().collect::>(); - if ids.is_empty() { - return Ok(0); - } - self.ensure_objects_editable(&ids)?; - let deleted = self.edit("Delete selection", |document| { - let mut count = 0; - for id in &ids { - count += usize::from(document.delete_object(*id)?); - } - Ok(count) - })?; - self.clear_selection(); - Ok(deleted) - } - - pub(super) fn begin_handle_drag(&mut self, point: Point2, tolerance: f64) -> bool { - let tolerance_squared = tolerance * tolerance; - let mut closest: Option<(f64, Uuid, EditableHandle)> = None; - for id in self.selection.iter().copied() { - if self.object_locked(id) { - continue; - } - if let Some(finding) = self.document.finding(id) { - match finding.geometry() { - VectorFindingGeometry::Point(candidate) => update_handle_candidate( - &mut closest, - point, - *candidate, - tolerance_squared, - id, - EditableHandle::PointFinding, - ), - VectorFindingGeometry::Regions(components) => { - for (component_index, component) in components.iter().enumerate() { - for (vertex_index, candidate) in component.iter().copied().enumerate() { - update_handle_candidate( - &mut closest, - point, - candidate, - tolerance_squared, - id, - EditableHandle::PolygonVertex { - component_index, - vertex_index, - }, - ); - } - } - } - } - } else if let Some(segment) = self.document.segment(id) { - for (primitive_index, primitive) in segment.primitives().iter().enumerate() { - let points = match primitive.geometry() { - SegmentationPrimitiveGeometry::Polygon { points } => points.as_ref(), - SegmentationPrimitiveGeometry::Brush { centerline, .. } => { - centerline.as_ref() - } - }; - for (point_index, candidate) in points.iter().copied().enumerate() { - update_handle_candidate( - &mut closest, - point, - candidate, - tolerance_squared, - id, - EditableHandle::SegmentPrimitivePoint { - primitive_index, - point_index, - }, - ); - } - } - } else if let Some(measurement) = self.document.measurement(id) { - for (endpoint_index, candidate) in measurement.endpoints().into_iter().enumerate() { - update_handle_candidate( - &mut closest, - point, - candidate, - tolerance_squared, - id, - EditableHandle::MeasurementEndpoint { endpoint_index }, - ); - } - } - } - let Some((_, object_id, handle)) = closest else { - return false; - }; - self.handle_drag = Some(HandleDrag { - object_id, - handle, - before: Arc::clone(&self.document), - }); - true - } - - pub(super) fn update_handle_drag( - &mut self, - point: Point2, - physical_length_mm: impl FnOnce(Point2, Point2) -> Option, - ) -> ViewerResult<()> { - let Some(drag) = &self.handle_drag else { - return Ok(()); - }; - let object_id = drag.object_id; - let handle = drag.handle; - let mut candidate = (*self.document).clone(); - match handle { - EditableHandle::PointFinding => { - candidate - .replace_vector_geometry(object_id, VectorFindingGeometry::Point(point))?; - } - EditableHandle::PolygonVertex { - component_index, - vertex_index, - } => { - candidate.move_vector_vertex(object_id, component_index, vertex_index, point)?; - } - EditableHandle::MeasurementEndpoint { endpoint_index } => { - let mut endpoints = candidate - .measurement(object_id) - .ok_or_else(|| { - ViewerError::InvalidInput("the dragged measurement no longer exists".into()) - })? - .endpoints(); - endpoints[endpoint_index] = point; - let length = physical_length_mm(endpoints[0], endpoints[1]); - candidate.set_measurement_endpoints(object_id, endpoints, length)?; - } - EditableHandle::SegmentPrimitivePoint { - primitive_index, - point_index, - } => { - candidate.move_segment_primitive_point( - object_id, - primitive_index, - point_index, - point, - )?; - } - } - candidate.validate()?; - self.document = Arc::new(candidate); - self.invalidate_spatial_index(); - Ok(()) - } - - pub(super) fn finish_handle_drag(&mut self) -> bool { - let Some(drag) = self.handle_drag.take() else { - return false; - }; - if drag.before.revision() == self.document.revision() { - return false; - } - self.history.record( - "Move geometry handle", - drag.before, - Arc::clone(&self.document), - ); - true - } - - pub(super) fn cancel_handle_drag(&mut self) -> bool { - let Some(drag) = self.handle_drag.take() else { - return false; - }; - self.document = drag.before; - self.invalidate_spatial_index(); - true - } - - #[must_use] - pub(super) const fn handle_drag_active(&self) -> bool { - self.handle_drag.is_some() - } - - pub(super) fn reclassify_selection(&mut self, class_id: &str) -> ViewerResult { - let ids = self.selection.iter().copied().collect::>(); - if ids.is_empty() { - return Ok(0); - } - self.ensure_objects_editable(&ids)?; - self.edit("Reclassify selection", |document| { - for id in &ids { - document.reclassify_object(*id, class_id)?; - } - Ok(ids.len()) - }) - } - - pub(super) fn set_selected_name(&mut self, name: Option<&str>) -> ViewerResult<()> { - let id = self.single_selection()?; - self.ensure_objects_editable(&[id])?; - self.edit("Rename finding", |document| { - document.set_object_name(id, name) - }) - } - - pub(super) fn set_selected_comment(&mut self, comment: Option<&str>) -> ViewerResult<()> { - let id = self.single_selection()?; - self.ensure_objects_editable(&[id])?; - self.edit("Edit finding comment", |document| { - document.set_object_comment(id, comment) - }) - } - - pub(super) fn set_selected_finding_site( - &mut self, - site: Option<&dicom_viewer_core::DicomCode>, - ) -> ViewerResult<()> { - let id = self.single_selection()?; - self.ensure_objects_editable(&[id])?; - self.edit("Set finding site", |document| { - document.set_object_finding_site(id, site) - }) - } - - #[must_use] - pub(super) fn active_class_id(&self) -> &str { - match self.active_tool { - ActiveTool::Point => &self.point_class_id, - _ => &self.region_class_id, - } - } - - pub(super) fn set_active_class(&mut self, class_id: impl Into) -> ViewerResult<()> { - if self.draft.is_some() { - return Err(ViewerError::InvalidInput( - "finish, resume, or discard the current polygon before changing class".into(), - )); - } - let class_id = class_id.into(); - let class = self.document.scheme().class(&class_id).ok_or_else(|| { - ViewerError::InvalidInput("selected annotation class does not exist".into()) - })?; - match class.geometry() { - dicom_viewer_core::AnnotationClassGeometry::Region => self.region_class_id = class_id, - dicom_viewer_core::AnnotationClassGeometry::Point => self.point_class_id = class_id, - } - self.draft_undo.clear(); - Ok(()) - } - - pub(super) fn migrate_scheme( - &mut self, - target: AnnotationScheme, - mappings: &BTreeMap, - ) -> ViewerResult<()> { - if self.draft.is_some() { - return Err(ViewerError::InvalidInput( - "finish, resume, or discard the current polygon before changing annotation scheme" - .into(), - )); - } - self.edit("Migrate annotation scheme", |document| { - document.migrate_scheme(target, mappings) - }) - } - - #[must_use] - pub(super) const fn segment_operation(&self) -> SegmentOperation { - self.segment_operation - } - - pub(super) fn set_segment_operation(&mut self, operation: SegmentOperation) { - self.segment_operation = operation; - } - - #[must_use] - pub(super) const fn brush_diameter(&self) -> f64 { - self.brush_diameter - } - - pub(super) fn adjust_brush_diameter(&mut self, scale: f64) { - self.brush_diameter = (self.brush_diameter * scale).clamp(1.0, 20_000.0); - } - - #[must_use] - #[cfg(test)] - pub(super) const fn active_vector_layer(&self) -> Uuid { - self.active_vector_layer - } - - pub(super) fn ensure_segmentation_layer(&mut self) -> ViewerResult { - if self.draft.is_some() { - return Err(ViewerError::InvalidInput( - "finish, resume, or discard the current polygon before activating Brush".into(), - )); - } - if let Some(id) = self.active_segmentation_layer { - self.editing_representation = EditingRepresentation::Segmentation; - self.draft_undo.clear(); - return Ok(id); - } - let id = self.edit("Create segmentation layer", |document| { - Ok(document.ensure_manual_segmentation_layer()) - })?; - self.active_segmentation_layer = Some(id); - self.editing_representation = EditingRepresentation::Segmentation; - self.draft_undo.clear(); - Ok(id) - } - - #[must_use] - pub(super) const fn editing_representation(&self) -> EditingRepresentation { - self.editing_representation - } - - pub(super) fn use_vector_layer(&mut self, layer_id: Uuid) -> ViewerResult<()> { - if self.draft.is_some() { - return Err(ViewerError::InvalidInput( - "finish, resume, or discard the current polygon before changing layers".into(), - )); - } - if !self - .document - .vector_layers() - .iter() - .any(|layer| layer.id() == layer_id) - { - return Err(ViewerError::InvalidInput( - "the selected vector layer does not exist".into(), - )); - } - self.active_vector_layer = layer_id; - self.editing_representation = EditingRepresentation::Vector; - self.draft_undo.clear(); - Ok(()) - } - - pub(super) fn use_segmentation_layer(&mut self, layer_id: Uuid) -> ViewerResult<()> { - if self.draft.is_some() { - return Err(ViewerError::InvalidInput( - "finish, resume, or discard the current polygon before changing layers".into(), - )); - } - if !self - .document - .segmentation_layers() - .iter() - .any(|layer| layer.id() == layer_id) - { - return Err(ViewerError::InvalidInput( - "the selected segmentation layer does not exist".into(), - )); - } - self.active_segmentation_layer = Some(layer_id); - self.editing_representation = EditingRepresentation::Segmentation; - self.draft_undo.clear(); - Ok(()) - } - - pub(super) fn begin_new_segment(&mut self) { - self.draft_undo.clear(); - self.clear_selection(); - } - - pub(super) fn add_external_annotation( - &mut self, - name: impl Into, - source_path: Option, - document: AnnotationDocument, - ) -> ViewerResult { - let count = document.groups().len() as u64; - let id = self.upsert_external_layer( - name.into(), - ExternalLayerKind::DicomAnn, - source_path, - None, - count, - )?; - self.external_payloads - .insert(id, ExternalLayerPayload::Annotation(Arc::new(document))); - Ok(id) - } - - pub(super) fn add_external_pathology( - &mut self, - name: impl Into, - session: PathologySession, - ) -> ViewerResult { - let source_path = Some(session.geojson_path().to_path_buf()); - let source_digest = Some(session.semantic_digest().to_owned()); - let count = session.preview().features().len() as u64; - let id = self.upsert_external_layer( - name.into(), - ExternalLayerKind::ProfiledGeoJson, - source_path, - source_digest, - count, - )?; - self.external_payloads - .insert(id, ExternalLayerPayload::ProfiledGeoJson(Arc::new(session))); - Ok(id) - } - - pub(super) fn add_external_segmentation( - &mut self, - name: impl Into, - source_path: Option, - document: SegmentationDocument, - ) -> ViewerResult<(Uuid, Vec)> { - let count = document.segments().len() as u64; - let id = self.upsert_external_layer( - name.into(), - ExternalLayerKind::DicomSeg, - source_path, - None, - count, - )?; - let (vector_groups, diagnostics) = - if document.kind() == dicom_viewer_core::SegmentationKind::Fractional { - (None, Vec::new()) - } else { - let projection = - document.vectorized_annotations(SegToAnnConversionPolicy::AllowLoss)?; - let diagnostics = projection.diagnostics().to_vec(); - (Some(Arc::from(projection.into_groups())), diagnostics) - }; - self.external_payloads.insert( - id, - ExternalLayerPayload::Segmentation { - document: Arc::new(document), - vector_groups, - }, - ); - Ok((id, diagnostics)) - } - - pub(super) fn add_external_report( - &mut self, - name: impl Into, - source_path: Option, - session: ReportSession, - ) -> ViewerResult { - let count = session.document().groups().len() as u64; - let id = self.upsert_external_layer( - name.into(), - ExternalLayerKind::DicomSr, - source_path, - None, - count, - )?; - self.external_payloads - .insert(id, ExternalLayerPayload::Report(Arc::new(session))); - Ok(id) - } - - pub(super) fn add_external_heatmap( - &mut self, - name: impl Into, - session: RasterSession, - context: &eframe::egui::Context, - ) -> ViewerResult { - let source_path = Some(session.raster_path().to_path_buf()); - let source_digest = Some(session.semantic_digest().to_owned()); - let count = u64::from(session.frame_count()) - .saturating_mul(u64::try_from(session.selected_channel_count()).unwrap_or(u64::MAX)); - let id = self.upsert_external_layer( - name.into(), - ExternalLayerKind::Heatmap, - source_path, - source_digest, - count, - )?; - let texture = session.load_texture(context); - self.external_payloads.insert( - id, - ExternalLayerPayload::Heatmap { - session: Arc::new(session), - texture, - }, - ); - Ok(id) - } - - fn upsert_external_layer( - &mut self, - name: String, - kind: ExternalLayerKind, - source_path: Option, - source_digest: Option, - source_object_count: u64, - ) -> ViewerResult { - if let Some(id) = self.matching_external_layer(&kind, source_path.as_deref()) { - self.edit("Load source layer", |workspace| { - workspace.hydrate_external_layer(id, source_object_count, source_digest) - })?; - return Ok(id); - } - let reference = ExternalLayerReference::new( - name, - kind, - source_path, - source_digest, - source_object_count, - ); - let id = reference.id(); - self.edit("Import source layer", |document| { - document.add_external_layer(reference) - })?; - Ok(id) - } - - pub(super) fn ensure_discovered_external_stub( - &mut self, - name: impl Into, - kind: ExternalLayerKind, - source_path: PathBuf, - ) -> ViewerResult { - if let Some(id) = self.matching_external_layer(&kind, Some(&source_path)) { - return Ok(id); - } - let reference = ExternalLayerReference::new(name, kind, Some(source_path), None, 0); - let id = reference.id(); - let mut candidate = (*self.document).clone(); - candidate.add_external_layer(reference)?; - candidate.validate()?; - self.document = Arc::new(candidate); - self.invalidate_spatial_index(); - Ok(id) - } - - pub(super) fn remove_external_layer(&mut self, layer_id: Uuid) -> ViewerResult { - self.edit("Remove source layer", |document| { - document.remove_external_layer(layer_id) - }) - } - - #[must_use] - pub(super) fn external_payload(&self, layer_id: Uuid) -> Option<&ExternalLayerPayload> { - self.external_payloads.get(&layer_id) - } - - fn matching_external_layer( - &self, - kind: &ExternalLayerKind, - source_path: Option<&Path>, - ) -> Option { - let source_path = source_path?; - self.document - .external_layers() - .iter() - .find(|layer| layer.kind() == kind && layer.source_path() == Some(source_path)) - .map(ExternalLayerReference::id) - } - - pub(super) fn external_classes( - &self, - layer_id: Uuid, - ) -> ViewerResult> { - let objects = self.prepare_external_objects(layer_id)?; - let mut classes = BTreeMap::::new(); - for object in objects { - let Some(geometry) = object.geometry else { - continue; - }; - let editable = object.promotion.is_some(); - let entry = classes.entry(object.class_key.clone()).or_insert_with(|| { - let exact_scheme_class_id = self - .document - .scheme() - .classes() - .iter() - .find(|class| class.concept_key().as_str() == object.class_key) - .map(|class| class.id().to_owned()); - ExternalClassDescriptor { - key: object.class_key, - label: object.label, - geometry, - object_count: 0, - exact_scheme_class_id, - editable, - } - }); - entry.object_count = entry.object_count.saturating_add(1); - entry.editable &= editable; - } - Ok(classes.into_values().collect()) - } - - pub(super) fn external_objects( - &self, - layer_id: Uuid, - ) -> ViewerResult> { - self.prepare_external_objects(layer_id).map(|objects| { - objects - .into_iter() - .map(|object| ExternalObjectDescriptor { - promoted: self.source_object_was_promoted(layer_id, &object.source_object_id), - promotable: object.promotion.is_some(), - source_object_id: object.source_object_id, - label: object.label, - class_key: object.class_key, - }) - .collect() - }) - } - - pub(super) fn set_external_class_mapping( - &mut self, - layer_id: Uuid, - source_class_key: &str, - target_class_id: &str, - ) -> ViewerResult<()> { - let source = self - .external_classes(layer_id)? - .into_iter() - .find(|class| class.key == source_class_key) - .ok_or_else(|| { - ViewerError::InvalidInput("the external source class does not exist".into()) - })?; - let target = self - .document - .scheme() - .class(target_class_id) - .ok_or_else(|| { - ViewerError::InvalidInput( - "the mapping target is not in the pinned annotation scheme".into(), - ) - })?; - if source.geometry != target.geometry() { - return Err(ViewerError::InvalidInput( - "external class mappings cannot change point/region geometry".into(), - )); - } - let source_class_key = source_class_key.to_owned(); - let target_class_id = target_class_id.to_owned(); - self.edit("Map external class", move |document| { - document.set_external_class_mapping(layer_id, &source_class_key, &target_class_id) - }) - } - - pub(super) fn promote_external_object( - &mut self, - layer_id: Uuid, - source_object_id: &str, - ) -> ViewerResult { - if self.source_object_was_promoted(layer_id, source_object_id) { - return Err(ViewerError::InvalidInput( - "the selected source object is already a tracked finding".into(), - )); - } - let object = self - .prepare_external_objects(layer_id)? - .into_iter() - .find(|object| object.source_object_id == source_object_id) - .ok_or_else(|| { - ViewerError::InvalidInput("the external source object does not exist".into()) - })?; - let class_id = self.external_mapping_target(layer_id, &object.class_key)?; - let promotion = object.promotion.ok_or_else(|| { - ViewerError::Unsupported( - "the selected external object cannot be represented by an editable workspace geometry".into(), - ) - })?; - let vector_layer = self.active_vector_layer; - let source_object_id = object.source_object_id; - let id = self.edit("Promote source object", move |document| { - apply_external_promotion( - document, - vector_layer, - layer_id, - source_object_id, - &class_id, - promotion, - ) - })?; - if self.document.segment(id).is_some() { - self.active_segmentation_layer = self - .document - .segmentation_layers() - .iter() - .find(|layer| { - layer - .segments() - .iter() - .any(|segment| segment.object_id() == id) - }) - .map(|layer| layer.id()); - } - self.select_only(id); - Ok(id) - } - - pub(super) fn make_external_layer_editable( - &mut self, - layer_id: Uuid, - ) -> ViewerResult> { - let objects = self.prepare_external_objects(layer_id)?; - if objects.is_empty() { - return Err(ViewerError::InvalidInput( - "the external layer contains no convertible objects".into(), - )); - } - if objects - .iter() - .any(|object| self.source_object_was_promoted(layer_id, &object.source_object_id)) - { - return Err(ViewerError::InvalidInput( - "the external layer already contains promoted objects; promote the remaining objects individually".into(), - )); - } - let prepared = objects - .into_iter() - .map(|object| { - let class_id = self.external_mapping_target(layer_id, &object.class_key)?; - let promotion = object.promotion.ok_or_else(|| { - ViewerError::Unsupported(format!( - "source object {} cannot be converted losslessly", - object.source_object_id - )) - })?; - Ok((object.source_object_id, class_id, promotion)) - }) - .collect::>>()?; - let vector_layer = self.active_vector_layer; - let ids = self.edit("Make external layer editable", move |document| { - prepared - .into_iter() - .map(|(source_object_id, class_id, promotion)| { - apply_external_promotion( - document, - vector_layer, - layer_id, - source_object_id, - &class_id, - promotion, - ) - }) - .collect::>>() - })?; - self.selection = ids.iter().copied().collect(); - if ids.iter().any(|id| self.document.segment(*id).is_some()) { - self.active_segmentation_layer = self - .document - .segmentation_layers() - .iter() - .find(|layer| { - layer - .segments() - .iter() - .any(|segment| ids.contains(&segment.object_id())) - }) - .map(|layer| layer.id()); - } - Ok(ids) - } - - fn external_mapping_target( - &self, - layer_id: Uuid, - source_class_key: &str, - ) -> ViewerResult { - self.document - .external_layers() - .iter() - .find(|layer| layer.id() == layer_id) - .and_then(|layer| layer.class_mappings().get(source_class_key)) - .cloned() - .ok_or_else(|| { - ViewerError::InvalidInput( - "every source class needs an explicit mapping before promotion".into(), - ) - }) - } - - fn prepare_external_objects( - &self, - layer_id: Uuid, - ) -> ViewerResult> { - if !self - .document - .external_layers() - .iter() - .any(|layer| layer.id() == layer_id) - { - return Err(ViewerError::InvalidInput( - "the external source layer does not exist".into(), - )); - } - match self.external_payloads.get(&layer_id) { - Some(ExternalLayerPayload::Annotation(document)) => { - prepare_annotation_objects(document) - } - Some(ExternalLayerPayload::ProfiledGeoJson(session)) => session - .editable_ann() - .map_or_else(|| Ok(Vec::new()), prepare_annotation_objects), - Some(ExternalLayerPayload::Segmentation { document, .. }) => { - prepare_segmentation_objects(document) - } - Some(ExternalLayerPayload::Report(session)) => { - prepare_report_objects(session.document()) - } - Some(ExternalLayerPayload::Heatmap { .. }) => Ok(Vec::new()), - None => Ok(Vec::new()), - } - } - - fn source_object_was_promoted(&self, layer_id: Uuid, source_object_id: &str) -> bool { - self.document - .vector_findings() - .map(|finding| finding.provenance()) - .chain(self.document.segments().map(|segment| segment.provenance())) - .chain( - self.document - .measurements() - .iter() - .map(|measurement| measurement.provenance()), - ) - .any(|provenance| { - matches!( - provenance, - WorkspaceObjectProvenance::Promoted { - source_layer_id, - source_object_id: promoted_id, - } if *source_layer_id == layer_id && promoted_id == source_object_id - ) - }) - } - - pub(super) fn refresh_spatial_index(&mut self) -> ViewerResult<()> { - if self.spatial_revision != self.document.revision() { - self.spatial_index = WorkspaceSpatialIndex::build(&self.document)?; - self.spatial_revision = self.document.revision(); - } - Ok(()) - } - - #[must_use] - pub(super) fn spatial_index(&self) -> &WorkspaceSpatialIndex { - &self.spatial_index - } - - #[must_use] - pub(super) fn hit_test(&self, point: Point2, tolerance: f64) -> Option { - let query = [ - point.x - tolerance, - point.y - tolerance, - point.x + tolerance, - point.y + tolerance, - ]; - self.spatial_index - .query(query, 256) - .into_iter() - .filter_map(|id| { - object_distance(self.document(), id, point).map(|distance| (id, distance)) - }) - .filter(|(_, distance)| *distance <= tolerance) - .min_by(|left, right| left.1.total_cmp(&right.1)) - .map(|(id, _)| id) - } - - #[must_use] - pub(super) fn object_bounds(&self, id: Uuid) -> Option<[f64; 4]> { - self.spatial_index.bounds(id) - } - - fn invalidate_spatial_index(&mut self) { - self.spatial_revision = u64::MAX; - } - - fn single_selection(&self) -> ViewerResult { - if self.selection.len() != 1 { - return Err(ViewerError::InvalidInput( - "select exactly one tracked object".into(), - )); - } - Ok(*self - .selection - .iter() - .next() - .expect("one selected object exists")) - } - - fn ensure_layer_editable(&self, layer_id: Uuid) -> ViewerResult<()> { - if self.document.presentation().layer(layer_id).locked { - return Err(ViewerError::InvalidInput( - "the active annotation layer is locked".into(), - )); - } - Ok(()) - } - - fn ensure_objects_editable(&self, object_ids: &[Uuid]) -> ViewerResult<()> { - if object_ids.iter().copied().any(|id| self.object_locked(id)) { - return Err(ViewerError::InvalidInput( - "the selection contains an object on a locked layer".into(), - )); - } - Ok(()) - } - - fn object_locked(&self, object_id: Uuid) -> bool { - self.document.vector_layers().iter().any(|layer| { - layer - .findings() - .iter() - .any(|finding| finding.object_id() == object_id) - && self.document.presentation().layer(layer.id()).locked - }) || self.document.segmentation_layers().iter().any(|layer| { - layer - .segments() - .iter() - .any(|segment| segment.object_id() == object_id) - && self.document.presentation().layer(layer.id()).locked - }) - } -} - -fn annotation_group_geometry( - group: &dicom_viewer_core::AnnotationGroup, -) -> Option { - match group.geometry() { - AnnotationGeometry::Points(_) => Some(AnnotationClassGeometry::Point), - AnnotationGeometry::Polygons(_) => Some(AnnotationClassGeometry::Region), - AnnotationGeometry::ReadOnly { graphic_type, .. } => match graphic_type { - dicom_viewer_core::AnnotationGraphicType::Point => Some(AnnotationClassGeometry::Point), - dicom_viewer_core::AnnotationGraphicType::Polygon => { - Some(AnnotationClassGeometry::Region) - } - dicom_viewer_core::AnnotationGraphicType::Polyline - | dicom_viewer_core::AnnotationGraphicType::Ellipse - | dicom_viewer_core::AnnotationGraphicType::Rectangle => None, - }, - } -} - -fn prepare_annotation_objects( - document: &AnnotationDocument, -) -> ViewerResult> { - let mut objects = Vec::new(); - for group in document.groups() { - let Some(geometry) = annotation_group_geometry(group) else { - continue; - }; - let class_key = annotation_class_concept_key( - geometry, - group.category(), - group.property_type(), - group.property_type_modifiers(), - ) - .to_string(); - let source_frame = SourceFrameContext::new( - (group.referenced_optical_paths().len() == 1) - .then(|| group.referenced_optical_paths()[0].clone()), - None, - None, - None, - ); - match group.geometry() { - AnnotationGeometry::Points(points) => { - for (index, point) in points.iter().copied().enumerate() { - let point = document.canonical_level0_pixel( - document.source(), - point.x, - point.y, - None, - )?; - objects.push(PreparedExternalObject { - source_object_id: format!("{}:{}", group.uid(), index + 1), - label: group.label().to_owned(), - class_key: class_key.clone(), - geometry: Some(geometry), - promotion: Some(PreparedExternalPromotion::Vector { - geometry: VectorFindingGeometry::Point(point), - tracking: None, - source_frame: source_frame.clone(), - }), - }); - } - } - AnnotationGeometry::Polygons(polygons) => { - for (index, polygon) in polygons.iter().enumerate() { - let polygon = polygon - .iter() - .map(|point| { - document - .canonical_level0_pixel(document.source(), point.x, point.y, None) - .map_err(dicom_viewer_core::ViewerError::from) - }) - .collect::>>()?; - objects.push(PreparedExternalObject { - source_object_id: format!("{}:{}", group.uid(), index + 1), - label: group.label().to_owned(), - class_key: class_key.clone(), - geometry: Some(geometry), - promotion: Some(PreparedExternalPromotion::Vector { - geometry: VectorFindingGeometry::regions(vec![polygon]), - tracking: None, - source_frame: source_frame.clone(), - }), - }); - } - } - AnnotationGeometry::ReadOnly { .. } => { - for index in 0..group.annotation_count() { - objects.push(PreparedExternalObject { - source_object_id: format!("{}:{}", group.uid(), index + 1), - label: group.label().to_owned(), - class_key: class_key.clone(), - geometry: Some(geometry), - promotion: None, - }); - } - } - } - } - Ok(objects) -} - -fn prepare_segmentation_objects( - document: &SegmentationDocument, -) -> ViewerResult> { - let mut polygons = BTreeMap::>>::new(); - if document.editable() { - for run in document.binary_runs()? { - let x0 = f64::from(run.column_start()); - let x1 = f64::from(run.column_start().saturating_add(run.length())); - let y0 = f64::from(run.row()); - let y1 = y0 + 1.0; - polygons.entry(run.segment_number()).or_default().push(vec![ - Point2::new(x0, y0), - Point2::new(x1, y0), - Point2::new(x1, y1), - Point2::new(x0, y1), - ]); - } - } - Ok(document - .segments() - .iter() - .enumerate() - .map(|(index, segment)| { - let number = segment - .source_segment_number() - .unwrap_or_else(|| u16::try_from(index + 1).unwrap_or(u16::MAX)); - let class_key = annotation_class_concept_key( - AnnotationClassGeometry::Region, - segment.category(), - segment.property_type(), - segment.property_type_modifiers(), - ) - .to_string(); - let tracking = segment - .tracking_id() - .zip(segment.tracking_uid()) - .and_then(|(id, uid)| TrackingIdentity::new(id, uid).ok()); - let primitives = polygons.remove(&number).map(|polygons| { - polygons - .into_iter() - .map(|polygon| SegmentationPrimitive::polygon(SegmentOperation::Add, polygon)) - .collect::>() - }); - PreparedExternalObject { - source_object_id: format!("segment:{number}"), - label: segment.label().to_owned(), - class_key, - geometry: Some(AnnotationClassGeometry::Region), - promotion: primitives.filter(|primitives| !primitives.is_empty()).map( - |primitives| PreparedExternalPromotion::Segment { - primitives, - tracking, - source_frame: SourceFrameContext::default(), - }, - ), - } - }) - .collect()) -} - -fn prepare_report_objects( - document: &StructuredReportDocument, -) -> ViewerResult> { - let mut objects = Vec::new(); - for group in document.groups() { - let class_key = annotation_class_concept_key( - AnnotationClassGeometry::Region, - group.finding_category(), - group.finding_type(), - &[], - ) - .to_string(); - let source_tracking = TrackingIdentity::new(group.tracking_id(), group.tracking_uid()).ok(); - for (measurement_index, measurement) in group.measurements().iter().enumerate() { - for (coordinate_index, coordinates) in measurement.coordinates().iter().enumerate() { - if coordinates.graphic() != dicom_viewer_core::CoordinateGraphic::Polyline - || coordinates.points().len() != 2 - { - continue; - } - let source_object_id = format!( - "{}:{}:{}", - group.tracking_uid(), - measurement_index + 1, - coordinate_index + 1 - ); - let physical_length_mm = measurement_length_mm(measurement); - let endpoints = coordinates - .points() - .iter() - .map(|point| { - document - .source() - .slide_coordinate_to_pixel3(point.x, point.y, point.z) - .map_err(dicom_viewer_core::ViewerError::from) - }) - .collect::>>()?; - let promotion = physical_length_mm.map(|physical_length_mm| { - PreparedExternalPromotion::Measurement { - endpoints: [endpoints[0], endpoints[1]], - physical_length_mm, - tracking: source_tracking.clone(), - source_frame: SourceFrameContext::default(), - } - }); - objects.push(PreparedExternalObject { - source_object_id, - label: group.finding_type().meaning().to_owned(), - class_key: class_key.clone(), - geometry: Some(AnnotationClassGeometry::Region), - promotion, - }); - } - } - } - Ok(objects) -} - -fn measurement_length_mm( - measurement: &dicom_viewer_core::StructuredReportMeasurement, -) -> Option { - if measurement.concept().scheme() != "SCT" - || measurement.concept().value() != "410668003" - || measurement.unit().scheme() != "UCUM" - || !measurement.value().is_finite() - || measurement.value() <= 0.0 - { - return None; - } - match measurement.unit().value() { - "mm" => Some(measurement.value()), - "um" | "µm" => Some(measurement.value() / 1_000.0), - _ => None, - } -} - -fn object_distance(document: &WorkspaceDocument, id: Uuid, point: Point2) -> Option { - if let Some(finding) = document.finding(id) { - return Some(match finding.geometry() { - dicom_viewer_core::VectorFindingGeometry::Point(candidate) => { - (candidate.x - point.x).hypot(candidate.y - point.y) - } - dicom_viewer_core::VectorFindingGeometry::Regions(components) => components - .iter() - .map(|component| polygon_distance(component, point)) - .fold(f64::INFINITY, f64::min), - }); - } - if let Some(segment) = document.segment(id) { - let geometry = document.composite_segment(segment.object_id()).ok()?; - return geometry - .components() - .iter() - .map(|component| { - let inside = dicom_viewer_core::polygon_contains_point(component.exterior(), point) - && !component - .holes() - .iter() - .any(|hole| dicom_viewer_core::polygon_contains_point(hole, point)); - if inside { - 0.0 - } else { - std::iter::once(component.exterior()) - .chain(component.holes().iter().map(Vec::as_slice)) - .map(|ring| ring_edge_distance(ring, point)) - .fold(f64::INFINITY, f64::min) - } - }) - .min_by(f64::total_cmp); - } - let measurement = document.measurement(id)?; - let endpoints = measurement.endpoints(); - Some(segment_distance(endpoints[0], endpoints[1], point)) -} - -fn update_handle_candidate( - closest: &mut Option<(f64, Uuid, EditableHandle)>, - target: Point2, - candidate: Point2, - tolerance_squared: f64, - object_id: Uuid, - handle: EditableHandle, -) { - let dx = candidate.x - target.x; - let dy = candidate.y - target.y; - let distance_squared = dx * dx + dy * dy; - if distance_squared > tolerance_squared - || closest - .as_ref() - .is_some_and(|(best, _, _)| *best <= distance_squared) - { - return; - } - *closest = Some((distance_squared, object_id, handle)); -} - -fn polygon_distance(polygon: &[Point2], point: Point2) -> f64 { - if dicom_viewer_core::polygon_contains_point(polygon, point) { - 0.0 - } else { - ring_edge_distance(polygon, point) - } -} - -fn ring_edge_distance(points: &[Point2], point: Point2) -> f64 { - points - .iter() - .zip(points.iter().cycle().skip(1)) - .take(points.len()) - .map(|(start, end)| segment_distance(*start, *end, point)) - .fold(f64::INFINITY, f64::min) -} - -fn segment_distance(start: Point2, end: Point2, point: Point2) -> f64 { - let dx = end.x - start.x; - let dy = end.y - start.y; - let length_squared = dx * dx + dy * dy; - if length_squared <= f64::EPSILON { - return (point.x - start.x).hypot(point.y - start.y); - } - let fraction = - (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0); - let closest = Point2::new(start.x + fraction * dx, start.y + fraction * dy); - (point.x - closest.x).hypot(point.y - closest.y) } #[cfg(test)] diff --git a/apps/dicom-viewer/src/app/workspace/external.rs b/apps/dicom-viewer/src/app/workspace/external.rs new file mode 100644 index 0000000..9a5dd17 --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace/external.rs @@ -0,0 +1,724 @@ +use super::*; + +impl WorkspaceRuntime { + pub(in crate::app) fn add_external_annotation( + &mut self, + name: impl Into, + source_path: Option, + document: AnnotationDocument, + ) -> ViewerResult { + let count = document.groups().len() as u64; + let id = self.upsert_external_layer( + name.into(), + ExternalLayerKind::DicomAnn, + source_path, + None, + count, + )?; + self.external_payloads + .insert(id, ExternalLayerPayload::Annotation(Arc::new(document))); + Ok(id) + } + + pub(in crate::app) fn add_external_pathology( + &mut self, + name: impl Into, + session: PathologySession, + ) -> ViewerResult { + let source_path = Some(session.geojson_path().to_path_buf()); + let source_digest = Some(session.semantic_digest().to_owned()); + let count = session.preview().features().len() as u64; + let id = self.upsert_external_layer( + name.into(), + ExternalLayerKind::ProfiledGeoJson, + source_path, + source_digest, + count, + )?; + self.external_payloads + .insert(id, ExternalLayerPayload::ProfiledGeoJson(Arc::new(session))); + Ok(id) + } + + pub(in crate::app) fn add_external_segmentation( + &mut self, + name: impl Into, + source_path: Option, + document: SegmentationDocument, + ) -> ViewerResult<(Uuid, Vec)> { + let count = document.segments().len() as u64; + let id = self.upsert_external_layer( + name.into(), + ExternalLayerKind::DicomSeg, + source_path, + None, + count, + )?; + let (vector_groups, diagnostics) = + if document.kind() == dicom_viewer_core::SegmentationKind::Fractional { + (None, Vec::new()) + } else { + let projection = + document.vectorized_annotations(SegToAnnConversionPolicy::AllowLoss)?; + let diagnostics = projection.diagnostics().to_vec(); + (Some(Arc::from(projection.into_groups())), diagnostics) + }; + self.external_payloads.insert( + id, + ExternalLayerPayload::Segmentation { + document: Arc::new(document), + vector_groups, + }, + ); + Ok((id, diagnostics)) + } + + pub(in crate::app) fn add_external_report( + &mut self, + name: impl Into, + source_path: Option, + session: ReportSession, + ) -> ViewerResult { + let count = session.document().groups().len() as u64; + let id = self.upsert_external_layer( + name.into(), + ExternalLayerKind::DicomSr, + source_path, + None, + count, + )?; + self.external_payloads + .insert(id, ExternalLayerPayload::Report(Arc::new(session))); + Ok(id) + } + + pub(in crate::app) fn add_external_heatmap( + &mut self, + name: impl Into, + session: RasterSession, + context: &eframe::egui::Context, + ) -> ViewerResult { + let source_path = Some(session.raster_path().to_path_buf()); + let source_digest = Some(session.semantic_digest().to_owned()); + let count = u64::from(session.frame_count()) + .saturating_mul(u64::try_from(session.selected_channel_count()).unwrap_or(u64::MAX)); + let id = self.upsert_external_layer( + name.into(), + ExternalLayerKind::Heatmap, + source_path, + source_digest, + count, + )?; + let texture = session.load_texture(context); + self.external_payloads.insert( + id, + ExternalLayerPayload::Heatmap { + session: Arc::new(session), + texture, + }, + ); + Ok(id) + } + + fn upsert_external_layer( + &mut self, + name: String, + kind: ExternalLayerKind, + source_path: Option, + source_digest: Option, + source_object_count: u64, + ) -> ViewerResult { + if let Some(id) = self.matching_external_layer(&kind, source_path.as_deref()) { + self.edit("Load source layer", |workspace| { + workspace.hydrate_external_layer(id, source_object_count, source_digest) + })?; + return Ok(id); + } + let reference = ExternalLayerReference::new( + name, + kind, + source_path, + source_digest, + source_object_count, + ); + let id = reference.id(); + self.edit("Import source layer", |document| { + document.add_external_layer(reference) + })?; + Ok(id) + } + + pub(in crate::app) fn ensure_discovered_external_stub( + &mut self, + name: impl Into, + kind: ExternalLayerKind, + source_path: PathBuf, + ) -> ViewerResult { + if let Some(id) = self.matching_external_layer(&kind, Some(&source_path)) { + return Ok(id); + } + let reference = ExternalLayerReference::new(name, kind, Some(source_path), None, 0); + let id = reference.id(); + let mut candidate = (*self.document).clone(); + candidate.add_external_layer(reference)?; + candidate.validate()?; + self.document = Arc::new(candidate); + self.invalidate_spatial_index(); + Ok(id) + } + + pub(in crate::app) fn remove_external_layer(&mut self, layer_id: Uuid) -> ViewerResult { + self.edit("Remove source layer", |document| { + document.remove_external_layer(layer_id) + }) + } + + #[must_use] + pub(in crate::app) fn external_payload(&self, layer_id: Uuid) -> Option<&ExternalLayerPayload> { + self.external_payloads.get(&layer_id) + } + + fn matching_external_layer( + &self, + kind: &ExternalLayerKind, + source_path: Option<&Path>, + ) -> Option { + let source_path = source_path?; + self.document + .external_layers() + .iter() + .find(|layer| layer.kind() == kind && layer.source_path() == Some(source_path)) + .map(ExternalLayerReference::id) + } + + pub(in crate::app) fn external_classes( + &self, + layer_id: Uuid, + ) -> ViewerResult> { + let objects = self.prepare_external_objects(layer_id)?; + let mut classes = BTreeMap::::new(); + for object in objects { + let Some(geometry) = object.geometry else { + continue; + }; + let editable = object.promotion.is_some(); + let entry = classes.entry(object.class_key.clone()).or_insert_with(|| { + let exact_scheme_class_id = self + .document + .scheme() + .classes() + .iter() + .find(|class| class.concept_key().as_str() == object.class_key) + .map(|class| class.id().to_owned()); + ExternalClassDescriptor { + key: object.class_key, + label: object.label, + geometry, + object_count: 0, + exact_scheme_class_id, + editable, + } + }); + entry.object_count = entry.object_count.saturating_add(1); + entry.editable &= editable; + } + Ok(classes.into_values().collect()) + } + + pub(in crate::app) fn external_objects( + &self, + layer_id: Uuid, + ) -> ViewerResult> { + self.prepare_external_objects(layer_id).map(|objects| { + objects + .into_iter() + .map(|object| ExternalObjectDescriptor { + promoted: self.source_object_was_promoted(layer_id, &object.source_object_id), + promotable: object.promotion.is_some(), + promotion_block_reason: object.promotion_block_reason, + source_object_id: object.source_object_id, + label: object.label, + class_key: object.class_key, + }) + .collect() + }) + } + + pub(in crate::app) fn set_external_class_mapping( + &mut self, + layer_id: Uuid, + source_class_key: &str, + target_class_id: &str, + ) -> ViewerResult<()> { + let source = self + .external_classes(layer_id)? + .into_iter() + .find(|class| class.key == source_class_key) + .ok_or_else(|| { + ViewerError::InvalidInput("the external source class does not exist".into()) + })?; + let target = self + .document + .scheme() + .class(target_class_id) + .ok_or_else(|| { + ViewerError::InvalidInput( + "the mapping target is not in the pinned annotation scheme".into(), + ) + })?; + if source.geometry != target.geometry() { + return Err(ViewerError::InvalidInput( + "external class mappings cannot change point/region geometry".into(), + )); + } + let source_class_key = source_class_key.to_owned(); + let target_class_id = target_class_id.to_owned(); + self.edit("Map external class", move |document| { + document.set_external_class_mapping(layer_id, &source_class_key, &target_class_id) + }) + } + + pub(in crate::app) fn promote_external_object( + &mut self, + layer_id: Uuid, + source_object_id: &str, + ) -> ViewerResult { + if self.source_object_was_promoted(layer_id, source_object_id) { + return Err(ViewerError::InvalidInput( + "the selected source object is already a tracked finding".into(), + )); + } + let object = self + .prepare_external_objects(layer_id)? + .into_iter() + .find(|object| object.source_object_id == source_object_id) + .ok_or_else(|| { + ViewerError::InvalidInput("the external source object does not exist".into()) + })?; + let class_id = self.external_mapping_target(layer_id, &object.class_key)?; + let promotion = object.promotion.ok_or_else(|| { + ViewerError::Unsupported(object.promotion_block_reason.unwrap_or_else(|| { + "the selected external object cannot be represented by an editable workspace geometry" + .into() + })) + })?; + let vector_layer = self.active_vector_layer; + let source_object_id = object.source_object_id; + let id = self.edit("Promote source object", move |document| { + apply_external_promotion( + document, + vector_layer, + layer_id, + source_object_id, + &class_id, + promotion, + ) + })?; + if self.document.segment(id).is_some() { + self.active_segmentation_layer = self + .document + .segmentation_layers() + .iter() + .find(|layer| { + layer + .segments() + .iter() + .any(|segment| segment.object_id() == id) + }) + .map(|layer| layer.id()); + } + self.select_only(id); + Ok(id) + } + + pub(in crate::app) fn make_external_layer_editable( + &mut self, + layer_id: Uuid, + ) -> ViewerResult> { + let objects = self.prepare_external_objects(layer_id)?; + if objects.is_empty() { + return Err(ViewerError::InvalidInput( + "the external layer contains no convertible objects".into(), + )); + } + if objects + .iter() + .any(|object| self.source_object_was_promoted(layer_id, &object.source_object_id)) + { + return Err(ViewerError::InvalidInput( + "the external layer already contains promoted objects; promote the remaining objects individually".into(), + )); + } + let prepared = objects + .into_iter() + .map(|object| { + let class_id = self.external_mapping_target(layer_id, &object.class_key)?; + let promotion = object.promotion.ok_or_else(|| { + ViewerError::Unsupported(object.promotion_block_reason.unwrap_or_else(|| { + format!( + "source object {} cannot be converted losslessly", + object.source_object_id + ) + })) + })?; + Ok((object.source_object_id, class_id, promotion)) + }) + .collect::>>()?; + let vector_layer = self.active_vector_layer; + let ids = self.edit("Make external layer editable", move |document| { + prepared + .into_iter() + .map(|(source_object_id, class_id, promotion)| { + apply_external_promotion( + document, + vector_layer, + layer_id, + source_object_id, + &class_id, + promotion, + ) + }) + .collect::>>() + })?; + self.selection = ids.iter().copied().collect(); + if ids.iter().any(|id| self.document.segment(*id).is_some()) { + self.active_segmentation_layer = self + .document + .segmentation_layers() + .iter() + .find(|layer| { + layer + .segments() + .iter() + .any(|segment| ids.contains(&segment.object_id())) + }) + .map(|layer| layer.id()); + } + Ok(ids) + } + + fn external_mapping_target( + &self, + layer_id: Uuid, + source_class_key: &str, + ) -> ViewerResult { + self.document + .external_layers() + .iter() + .find(|layer| layer.id() == layer_id) + .and_then(|layer| layer.class_mappings().get(source_class_key)) + .cloned() + .ok_or_else(|| { + ViewerError::InvalidInput( + "every source class needs an explicit mapping before promotion".into(), + ) + }) + } + + fn prepare_external_objects( + &self, + layer_id: Uuid, + ) -> ViewerResult> { + if !self + .document + .external_layers() + .iter() + .any(|layer| layer.id() == layer_id) + { + return Err(ViewerError::InvalidInput( + "the external source layer does not exist".into(), + )); + } + match self.external_payloads.get(&layer_id) { + Some(ExternalLayerPayload::Annotation(document)) => { + prepare_annotation_objects(document) + } + Some(ExternalLayerPayload::ProfiledGeoJson(session)) => session + .editable_ann() + .map_or_else(|| Ok(Vec::new()), prepare_annotation_objects), + Some(ExternalLayerPayload::Segmentation { document, .. }) => { + prepare_segmentation_objects(document) + } + Some(ExternalLayerPayload::Report(session)) => { + prepare_report_objects(session.document()) + } + Some(ExternalLayerPayload::Heatmap { .. }) => Ok(Vec::new()), + None => Ok(Vec::new()), + } + } + + fn source_object_was_promoted(&self, layer_id: Uuid, source_object_id: &str) -> bool { + self.document + .vector_findings() + .map(|finding| finding.provenance()) + .chain(self.document.segments().map(|segment| segment.provenance())) + .chain( + self.document + .measurements() + .iter() + .map(|measurement| measurement.provenance()), + ) + .any(|provenance| { + matches!( + provenance, + WorkspaceObjectProvenance::Promoted { + source_layer_id, + source_object_id: promoted_id, + } if *source_layer_id == layer_id && promoted_id == source_object_id + ) + }) + } +} + +fn annotation_group_geometry( + group: &dicom_viewer_core::AnnotationGroup, +) -> Option { + match group.geometry() { + AnnotationGeometry::Points(_) => Some(AnnotationClassGeometry::Point), + AnnotationGeometry::Polygons(_) => Some(AnnotationClassGeometry::Region), + AnnotationGeometry::ReadOnly { graphic_type, .. } => match graphic_type { + dicom_viewer_core::AnnotationGraphicType::Point => Some(AnnotationClassGeometry::Point), + dicom_viewer_core::AnnotationGraphicType::Polygon => { + Some(AnnotationClassGeometry::Region) + } + dicom_viewer_core::AnnotationGraphicType::Polyline + | dicom_viewer_core::AnnotationGraphicType::Ellipse + | dicom_viewer_core::AnnotationGraphicType::Rectangle => None, + }, + } +} + +fn prepare_annotation_objects( + document: &AnnotationDocument, +) -> ViewerResult> { + let mut objects = Vec::new(); + for group in document.groups() { + let Some(geometry) = annotation_group_geometry(group) else { + continue; + }; + let class_key = annotation_class_concept_key( + geometry, + group.category(), + group.property_type(), + group.property_type_modifiers(), + ) + .to_string(); + let (source_frame, promotion_block_reason) = match SourceFrameContext::from_ann_group(group) + { + Ok(source_frame) => (Some(source_frame), None), + Err(ViewerError::Unsupported(reason) | ViewerError::InvalidInput(reason)) => { + (None, Some(reason)) + } + Err(error) => (None, Some(error.to_string())), + }; + match group.geometry() { + AnnotationGeometry::Points(points) => { + for (index, point) in points.iter().copied().enumerate() { + let point = document.canonical_level0_pixel( + document.source(), + point.x, + point.y, + None, + )?; + objects.push(PreparedExternalObject { + source_object_id: format!("{}:{}", group.uid(), index + 1), + label: group.label().to_owned(), + class_key: class_key.clone(), + geometry: Some(geometry), + promotion: source_frame.clone().map(|source_frame| { + PreparedExternalPromotion::Vector { + geometry: VectorFindingGeometry::Point(point), + tracking: None, + source_frame, + } + }), + promotion_block_reason: promotion_block_reason.clone(), + }); + } + } + AnnotationGeometry::Polygons(polygons) => { + for (index, polygon) in polygons.iter().enumerate() { + let polygon = polygon + .iter() + .map(|point| { + document + .canonical_level0_pixel(document.source(), point.x, point.y, None) + .map_err(dicom_viewer_core::ViewerError::from) + }) + .collect::>>()?; + objects.push(PreparedExternalObject { + source_object_id: format!("{}:{}", group.uid(), index + 1), + label: group.label().to_owned(), + class_key: class_key.clone(), + geometry: Some(geometry), + promotion: source_frame.clone().map(|source_frame| { + PreparedExternalPromotion::Vector { + geometry: VectorFindingGeometry::regions(vec![polygon]), + tracking: None, + source_frame, + } + }), + promotion_block_reason: promotion_block_reason.clone(), + }); + } + } + AnnotationGeometry::ReadOnly { .. } => { + for index in 0..group.annotation_count() { + objects.push(PreparedExternalObject { + source_object_id: format!("{}:{}", group.uid(), index + 1), + label: group.label().to_owned(), + class_key: class_key.clone(), + geometry: Some(geometry), + promotion: None, + promotion_block_reason: Some( + "the ANN graphic type is viewable but has no editable workspace geometry" + .into(), + ), + }); + } + } + } + } + Ok(objects) +} + +fn prepare_segmentation_objects( + document: &SegmentationDocument, +) -> ViewerResult> { + let mut polygons = BTreeMap::>>::new(); + if document.editable() { + for run in document.binary_runs()? { + let x0 = f64::from(run.column_start()); + let x1 = f64::from(run.column_start().saturating_add(run.length())); + let y0 = f64::from(run.row()); + let y1 = y0 + 1.0; + polygons.entry(run.segment_number()).or_default().push(vec![ + Point2::new(x0, y0), + Point2::new(x1, y0), + Point2::new(x1, y1), + Point2::new(x0, y1), + ]); + } + } + Ok(document + .segments() + .iter() + .enumerate() + .map(|(index, segment)| { + let number = segment + .source_segment_number() + .unwrap_or_else(|| u16::try_from(index + 1).unwrap_or(u16::MAX)); + let class_key = annotation_class_concept_key( + AnnotationClassGeometry::Region, + segment.category(), + segment.property_type(), + segment.property_type_modifiers(), + ) + .to_string(); + let tracking = segment + .tracking_id() + .zip(segment.tracking_uid()) + .and_then(|(id, uid)| TrackingIdentity::new(id, uid).ok()); + let primitives = polygons.remove(&number).map(|polygons| { + polygons + .into_iter() + .map(|polygon| SegmentationPrimitive::polygon(SegmentOperation::Add, polygon)) + .collect::>() + }); + PreparedExternalObject { + source_object_id: format!("segment:{number}"), + label: segment.label().to_owned(), + class_key, + geometry: Some(AnnotationClassGeometry::Region), + promotion: primitives.filter(|primitives| !primitives.is_empty()).map( + |primitives| PreparedExternalPromotion::Segment { + primitives, + tracking, + source_frame: SourceFrameContext::default(), + }, + ), + promotion_block_reason: (!document.editable()) + .then(|| "the SEG object is viewable but is not losslessly editable".into()), + } + }) + .collect()) +} + +fn prepare_report_objects( + document: &StructuredReportDocument, +) -> ViewerResult> { + let mut objects = Vec::new(); + for group in document.groups() { + let class_key = annotation_class_concept_key( + AnnotationClassGeometry::Region, + group.finding_category(), + group.finding_type(), + &[], + ) + .to_string(); + let source_tracking = TrackingIdentity::new(group.tracking_id(), group.tracking_uid()).ok(); + for (measurement_index, measurement) in group.measurements().iter().enumerate() { + for (coordinate_index, coordinates) in measurement.coordinates().iter().enumerate() { + if coordinates.graphic() != dicom_viewer_core::CoordinateGraphic::Polyline + || coordinates.points().len() != 2 + { + continue; + } + let source_object_id = format!( + "{}:{}:{}", + group.tracking_uid(), + measurement_index + 1, + coordinate_index + 1 + ); + let physical_length_mm = measurement_length_mm(measurement); + let endpoints = coordinates + .points() + .iter() + .map(|point| { + document + .source() + .slide_coordinate_to_pixel3(point.x, point.y, point.z) + .map_err(dicom_viewer_core::ViewerError::from) + }) + .collect::>>()?; + let promotion = physical_length_mm.map(|physical_length_mm| { + PreparedExternalPromotion::Measurement { + endpoints: [endpoints[0], endpoints[1]], + physical_length_mm, + tracking: source_tracking.clone(), + source_frame: SourceFrameContext::default(), + } + }); + objects.push(PreparedExternalObject { + source_object_id, + label: group.finding_type().meaning().to_owned(), + class_key: class_key.clone(), + geometry: Some(AnnotationClassGeometry::Region), + promotion, + promotion_block_reason: physical_length_mm + .is_none() + .then(|| "the SR measurement has no supported physical length".into()), + }); + } + } + } + Ok(objects) +} + +fn measurement_length_mm( + measurement: &dicom_viewer_core::StructuredReportMeasurement, +) -> Option { + if measurement.concept().scheme() != "SCT" + || measurement.concept().value() != "410668003" + || measurement.unit().scheme() != "UCUM" + || !measurement.value().is_finite() + || measurement.value() <= 0.0 + { + return None; + } + match measurement.unit().value() { + "mm" => Some(measurement.value()), + "um" | "µm" => Some(measurement.value() / 1_000.0), + _ => None, + } +} diff --git a/apps/dicom-viewer/src/app/workspace/geometry.rs b/apps/dicom-viewer/src/app/workspace/geometry.rs new file mode 100644 index 0000000..01ba93b --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace/geometry.rs @@ -0,0 +1,295 @@ +use super::*; + +impl WorkspaceRuntime { + pub(in crate::app) fn begin_handle_drag(&mut self, point: Point2, tolerance: f64) -> bool { + let tolerance_squared = tolerance * tolerance; + let mut closest: Option<(f64, Uuid, EditableHandle)> = None; + for id in self.selection.iter().copied() { + if self.object_locked(id) { + continue; + } + if let Some(finding) = self.document.finding(id) { + match finding.geometry() { + VectorFindingGeometry::Point(candidate) => update_handle_candidate( + &mut closest, + point, + *candidate, + tolerance_squared, + id, + EditableHandle::PointFinding, + ), + VectorFindingGeometry::Regions(components) => { + for (component_index, component) in components.iter().enumerate() { + for (vertex_index, candidate) in component.iter().copied().enumerate() { + update_handle_candidate( + &mut closest, + point, + candidate, + tolerance_squared, + id, + EditableHandle::PolygonVertex { + component_index, + vertex_index, + }, + ); + } + } + } + } + } else if let Some(segment) = self.document.segment(id) { + for (primitive_index, primitive) in segment.primitives().iter().enumerate() { + let points = match primitive.geometry() { + SegmentationPrimitiveGeometry::Polygon { points } => points.as_ref(), + SegmentationPrimitiveGeometry::Brush { centerline, .. } => { + centerline.as_ref() + } + }; + for (point_index, candidate) in points.iter().copied().enumerate() { + update_handle_candidate( + &mut closest, + point, + candidate, + tolerance_squared, + id, + EditableHandle::SegmentPrimitivePoint { + primitive_index, + point_index, + }, + ); + } + } + } else if let Some(measurement) = self.document.measurement(id) { + for (endpoint_index, candidate) in measurement.endpoints().into_iter().enumerate() { + update_handle_candidate( + &mut closest, + point, + candidate, + tolerance_squared, + id, + EditableHandle::MeasurementEndpoint { endpoint_index }, + ); + } + } + } + let Some((_, object_id, handle)) = closest else { + return false; + }; + self.handle_drag = Some(HandleDrag { + object_id, + handle, + before: Arc::clone(&self.document), + }); + true + } + + pub(in crate::app) fn update_handle_drag( + &mut self, + point: Point2, + physical_length_mm: impl FnOnce(Point2, Point2) -> Option, + ) -> ViewerResult<()> { + let Some(drag) = &self.handle_drag else { + return Ok(()); + }; + let object_id = drag.object_id; + let handle = drag.handle; + let mut candidate = (*self.document).clone(); + match handle { + EditableHandle::PointFinding => { + candidate + .replace_vector_geometry(object_id, VectorFindingGeometry::Point(point))?; + } + EditableHandle::PolygonVertex { + component_index, + vertex_index, + } => { + candidate.move_vector_vertex(object_id, component_index, vertex_index, point)?; + } + EditableHandle::MeasurementEndpoint { endpoint_index } => { + let mut endpoints = candidate + .measurement(object_id) + .ok_or_else(|| { + ViewerError::InvalidInput("the dragged measurement no longer exists".into()) + })? + .endpoints(); + endpoints[endpoint_index] = point; + let length = physical_length_mm(endpoints[0], endpoints[1]); + candidate.set_measurement_endpoints(object_id, endpoints, length)?; + } + EditableHandle::SegmentPrimitivePoint { + primitive_index, + point_index, + } => { + candidate.move_segment_primitive_point( + object_id, + primitive_index, + point_index, + point, + )?; + } + } + candidate.validate()?; + self.document = Arc::new(candidate); + self.invalidate_spatial_index(); + Ok(()) + } + + pub(in crate::app) fn finish_handle_drag(&mut self) -> bool { + let Some(drag) = self.handle_drag.take() else { + return false; + }; + if drag.before.revision() == self.document.revision() { + return false; + } + self.history.record( + "Move geometry handle", + drag.before, + Arc::clone(&self.document), + ); + true + } + + pub(in crate::app) fn cancel_handle_drag(&mut self) -> bool { + let Some(drag) = self.handle_drag.take() else { + return false; + }; + self.document = drag.before; + self.invalidate_spatial_index(); + true + } + + #[must_use] + pub(in crate::app) const fn handle_drag_active(&self) -> bool { + self.handle_drag.is_some() + } + + pub(in crate::app) fn refresh_spatial_index(&mut self) -> ViewerResult<()> { + if self.spatial_revision != self.document.revision() { + self.spatial_index = WorkspaceSpatialIndex::build(&self.document)?; + self.spatial_revision = self.document.revision(); + } + Ok(()) + } + + #[must_use] + pub(in crate::app) fn spatial_index(&self) -> &WorkspaceSpatialIndex { + &self.spatial_index + } + + #[must_use] + pub(in crate::app) fn hit_test(&self, point: Point2, tolerance: f64) -> Option { + let query = [ + point.x - tolerance, + point.y - tolerance, + point.x + tolerance, + point.y + tolerance, + ]; + self.spatial_index + .query(query, 256) + .into_iter() + .filter_map(|id| { + object_distance(self.document(), id, point).map(|distance| (id, distance)) + }) + .filter(|(_, distance)| *distance <= tolerance) + .min_by(|left, right| left.1.total_cmp(&right.1)) + .map(|(id, _)| id) + } + + #[must_use] + pub(in crate::app) fn object_bounds(&self, id: Uuid) -> Option<[f64; 4]> { + self.spatial_index.bounds(id) + } + + pub(in crate::app) fn invalidate_spatial_index(&mut self) { + self.spatial_revision = u64::MAX; + } +} + +fn object_distance(document: &WorkspaceDocument, id: Uuid, point: Point2) -> Option { + if let Some(finding) = document.finding(id) { + return Some(match finding.geometry() { + dicom_viewer_core::VectorFindingGeometry::Point(candidate) => { + (candidate.x - point.x).hypot(candidate.y - point.y) + } + dicom_viewer_core::VectorFindingGeometry::Regions(components) => components + .iter() + .map(|component| polygon_distance(component, point)) + .fold(f64::INFINITY, f64::min), + }); + } + if let Some(segment) = document.segment(id) { + let geometry = document.composite_segment(segment.object_id()).ok()?; + return geometry + .components() + .iter() + .map(|component| { + let inside = dicom_viewer_core::polygon_contains_point(component.exterior(), point) + && !component + .holes() + .iter() + .any(|hole| dicom_viewer_core::polygon_contains_point(hole, point)); + if inside { + 0.0 + } else { + std::iter::once(component.exterior()) + .chain(component.holes().iter().map(Vec::as_slice)) + .map(|ring| ring_edge_distance(ring, point)) + .fold(f64::INFINITY, f64::min) + } + }) + .min_by(f64::total_cmp); + } + let measurement = document.measurement(id)?; + let endpoints = measurement.endpoints(); + Some(segment_distance(endpoints[0], endpoints[1], point)) +} + +fn update_handle_candidate( + closest: &mut Option<(f64, Uuid, EditableHandle)>, + target: Point2, + candidate: Point2, + tolerance_squared: f64, + object_id: Uuid, + handle: EditableHandle, +) { + let dx = candidate.x - target.x; + let dy = candidate.y - target.y; + let distance_squared = dx * dx + dy * dy; + if distance_squared > tolerance_squared + || closest + .as_ref() + .is_some_and(|(best, _, _)| *best <= distance_squared) + { + return; + } + *closest = Some((distance_squared, object_id, handle)); +} + +fn polygon_distance(polygon: &[Point2], point: Point2) -> f64 { + if dicom_viewer_core::polygon_contains_point(polygon, point) { + 0.0 + } else { + ring_edge_distance(polygon, point) + } +} + +fn ring_edge_distance(points: &[Point2], point: Point2) -> f64 { + points + .iter() + .zip(points.iter().cycle().skip(1)) + .take(points.len()) + .map(|(start, end)| segment_distance(*start, *end, point)) + .fold(f64::INFINITY, f64::min) +} + +fn segment_distance(start: Point2, end: Point2, point: Point2) -> f64 { + let dx = end.x - start.x; + let dy = end.y - start.y; + let length_squared = dx * dx + dy * dy; + if length_squared <= f64::EPSILON { + return (point.x - start.x).hypot(point.y - start.y); + } + let fraction = + (((point.x - start.x) * dx + (point.y - start.y) * dy) / length_squared).clamp(0.0, 1.0); + let closest = Point2::new(start.x + fraction * dx, start.y + fraction * dy); + (point.x - closest.x).hypot(point.y - closest.y) +} diff --git a/apps/dicom-viewer/src/app/workspace/selection.rs b/apps/dicom-viewer/src/app/workspace/selection.rs new file mode 100644 index 0000000..8ea21fa --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace/selection.rs @@ -0,0 +1,132 @@ +use super::*; + +impl WorkspaceRuntime { + #[must_use] + pub(in crate::app) fn selection(&self) -> &HashSet { + &self.selection + } + + pub(in crate::app) fn select_only(&mut self, object_id: Uuid) { + self.selection.clear(); + self.selection.insert(object_id); + } + + pub(in crate::app) fn toggle_selection(&mut self, object_id: Uuid) { + if !self.selection.remove(&object_id) { + self.selection.insert(object_id); + } + } + + pub(in crate::app) fn clear_selection(&mut self) { + self.selection.clear(); + } + + pub(in crate::app) fn selected_segment(&self) -> Option { + self.selection.iter().copied().find(|id| { + self.document.object(*id).is_some_and(|object| { + object.geometry_kind() + == dicom_viewer_core::WorkspaceObjectGeometryKind::Segmentation + }) + }) + } + + pub(in crate::app) fn delete_selection(&mut self) -> ViewerResult { + let ids = self.selection.iter().copied().collect::>(); + if ids.is_empty() { + return Ok(0); + } + self.ensure_objects_editable(&ids)?; + let deleted = self.edit("Delete selection", |document| { + let mut count = 0; + for id in &ids { + count += usize::from(document.delete_object(*id)?); + } + Ok(count) + })?; + self.clear_selection(); + Ok(deleted) + } + + pub(in crate::app) fn reclassify_selection(&mut self, class_id: &str) -> ViewerResult { + let ids = self.selection.iter().copied().collect::>(); + if ids.is_empty() { + return Ok(0); + } + self.ensure_objects_editable(&ids)?; + self.edit("Reclassify selection", |document| { + for id in &ids { + document.reclassify_object(*id, class_id)?; + } + Ok(ids.len()) + }) + } + + pub(in crate::app) fn set_selected_name(&mut self, name: Option<&str>) -> ViewerResult<()> { + let id = self.single_selection()?; + self.ensure_objects_editable(&[id])?; + self.edit("Rename finding", |document| { + document.set_object_name(id, name) + }) + } + + pub(in crate::app) fn set_selected_comment( + &mut self, + comment: Option<&str>, + ) -> ViewerResult<()> { + let id = self.single_selection()?; + self.ensure_objects_editable(&[id])?; + self.edit("Edit finding comment", |document| { + document.set_object_comment(id, comment) + }) + } + + pub(in crate::app) fn set_selected_finding_site( + &mut self, + site: Option<&dicom_viewer_core::DicomCode>, + ) -> ViewerResult<()> { + let id = self.single_selection()?; + self.ensure_objects_editable(&[id])?; + self.edit("Set finding site", |document| { + document.set_object_finding_site(id, site) + }) + } + + pub(in crate::app) fn single_selection(&self) -> ViewerResult { + if self.selection.len() != 1 { + return Err(ViewerError::InvalidInput( + "select exactly one tracked object".into(), + )); + } + Ok(*self + .selection + .iter() + .next() + .expect("one selected object exists")) + } + + pub(in crate::app) fn ensure_layer_editable(&self, layer_id: Uuid) -> ViewerResult<()> { + if self.document.presentation().layer(layer_id).locked { + return Err(ViewerError::InvalidInput( + "the active annotation layer is locked".into(), + )); + } + Ok(()) + } + + pub(in crate::app) fn ensure_objects_editable(&self, object_ids: &[Uuid]) -> ViewerResult<()> { + if object_ids.iter().copied().any(|id| self.object_locked(id)) { + return Err(ViewerError::InvalidInput( + "the selection contains an object on a locked layer".into(), + )); + } + Ok(()) + } + + pub(in crate::app) fn object_locked(&self, object_id: Uuid) -> bool { + self.document + .object_layer_id(object_id) + .ok() + .flatten() + .is_some_and(|layer_id| self.document.presentation().layer(layer_id).locked) + } +} diff --git a/apps/dicom-viewer/src/app/workspace/tests.rs b/apps/dicom-viewer/src/app/workspace/tests.rs index f38b643..8251e7a 100644 --- a/apps/dicom-viewer/src/app/workspace/tests.rs +++ b/apps/dicom-viewer/src/app/workspace/tests.rs @@ -28,6 +28,14 @@ fn square(x: f64) -> VectorFindingGeometry { ]]) } +#[test] +fn a_new_workspace_starts_in_pan_mode() { + let runtime = + WorkspaceRuntime::new(source(), AnnotationScheme::general_pathology_v1()).unwrap(); + + assert_eq!(runtime.active_tool(), ActiveTool::Pan); +} + #[test] fn history_undoes_and_redoes_whole_commands_and_new_edits_invalidate_redo() { let mut runtime = WorkspaceRuntime::with_history_limits( @@ -508,6 +516,58 @@ fn imported_ann_requires_explicit_complete_mapping_before_atomic_conversion() { .is_err()); } +#[test] +fn imported_multi_optical_ann_remains_viewable_with_precise_promotion_block_reason() { + let directory = tempfile::tempdir().unwrap(); + let source_path = directory.path().join("source.dcm"); + crate::app::tests::write_source_wsi_with_optical_paths(&source_path, &["A", "B"]); + let context = DicomAnnotationContext::from_source(&source_path).unwrap(); + let scheme = AnnotationScheme::general_pathology_v1(); + let neoplasm = scheme.class("neoplasm").unwrap(); + let group = AnnotationGroup::polygons( + "Multi-path finding", + neoplasm.category().clone(), + neoplasm.property_type().clone(), + neoplasm.recommended_display_cielab(), + vec![vec![ + Point2::new(1.0, 1.0), + Point2::new(6.0, 1.0), + Point2::new(6.0, 6.0), + Point2::new(1.0, 6.0), + ]], + ) + .unwrap() + .with_referenced_optical_paths(vec!["A".into(), "B".into()]) + .unwrap(); + let source_object_id = format!("{}:1", group.uid()); + let ann = AnnotationDocument::new(context, vec![group]).unwrap(); + let mut runtime = WorkspaceRuntime::new( + ViewerSourceIdentity::new(9, 0, 0, 0, 0, 0, (16, 16)), + scheme, + ) + .unwrap(); + let external = runtime + .add_external_annotation("Imported ANN", Some(source_path), ann) + .unwrap(); + let class = runtime.external_classes(external).unwrap().remove(0); + runtime + .set_external_class_mapping(external, &class.key, "neoplasm") + .unwrap(); + + let object = runtime.external_objects(external).unwrap().remove(0); + assert_eq!(object.source_object_id, source_object_id); + assert!(!object.promotable); + assert!(!object.promoted); + let error = runtime + .promote_external_object(external, &source_object_id) + .unwrap_err(); + assert_eq!( + error.to_string(), + "unsupported input: ANN group references 2 optical paths and remains read-only because promotion would lose applicability" + ); + assert_eq!(runtime.document().object_count(), 0); +} + #[test] fn external_layer_removal_is_undoable_and_retains_the_shared_payload() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/dicom-viewer/src/app/workspace/tools.rs b/apps/dicom-viewer/src/app/workspace/tools.rs new file mode 100644 index 0000000..a6c56b6 --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace/tools.rs @@ -0,0 +1,453 @@ +use super::*; + +impl WorkspaceRuntime { + #[must_use] + pub(in crate::app) const fn active_tool(&self) -> ActiveTool { + self.active_tool + } + + #[cfg(test)] + pub(in crate::app) 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(in crate::app) 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(in crate::app) 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(in crate::app) 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(in crate::app) fn draft(&self) -> Option<&DraftInteraction> { + self.draft.as_ref() + } + + pub(in crate::app) fn set_draft(&mut self, draft: DraftInteraction) { + self.draft = Some(draft); + self.draft_undo.clear(); + } + + pub(in crate::app) 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(in crate::app) 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(in crate::app) 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(in crate::app) 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(in crate::app) fn finish_brush_stroke(&mut self) -> ViewerResult> { + let Some(centerline) = self.brush_stroke.take() else { + return Ok(None); + }; + let layer_id = self.ensure_segmentation_layer()?; + self.ensure_layer_editable(layer_id)?; + let operation = self + .brush_operation + .take() + .unwrap_or(self.segment_operation); + let segment_id = self.selected_segment(); + let primitive = SegmentationPrimitive::brush(operation, centerline, self.brush_diameter); + let id = if let Some(segment_id) = segment_id { + let outcome = self.edit("Brush stroke", |document| { + document.apply_segment_primitive(segment_id, primitive) + })?; + if outcome == dicom_viewer_core::SegmentEditOutcome::NoIntersection { + return Ok(None); + } + segment_id + } else { + let class_id = self.region_class_id.clone(); + self.edit("Add segment", |document| { + document.add_segment(layer_id, &class_id, primitive) + })? + }; + self.select_only(id); + Ok(Some(id)) + } + + pub(in crate::app) fn cancel_pointer_interaction(&mut self) { + self.brush_stroke = None; + self.brush_operation = None; + } + + #[must_use] + pub(in crate::app) fn brush_stroke(&self) -> Option<&[Point2]> { + self.brush_stroke.as_deref() + } + + pub(in crate::app) fn place_ruler_point( + &mut self, + point: Point2, + physical_length_mm: impl FnOnce(Point2, Point2) -> Option, + ) -> ViewerResult> { + let Some(start) = self.ruler_start.take() else { + self.ruler_start = Some(point); + return Ok(None); + }; + let class_id = self.region_class_id.clone(); + let length = physical_length_mm(start, point); + let id = self.edit("Add ruler", |document| { + document.add_linear_measurement(&class_id, [start, point], length) + })?; + self.select_only(id); + Ok(Some(id)) + } + + #[must_use] + pub(in crate::app) const fn ruler_start(&self) -> Option { + self.ruler_start + } + + pub(in crate::app) fn cancel_ruler(&mut self) -> bool { + self.ruler_start.take().is_some() + } + + pub(in crate::app) fn cancel_draft_step(&mut self) -> bool { + let Some(before) = self.draft.as_ref().cloned() else { + return false; + }; + if before.points().is_empty() { + self.draft = None; + return true; + } + if before.points().len() == 1 { + self.draft = None; + self.draft_undo.push(DraftUndoStep::RestoreDraft(before)); + return true; + } + let point = self + .draft + .as_mut() + .and_then(DraftInteraction::pop_point) + .expect("the non-empty polygon draft was checked"); + self.draft_undo.push(DraftUndoStep::AppendPoint(point)); + true + } + + pub(in crate::app) fn undo_draft_cancel(&mut self) -> bool { + let Some(step) = self.draft_undo.pop() else { + return false; + }; + match step { + DraftUndoStep::AppendPoint(point) => { + let Some(draft) = &mut self.draft else { + self.draft_undo.push(DraftUndoStep::AppendPoint(point)); + return false; + }; + draft.push_point(point); + } + DraftUndoStep::RestoreDraft(draft) => { + if self.draft.is_some() { + self.draft_undo.push(DraftUndoStep::RestoreDraft(draft)); + return false; + } + self.draft = Some(draft); + } + } + true + } + + pub(in crate::app) fn discard_draft(&mut self) { + self.draft = None; + self.draft_undo.clear(); + self.pending_tool = None; + } + + #[must_use] + pub(in crate::app) fn active_class_id(&self) -> &str { + match self.active_tool { + ActiveTool::Point => &self.point_class_id, + _ => &self.region_class_id, + } + } + + pub(in crate::app) fn set_active_class( + &mut self, + class_id: impl Into, + ) -> ViewerResult<()> { + if self.draft.is_some() { + return Err(ViewerError::InvalidInput( + "finish, resume, or discard the current polygon before changing class".into(), + )); + } + let class_id = class_id.into(); + let class = self.document.scheme().class(&class_id).ok_or_else(|| { + ViewerError::InvalidInput("selected annotation class does not exist".into()) + })?; + match class.geometry() { + dicom_viewer_core::AnnotationClassGeometry::Region => self.region_class_id = class_id, + dicom_viewer_core::AnnotationClassGeometry::Point => self.point_class_id = class_id, + } + self.draft_undo.clear(); + Ok(()) + } + + pub(in crate::app) fn migrate_scheme( + &mut self, + target: AnnotationScheme, + mappings: &BTreeMap, + ) -> ViewerResult<()> { + if self.draft.is_some() { + return Err(ViewerError::InvalidInput( + "finish, resume, or discard the current polygon before changing annotation scheme" + .into(), + )); + } + self.edit("Migrate annotation scheme", |document| { + document.migrate_scheme(target, mappings) + }) + } + + #[must_use] + pub(in crate::app) const fn segment_operation(&self) -> SegmentOperation { + self.segment_operation + } + + pub(in crate::app) fn set_segment_operation(&mut self, operation: SegmentOperation) { + self.segment_operation = operation; + } + + #[must_use] + pub(in crate::app) const fn brush_diameter(&self) -> f64 { + self.brush_diameter + } + + pub(in crate::app) fn adjust_brush_diameter(&mut self, scale: f64) { + self.brush_diameter = (self.brush_diameter * scale).clamp(1.0, 20_000.0); + } + + #[must_use] + #[cfg(test)] + pub(in crate::app) const fn active_vector_layer(&self) -> Uuid { + self.active_vector_layer + } + + pub(in crate::app) fn ensure_segmentation_layer(&mut self) -> ViewerResult { + if self.draft.is_some() { + return Err(ViewerError::InvalidInput( + "finish, resume, or discard the current polygon before activating Brush".into(), + )); + } + if let Some(id) = self.active_segmentation_layer { + self.editing_representation = EditingRepresentation::Segmentation; + self.draft_undo.clear(); + return Ok(id); + } + let id = self.edit("Create segmentation layer", |document| { + Ok(document.ensure_manual_segmentation_layer()) + })?; + self.active_segmentation_layer = Some(id); + self.editing_representation = EditingRepresentation::Segmentation; + self.draft_undo.clear(); + Ok(id) + } + + #[must_use] + pub(in crate::app) const fn editing_representation(&self) -> EditingRepresentation { + self.editing_representation + } + + pub(in crate::app) fn use_vector_layer(&mut self, layer_id: Uuid) -> ViewerResult<()> { + if self.draft.is_some() { + return Err(ViewerError::InvalidInput( + "finish, resume, or discard the current polygon before changing layers".into(), + )); + } + if !self + .document + .vector_layers() + .iter() + .any(|layer| layer.id() == layer_id) + { + return Err(ViewerError::InvalidInput( + "the selected vector layer does not exist".into(), + )); + } + self.active_vector_layer = layer_id; + self.editing_representation = EditingRepresentation::Vector; + self.draft_undo.clear(); + Ok(()) + } + + pub(in crate::app) fn use_segmentation_layer(&mut self, layer_id: Uuid) -> ViewerResult<()> { + if self.draft.is_some() { + return Err(ViewerError::InvalidInput( + "finish, resume, or discard the current polygon before changing layers".into(), + )); + } + if !self + .document + .segmentation_layers() + .iter() + .any(|layer| layer.id() == layer_id) + { + return Err(ViewerError::InvalidInput( + "the selected segmentation layer does not exist".into(), + )); + } + self.active_segmentation_layer = Some(layer_id); + self.editing_representation = EditingRepresentation::Segmentation; + self.draft_undo.clear(); + Ok(()) + } + + pub(in crate::app) fn begin_new_segment(&mut self) { + self.draft_undo.clear(); + self.clear_selection(); + } +} diff --git a/apps/dicom-viewer/src/app/workspace/transaction.rs b/apps/dicom-viewer/src/app/workspace/transaction.rs new file mode 100644 index 0000000..7d0ea60 --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace/transaction.rs @@ -0,0 +1,142 @@ +use super::*; + +impl WorkspaceRuntime { + pub(in crate::app) fn new( + source_identity: ViewerSourceIdentity, + scheme: AnnotationScheme, + ) -> ViewerResult { + Self::from_document(WorkspaceDocument::new(source_identity, scheme)?) + } + + #[cfg(test)] + pub(in crate::app) 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(in crate::app) 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::Pan, + 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(in crate::app) fn document(&self) -> &WorkspaceDocument { + &self.document + } + + #[must_use] + pub(in crate::app) fn document_snapshot(&self) -> Arc { + Arc::clone(&self.document) + } + + pub(in crate::app) 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(in crate::app) fn undo(&mut self) -> bool { + let Some(document) = self.history.undo() else { + return false; + }; + self.document = document; + self.selection + .retain(|id| self.document.object(*id).is_some()); + self.invalidate_spatial_index(); + true + } + + pub(in crate::app) 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(in crate::app) fn can_undo(&self) -> bool { + self.history.can_undo() + } + + #[must_use] + pub(in crate::app) fn can_redo(&self) -> bool { + self.history.can_redo() + } + + #[must_use] + pub(in crate::app) fn history_truncated(&self) -> bool { + self.history.truncated() + } + + #[must_use] + pub(in crate::app) fn undo_label(&self) -> Option<&str> { + self.history.undo_label() + } + + #[must_use] + pub(in crate::app) fn redo_label(&self) -> Option<&str> { + self.history.redo_label() + } +} diff --git a/apps/dicom-viewer/src/app/workspace_actions.rs b/apps/dicom-viewer/src/app/workspace_actions.rs index ab82d13..4e3bfa7 100644 --- a/apps/dicom-viewer/src/app/workspace_actions.rs +++ b/apps/dicom-viewer/src/app/workspace_actions.rs @@ -71,6 +71,9 @@ impl DicomViewerApp { } } + if actions.export_current_view_tiff { + self.begin_current_view_tiff_export(ctx); + } if actions.export_portable_workspace { self.export_portable_workspace(ctx); } diff --git a/apps/dicom-viewer/src/app/workspace_dialogs.rs b/apps/dicom-viewer/src/app/workspace_dialogs.rs index 815ba6f..fe984c4 100644 --- a/apps/dicom-viewer/src/app/workspace_dialogs.rs +++ b/apps/dicom-viewer/src/app/workspace_dialogs.rs @@ -172,11 +172,21 @@ impl DicomViewerApp { == AnnotationScheme::tumor_mask_compatibility_v1().content_digest(); let mut open = true; let mut action = PathologyWorkspaceActions::default(); - egui::Window::new("Export Pathology") + egui::Window::new("Export") .id(egui::Id::new("pathology-export-wizard")) .open(&mut open) .default_width(430.0) .show(ctx, |ui| { + action.export_current_view_tiff = + ui.button("Current screen view (TIFF)…").clicked(); + ui.label( + RichText::new( + "Captures the visible slide canvas at the current pan and zoom, including pathology overlays but excluding the viewer HUD.", + ) + .small() + .weak(), + ); + ui.separator(); ui.label(format!( "Preflight: {vectors} vector finding(s), {segments} segment(s), {rulers} ruler(s)." )); @@ -229,7 +239,8 @@ impl DicomViewerApp { ui.label(RichText::new("GeoJSON excludes rulers only after explicit eligible-items confirmation.").small().weak()); } }); - let acted = action.export_portable_workspace + let acted = action.export_current_view_tiff + || action.export_portable_workspace || action.export_scheme_geojson || action.export_compatibility_geojson || action.export_ann diff --git a/apps/dicom-viewer/src/app/workspace_interaction.rs b/apps/dicom-viewer/src/app/workspace_interaction.rs index f0cbaf6..10fd1b4 100644 --- a/apps/dicom-viewer/src/app/workspace_interaction.rs +++ b/apps/dicom-viewer/src/app/workspace_interaction.rs @@ -1,9 +1,15 @@ +mod keyboard; +mod pointer; + +#[cfg(test)] +mod tests; + use dicom_viewer_core::{Point2, StudySummary}; use eframe::egui::{self, Rect}; use super::camera::CameraView; use super::viewport::{base_contains_point, screen_to_base}; -use super::workspace::{ActiveTool, ToolTransitionOutcome}; +use super::workspace::ActiveTool; use super::DicomViewerApp; #[derive(Debug, Default)] @@ -29,107 +35,7 @@ impl DicomViewerApp { }; if accepts_keys { - let (modifiers, pressed) = ui.input(|input| { - let pressed = |key| input.key_pressed(key); - ( - input.modifiers, - [ - pressed(egui::Key::V), - pressed(egui::Key::P), - pressed(egui::Key::B), - pressed(egui::Key::K), - pressed(egui::Key::R), - pressed(egui::Key::N), - pressed(egui::Key::Enter), - pressed(egui::Key::Escape), - pressed(egui::Key::Delete), - pressed(egui::Key::Z), - pressed(egui::Key::Y), - pressed(egui::Key::OpenBracket), - pressed(egui::Key::CloseBracket), - ], - ) - }); - let command_handled = if modifiers.command && pressed[9] { - if modifiers.shift { - if runtime.redo() { - self.status = "Redid the last pathology command.".into(); - } - } else if runtime.undo_draft_cancel() { - self.status = "Restored the last cancelled draft step.".into(); - } else if runtime.undo() { - self.status = "Undid the last pathology command.".into(); - } - true - } else if modifiers.ctrl && pressed[10] { - if runtime.redo() { - self.status = "Redid the last pathology command.".into(); - } - true - } else { - false - }; - if !command_handled && !modifiers.command && !modifiers.ctrl && !modifiers.alt { - let requested_tool = if pressed[0] { - Some(ActiveTool::Select) - } else if pressed[1] { - Some(ActiveTool::Polygon) - } else if pressed[2] { - Some(ActiveTool::Brush) - } else if pressed[3] { - Some(ActiveTool::Point) - } else if pressed[4] { - Some(ActiveTool::Ruler) - } else { - None - }; - if let Some(tool) = requested_tool { - if tool == ActiveTool::Brush { - if let Err(error) = runtime.ensure_segmentation_layer() { - self.status = error.to_string(); - } - } - if runtime.request_tool(tool) == ToolTransitionOutcome::BlockedByDraft { - self.status = - "Unfinished polygon: choose Resume, Finish, or Discard.".into(); - } - } - if pressed[5] { - runtime.begin_new_segment(); - self.status = "The next edit will create an independent tracked object.".into(); - } - if pressed[6] && runtime.draft().is_some() { - match runtime.finish_draft() { - Ok(_) => self.status = "Finished polygon.".into(), - Err(error) => self.status = error.to_string(), - } - } - if pressed[7] { - if runtime.brush_stroke().is_some() { - runtime.cancel_pointer_interaction(); - self.status = "Cancelled the current brush stroke.".into(); - } else if runtime.cancel_draft_step() { - self.status = "Removed the latest draft vertex; Escape again to continue undoing the draft.".into(); - } else if runtime.cancel_ruler() { - self.status = "Cancelled the unfinished ruler.".into(); - } - } - if pressed[8] { - match runtime.delete_selection() { - Ok(count) if count > 0 => { - self.status = format!("Deleted {count} tracked object(s).") - } - Ok(_) => {} - Err(error) => self.status = error.to_string(), - } - } - if pressed[11] { - runtime.adjust_brush_diameter(0.8); - } - if pressed[12] { - runtime.adjust_brush_diameter(1.25); - } - } + ui.input(|input| keyboard::handle_keyboard(runtime, &mut self.status, input)); } let space_pan = accepts_keys && ui.input(|input| input.key_down(egui::Key::Space)); @@ -144,182 +50,20 @@ impl DicomViewerApp { .map(|pointer| screen_to_base(rect, pointer, view.center_base, view.zoom)) .filter(|point| base_contains_point(summary, *point)) .map(|point| Point2::new(f64::from(point.x), f64::from(point.y))); - let alt = ui.input(|input| input.modifiers.alt); - - match runtime.active_tool() { - ActiveTool::Pan => unreachable!(), - ActiveTool::Select => { - if response.drag_started() { - if let Some(point) = point { - let tolerance = 11.0 / f64::from(view.zoom.max(f32::EPSILON)); - interaction.drag_consumed = runtime.begin_handle_drag(point, tolerance); - } - } - if runtime.handle_drag_active() && response.dragged() { - interaction.drag_consumed = true; - if let Some(point) = point { - let mpp = valid_mpp(summary); - if let Err(error) = runtime.update_handle_drag(point, |start, end| { - mpp.map(|(mpp_x, mpp_y)| { - ((end.x - start.x) * mpp_x).hypot((end.y - start.y) * mpp_y) - / 1_000.0 - }) - }) { - self.status = format!("Geometry handle cannot move there: {error}"); - } - } - } - if runtime.handle_drag_active() && response.drag_stopped() { - interaction.drag_consumed = true; - if runtime.finish_handle_drag() { - self.status = "Committed one geometry-move command.".into(); - } - } else if runtime.handle_drag_active() - && (!ui.input(|input| input.pointer.primary_down()) - || ui.input(|input| input.viewport().focused == Some(false))) - { - runtime.cancel_handle_drag(); - self.status = - "Pointer capture was lost; the geometry move was cancelled.".into(); - } - if response.clicked() && !interaction.drag_consumed { - interaction.click_consumed = true; - if let Some(point) = point { - let tolerance = 9.0 / f64::from(view.zoom.max(f32::EPSILON)); - if let Some(id) = runtime.hit_test(point, tolerance) { - if ui.input(|input| input.modifiers.shift) { - runtime.toggle_selection(id); - } else { - runtime.select_only(id); - } - } else if !ui.input(|input| input.modifiers.shift) { - runtime.clear_selection(); - } - } - } - } - ActiveTool::Polygon => { - if response.double_clicked() { - interaction.click_consumed = true; - if let Some(point) = point { - let original = runtime.segment_operation(); - if alt { - runtime.set_segment_operation(original.reversed()); - } - let add = runtime.add_polygon_point(point); - runtime.set_segment_operation(original); - match add.and_then(|()| runtime.finish_draft().map(|_| ())) { - Ok(()) => self.status = "Finished polygon.".into(), - Err(error) => self.status = error.to_string(), - } - } - } else if response.clicked() { - interaction.click_consumed = true; - if let Some(point) = point { - let original = runtime.segment_operation(); - if alt { - runtime.set_segment_operation(original.reversed()); - } - let result = runtime.add_polygon_point(point); - runtime.set_segment_operation(original); - self.status = match result { - Ok(()) => { - "Polygon vertex added; Enter or double-click to finish.".into() - } - Err(error) => error.to_string(), - }; - } - } - } - ActiveTool::Brush => { - if response.drag_started() { - interaction.drag_consumed = true; - if let Some(point) = point { - let operation = if alt { - runtime.segment_operation().reversed() - } else { - runtime.segment_operation() - }; - if let Err(error) = - runtime.begin_brush_stroke_with_operation(point, operation) - { - self.status = error.to_string(); - } - } - } - if response.dragged() { - interaction.drag_consumed = true; - if let Some(point) = point { - runtime.extend_brush_stroke(point); - } - } - if response.drag_stopped() { - interaction.drag_consumed = true; - match runtime.finish_brush_stroke() { - Ok(Some(_)) => self.status = "Committed one brush-stroke command.".into(), - Ok(None) => { - self.status = - "Erase did not intersect the selected segment; nothing changed." - .into() - } - Err(error) => self.status = error.to_string(), - } - } else if runtime.brush_stroke().is_some() - && (!ui.input(|input| input.pointer.primary_down()) - || ui.input(|input| input.viewport().focused == Some(false))) - { - runtime.cancel_pointer_interaction(); - self.status = - "Pointer capture was lost; the brush stroke was cancelled.".into(); - } - } - ActiveTool::Point => { - if response.clicked() { - interaction.click_consumed = true; - if let Some(point) = point { - match runtime.add_point_finding(point) { - Ok(_) => self.status = "Added an independent tracked point.".into(), - Err(error) => self.status = error.to_string(), - } - } - } - } - ActiveTool::Ruler => { - if response.clicked() { - interaction.click_consumed = true; - if let Some(point) = point { - let mpp = valid_mpp(summary); - match runtime.place_ruler_point(point, |start, end| { - mpp.map(|(mpp_x, mpp_y)| { - ((end.x - start.x) * mpp_x).hypot((end.y - start.y) * mpp_y) - / 1_000.0 - }) - }) { - Ok(Some(id)) => { - let label = runtime - .document() - .measurement(id) - .and_then(|measurement| measurement.physical_length_mm()) - .map_or_else( - || "unscaled pixels".into(), - |millimeters| { - if millimeters >= 1.0 { - format!("{millimeters:.3} mm") - } else { - format!("{:.1} µm", millimeters * 1_000.0) - } - }, - ); - self.status = format!("Added tracked ruler: {label}."); - } - Ok(None) => self.status = "Ruler start set.".into(), - Err(error) => self.status = error.to_string(), - } - } - } - } - } - interaction + let input = ui.input(|input| pointer::PointerInput { + point, + clicked: response.clicked(), + double_clicked: response.double_clicked(), + drag_started: response.drag_started(), + dragged: response.dragged(), + drag_stopped: response.drag_stopped(), + capture_lost: !input.pointer.primary_down() || input.viewport().focused == Some(false), + shift: input.modifiers.shift, + alt: input.modifiers.alt, + zoom: view.zoom, + mpp: valid_mpp(summary), + }); + pointer::handle_pointer(runtime, &mut self.status, &input) } } diff --git a/apps/dicom-viewer/src/app/workspace_interaction/keyboard.rs b/apps/dicom-viewer/src/app/workspace_interaction/keyboard.rs new file mode 100644 index 0000000..2f01ecc --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace_interaction/keyboard.rs @@ -0,0 +1,109 @@ +use eframe::egui; + +use crate::app::workspace::{ActiveTool, ToolTransitionOutcome, WorkspaceRuntime}; + +pub(super) fn handle_keyboard( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &egui::InputState, +) { + let modifiers = input.modifiers; + if !handle_history_key(runtime, status, input) + && !modifiers.command + && !modifiers.ctrl + && !modifiers.alt + { + handle_edit_keys(runtime, status, input); + } +} + +fn handle_history_key( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &egui::InputState, +) -> bool { + let modifiers = input.modifiers; + if modifiers.command && input.key_pressed(egui::Key::Z) { + if modifiers.shift { + if runtime.redo() { + *status = "Redid the last pathology command.".into(); + } + } else if runtime.undo_draft_cancel() { + *status = "Restored the last cancelled draft step.".into(); + } else if runtime.undo() { + *status = "Undid the last pathology command.".into(); + } + true + } else if modifiers.ctrl && input.key_pressed(egui::Key::Y) { + if runtime.redo() { + *status = "Redid the last pathology command.".into(); + } + true + } else { + false + } +} + +fn requested_tool(input: &egui::InputState) -> Option { + if input.key_pressed(egui::Key::V) { + Some(ActiveTool::Select) + } else if input.key_pressed(egui::Key::P) { + Some(ActiveTool::Polygon) + } else if input.key_pressed(egui::Key::B) { + Some(ActiveTool::Brush) + } else if input.key_pressed(egui::Key::K) { + Some(ActiveTool::Point) + } else if input.key_pressed(egui::Key::R) { + Some(ActiveTool::Ruler) + } else { + None + } +} + +fn handle_edit_keys(runtime: &mut WorkspaceRuntime, status: &mut String, input: &egui::InputState) { + if let Some(tool) = requested_tool(input) { + if tool == ActiveTool::Brush { + if let Err(error) = runtime.ensure_segmentation_layer() { + *status = error.to_string(); + } + } + if runtime.request_tool(tool) == ToolTransitionOutcome::BlockedByDraft { + *status = "Unfinished polygon: choose Resume, Finish, or Discard.".into(); + } + } + if input.key_pressed(egui::Key::N) { + runtime.begin_new_segment(); + *status = "The next edit will create an independent tracked object.".into(); + } + if input.key_pressed(egui::Key::Enter) && runtime.draft().is_some() { + match runtime.finish_draft() { + Ok(_) => *status = "Finished polygon.".into(), + Err(error) => *status = error.to_string(), + } + } + if input.key_pressed(egui::Key::Escape) { + if runtime.brush_stroke().is_some() { + runtime.cancel_pointer_interaction(); + *status = "Cancelled the current brush stroke.".into(); + } else if runtime.cancel_draft_step() { + *status = + "Removed the latest draft vertex; Escape again to continue undoing the draft." + .into(); + } else if runtime.cancel_ruler() { + *status = "Cancelled the unfinished ruler.".into(); + } + } + if input.key_pressed(egui::Key::Delete) { + match runtime.delete_selection() { + Ok(count) if count > 0 => *status = format!("Deleted {count} tracked object(s)."), + Ok(_) => {} + Err(error) => *status = error.to_string(), + } + } + if input.key_pressed(egui::Key::OpenBracket) { + runtime.adjust_brush_diameter(0.8); + } + if input.key_pressed(egui::Key::CloseBracket) { + runtime.adjust_brush_diameter(1.25); + } +} diff --git a/apps/dicom-viewer/src/app/workspace_interaction/pointer.rs b/apps/dicom-viewer/src/app/workspace_interaction/pointer.rs new file mode 100644 index 0000000..f512a79 --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace_interaction/pointer.rs @@ -0,0 +1,227 @@ +use super::WorkspaceCanvasInteraction; +use crate::app::workspace::{ActiveTool, WorkspaceRuntime}; +use dicom_viewer_core::Point2; + +/// One pointer observation, translated from egui before issuing domain commands. +#[derive(Debug, Default)] +pub(super) struct PointerInput { + pub(super) point: Option, + pub(super) clicked: bool, + pub(super) double_clicked: bool, + pub(super) drag_started: bool, + pub(super) dragged: bool, + pub(super) drag_stopped: bool, + pub(super) capture_lost: bool, + pub(super) shift: bool, + pub(super) alt: bool, + pub(super) zoom: f32, + pub(super) mpp: Option<(f64, f64)>, +} + +pub(super) fn handle_pointer( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &PointerInput, +) -> WorkspaceCanvasInteraction { + let mut interaction = WorkspaceCanvasInteraction::default(); + match runtime.active_tool() { + ActiveTool::Pan => {} + ActiveTool::Select => handle_select(runtime, status, input, &mut interaction), + ActiveTool::Polygon => handle_polygon(runtime, status, input, &mut interaction), + ActiveTool::Brush => handle_brush(runtime, status, input, &mut interaction), + ActiveTool::Point => handle_point(runtime, status, input, &mut interaction), + ActiveTool::Ruler => handle_ruler(runtime, status, input, &mut interaction), + } + interaction +} + +fn handle_select( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &PointerInput, + interaction: &mut WorkspaceCanvasInteraction, +) { + let point = input.point; + if input.drag_started { + if let Some(point) = point { + let tolerance = 11.0 / f64::from(input.zoom.max(f32::EPSILON)); + interaction.drag_consumed = runtime.begin_handle_drag(point, tolerance); + } + } + if runtime.handle_drag_active() && input.dragged { + interaction.drag_consumed = true; + if let Some(point) = point { + let mpp = input.mpp; + if let Err(error) = runtime.update_handle_drag(point, |start, end| { + mpp.map(|(mpp_x, mpp_y)| { + ((end.x - start.x) * mpp_x).hypot((end.y - start.y) * mpp_y) / 1_000.0 + }) + }) { + *status = format!("Geometry handle cannot move there: {error}"); + } + } + } + if runtime.handle_drag_active() && input.drag_stopped { + interaction.drag_consumed = true; + if runtime.finish_handle_drag() { + *status = "Committed one geometry-move command.".into(); + } + } else if runtime.handle_drag_active() && input.capture_lost { + runtime.cancel_handle_drag(); + *status = "Pointer capture was lost; the geometry move was cancelled.".into(); + } + if input.clicked && !interaction.drag_consumed { + interaction.click_consumed = true; + if let Some(point) = point { + let tolerance = 9.0 / f64::from(input.zoom.max(f32::EPSILON)); + if let Some(id) = runtime.hit_test(point, tolerance) { + if input.shift { + runtime.toggle_selection(id); + } else { + runtime.select_only(id); + } + } else if !input.shift { + runtime.clear_selection(); + } + } + } +} + +fn handle_polygon( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &PointerInput, + interaction: &mut WorkspaceCanvasInteraction, +) { + let point = input.point; + let alt = input.alt; + if input.double_clicked { + interaction.click_consumed = true; + if let Some(point) = point { + let original = runtime.segment_operation(); + if alt { + runtime.set_segment_operation(original.reversed()); + } + let add = runtime.add_polygon_point(point); + runtime.set_segment_operation(original); + match add.and_then(|()| runtime.finish_draft().map(|_| ())) { + Ok(()) => *status = "Finished polygon.".into(), + Err(error) => *status = error.to_string(), + } + } + } else if input.clicked { + interaction.click_consumed = true; + if let Some(point) = point { + let original = runtime.segment_operation(); + if alt { + runtime.set_segment_operation(original.reversed()); + } + let result = runtime.add_polygon_point(point); + runtime.set_segment_operation(original); + *status = match result { + Ok(()) => "Polygon vertex added; Enter or double-click to finish.".into(), + Err(error) => error.to_string(), + }; + } + } +} + +fn handle_brush( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &PointerInput, + interaction: &mut WorkspaceCanvasInteraction, +) { + let point = input.point; + let alt = input.alt; + if input.drag_started { + interaction.drag_consumed = true; + if let Some(point) = point { + let operation = if alt { + runtime.segment_operation().reversed() + } else { + runtime.segment_operation() + }; + if let Err(error) = runtime.begin_brush_stroke_with_operation(point, operation) { + *status = error.to_string(); + } + } + } + if input.dragged { + interaction.drag_consumed = true; + if let Some(point) = point { + runtime.extend_brush_stroke(point); + } + } + if input.drag_stopped { + interaction.drag_consumed = true; + match runtime.finish_brush_stroke() { + Ok(Some(_)) => *status = "Committed one brush-stroke command.".into(), + Ok(None) => { + *status = "Erase did not intersect the selected segment; nothing changed.".into() + } + Err(error) => *status = error.to_string(), + } + } else if runtime.brush_stroke().is_some() && input.capture_lost { + runtime.cancel_pointer_interaction(); + *status = "Pointer capture was lost; the brush stroke was cancelled.".into(); + } +} + +fn handle_point( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &PointerInput, + interaction: &mut WorkspaceCanvasInteraction, +) { + let point = input.point; + if input.clicked { + interaction.click_consumed = true; + if let Some(point) = point { + match runtime.add_point_finding(point) { + Ok(_) => *status = "Added an independent tracked point.".into(), + Err(error) => *status = error.to_string(), + } + } + } +} + +fn handle_ruler( + runtime: &mut WorkspaceRuntime, + status: &mut String, + input: &PointerInput, + interaction: &mut WorkspaceCanvasInteraction, +) { + let point = input.point; + if input.clicked { + interaction.click_consumed = true; + if let Some(point) = point { + let mpp = input.mpp; + match runtime.place_ruler_point(point, |start, end| { + mpp.map(|(mpp_x, mpp_y)| { + ((end.x - start.x) * mpp_x).hypot((end.y - start.y) * mpp_y) / 1_000.0 + }) + }) { + Ok(Some(id)) => { + let label = runtime + .document() + .measurement(id) + .and_then(|measurement| measurement.physical_length_mm()) + .map_or_else( + || "unscaled pixels".into(), + |millimeters| { + if millimeters >= 1.0 { + format!("{millimeters:.3} mm") + } else { + format!("{:.1} µm", millimeters * 1_000.0) + } + }, + ); + *status = format!("Added tracked ruler: {label}."); + } + Ok(None) => *status = "Ruler start set.".into(), + Err(error) => *status = error.to_string(), + } + } + } +} diff --git a/apps/dicom-viewer/src/app/workspace_interaction/tests.rs b/apps/dicom-viewer/src/app/workspace_interaction/tests.rs new file mode 100644 index 0000000..a2628d9 --- /dev/null +++ b/apps/dicom-viewer/src/app/workspace_interaction/tests.rs @@ -0,0 +1,100 @@ +use dicom_viewer_core::{AnnotationScheme, Point2, ViewerSourceIdentity}; +use eframe::egui; + +use super::{keyboard, pointer}; +use crate::app::workspace::{ActiveTool, WorkspaceRuntime}; + +fn workspace() -> WorkspaceRuntime { + WorkspaceRuntime::new( + ViewerSourceIdentity::new(9, 0, 0, 0, 0, 0, (5_000, 4_000)), + AnnotationScheme::general_pathology_v1(), + ) + .unwrap() +} + +fn press(runtime: &mut WorkspaceRuntime, keys: &[egui::Key], modifiers: egui::Modifiers) { + let context = egui::Context::default(); + let input = egui::RawInput { + modifiers, + events: keys + .iter() + .map(|&key| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers, + }) + .collect(), + ..Default::default() + }; + let _ = context.run_ui(input, |ui| { + ui.input(|input| keyboard::handle_keyboard(runtime, &mut String::new(), input)); + }); +} + +#[test] +fn keyboard_history_precedes_tool_selection_and_restores_cancelled_drafts() { + let mut runtime = workspace(); + runtime.request_tool(ActiveTool::Polygon); + runtime.add_polygon_point(Point2::new(10.0, 10.0)).unwrap(); + runtime.add_polygon_point(Point2::new(20.0, 10.0)).unwrap(); + press(&mut runtime, &[egui::Key::Escape], egui::Modifiers::NONE); + assert_eq!(runtime.draft().unwrap().points().len(), 1); + press( + &mut runtime, + &[egui::Key::Z, egui::Key::V], + egui::Modifiers { + command: true, + ..Default::default() + }, + ); + assert_eq!(runtime.draft().unwrap().points().len(), 2); + assert_eq!(runtime.active_tool(), ActiveTool::Polygon); + let layer_count = runtime.document().segmentation_layers().len(); + press(&mut runtime, &[egui::Key::B], egui::Modifiers::NONE); + assert_eq!(runtime.active_tool(), ActiveTool::Polygon); + assert_eq!(runtime.document().segmentation_layers().len(), layer_count); +} + +#[test] +fn pointer_capture_loss_cancels_stroke_and_release_commits_one_undoable_edit() { + let mut runtime = workspace(); + runtime.ensure_segmentation_layer().unwrap(); + runtime.request_tool(ActiveTool::Brush); + let mut status = String::new(); + let start = pointer::PointerInput { + point: Some(Point2::new(50.0, 50.0)), + drag_started: true, + dragged: true, + zoom: 1.0, + ..Default::default() + }; + assert!(pointer::handle_pointer(&mut runtime, &mut status, &start).drag_consumed); + assert!(runtime.brush_stroke().is_some()); + pointer::handle_pointer( + &mut runtime, + &mut status, + &pointer::PointerInput { + capture_lost: true, + ..Default::default() + }, + ); + assert!(runtime.brush_stroke().is_none()); + assert_eq!(runtime.document().object_count(), 0); + assert!(status.contains("cancelled")); + + pointer::handle_pointer(&mut runtime, &mut status, &start); + let release = pointer::PointerInput { + drag_stopped: true, + capture_lost: true, + ..Default::default() + }; + assert!(pointer::handle_pointer(&mut runtime, &mut status, &release).drag_consumed); + assert_eq!(runtime.document().object_count(), 1); + assert!(runtime.brush_stroke().is_none()); + assert!(runtime.undo()); + assert_eq!(runtime.document().object_count(), 0); + assert!(runtime.redo()); + assert_eq!(runtime.document().object_count(), 1); +} diff --git a/apps/dicom-viewer/src/bin/annotation_probe.rs b/apps/dicom-viewer/src/bin/annotation_probe.rs deleted file mode 100644 index e5880cd..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe.rs +++ /dev/null @@ -1,30 +0,0 @@ -#![forbid(unsafe_code)] - -use peak_alloc::PeakAlloc; - -#[path = "annotation_probe/command.rs"] -mod command; -#[path = "annotation_probe/conversion_report.rs"] -mod conversion_report; -#[path = "annotation_probe/convert_geojson.rs"] -mod convert_geojson; -#[path = "annotation_probe/convert_raster.rs"] -mod convert_raster; -#[path = "annotation_probe/legacy/mod.rs"] -mod legacy; -#[path = "annotation_probe/publication.rs"] -mod publication; - -#[global_allocator] -pub(crate) static PEAK_ALLOC: PeakAlloc = PeakAlloc; - -fn main() { - let exit_code = command::execute( - std::env::args_os().skip(1), - std::io::stdout().lock(), - std::io::stderr().lock(), - ); - if exit_code != 0 { - std::process::exit(exit_code); - } -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/command.rs b/apps/dicom-viewer/src/bin/annotation_probe/command.rs deleted file mode 100644 index e532c90..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/command.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::ffi::OsString; -use std::io::Write; -use std::path::PathBuf; - -use crate::{convert_geojson, convert_raster, legacy}; - -pub(crate) const USAGE: &str = "usage:\n annotation_probe inspect --source [--canonical-source ] [--payload full|digest] \n annotation_probe roundtrip --source [--canonical-source ] --output [--allow-lossy] [--payload full|digest] \n annotation_probe convert-geojson --source [--canonical-source ] --mapping --coordinate-space level0-pixels|source-pixels|slide-mm --target ann [--target seg] [--target sr] (--output | --output-dir ) [--allow-lossy] \n annotation_probe convert-raster --source [--canonical-source ] --profile [--channel | --all-channels] (--output | --output-dir ) [--max-instance-bytes ] "; - -pub(crate) fn next_path( - arguments: &mut impl Iterator, - option: &str, -) -> Result { - arguments - .next() - .map(PathBuf::from) - .ok_or_else(|| format!("{option} requires a path")) -} - -pub(crate) fn next_utf8( - arguments: &mut impl Iterator, - option: &str, -) -> Result { - let value = arguments - .next() - .ok_or_else(|| format!("{option} requires a value"))?; - value - .into_string() - .map_err(|_| format!("{option} requires UTF-8 text")) -} - -pub(crate) fn set_once(slot: &mut Option, value: T, option: &str) -> Result<(), String> { - if slot.is_some() { - return Err(format!("{option} may be supplied only once")); - } - *slot = Some(value); - Ok(()) -} - -pub(crate) fn execute( - arguments: impl IntoIterator, - stdout: impl Write, - mut stderr: impl Write, -) -> i32 { - let arguments: Vec<_> = arguments.into_iter().collect(); - match arguments.first().and_then(|argument| argument.to_str()) { - Some("inspect" | "roundtrip") => legacy::execute(arguments, stdout, stderr), - Some("convert-geojson") => { - convert_geojson::execute(arguments.into_iter().skip(1), stdout, stderr) - } - Some("convert-raster") => { - convert_raster::execute(arguments.into_iter().skip(1), stdout, stderr) - } - _ => { - let _ = writeln!( - stderr, - "the first argument must name a supported command\n{USAGE}" - ); - 2 - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn conversion_commands_have_owned_grammars() { - for (command, expected_error) in [ - ("convert-geojson", "--source is required"), - ("convert-raster", "--source is required"), - ] { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - - let exit = execute([OsString::from(command)], &mut stdout, &mut stderr); - - assert_eq!(exit, 2); - let report: serde_json::Value = serde_json::from_slice(&stdout).unwrap(); - assert_eq!(report["schema"], "conversion-report-v1"); - assert_eq!(report["status"], "error"); - assert_eq!(report["operation"], command); - assert_eq!(report["error"]["code"], "USAGE_ERROR"); - let stderr = String::from_utf8(stderr).unwrap(); - assert!(stderr.contains(expected_error), "{stderr}"); - assert!(stderr.contains("annotation_probe convert-geojson")); - assert!(stderr.contains("annotation_probe convert-raster")); - } - } - - #[test] - fn conversion_failures_emit_conversion_report_v1() { - for (command, profile_option, profile_path, input) in [ - ( - "convert-geojson", - "--mapping", - "missing-mapping.json", - "missing.geojson", - ), - ( - "convert-raster", - "--profile", - "missing-profile.json", - "missing.npy", - ), - ] { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let mut arguments = vec![ - OsString::from(command), - OsString::from("--source"), - OsString::from("missing-source.dcm"), - OsString::from(profile_option), - OsString::from(profile_path), - ]; - if command == "convert-geojson" { - arguments.extend([ - OsString::from("--coordinate-space"), - OsString::from("level0-pixels"), - OsString::from("--target"), - OsString::from("ann"), - ]); - } - arguments.extend([ - OsString::from("--output"), - OsString::from("missing-output.dcm"), - OsString::from(input), - ]); - - let exit = execute(arguments, &mut stdout, &mut stderr); - - assert_eq!(exit, 1, "{}", String::from_utf8_lossy(&stderr)); - let report: serde_json::Value = serde_json::from_slice(&stdout).unwrap(); - assert_eq!(report["schema"], "conversion-report-v1"); - assert_eq!(report["schema_version"], 1); - assert_eq!(report["status"], "error"); - assert_eq!(report["operation"], command); - assert_eq!(report["error"]["code"], "SOURCE_READ_FAILED"); - assert!(!stderr.is_empty()); - } - } -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/conversion_report.rs b/apps/dicom-viewer/src/bin/annotation_probe/conversion_report.rs deleted file mode 100644 index b97b836..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/conversion_report.rs +++ /dev/null @@ -1,583 +0,0 @@ -use std::collections::BTreeMap; -use std::fs::File; -use std::io::Write; -use std::path::Path; -use std::time::Instant; - -use serde::Serialize; -use wsi_dicom_annotations::{ - DiagnosticDisposition, DiagnosticSeverity, InteroperabilityDiagnostic, -}; - -#[path = "conversion_report/checksum.rs"] -mod checksum; - -use checksum::{sha256_bytes, sha256_directory, sha256_file}; - -pub(crate) const SCHEMA: &str = "conversion-report-v1"; -pub(crate) const SCHEMA_VERSION: u32 = 1; - -#[derive(Debug)] -pub(crate) struct ConversionError { - pub(crate) code: &'static str, - pub(crate) message: String, -} - -impl ConversionError { - pub(crate) fn new(code: &'static str, message: impl Into) -> Self { - Self { - code, - message: message.into(), - } - } - - pub(crate) fn io(operation: &'static str, path: &Path, error: impl std::fmt::Display) -> Self { - Self::new(operation, format!("{}: {error}", path.to_string_lossy())) - } - - pub(crate) fn conversion(error: impl std::fmt::Display) -> Self { - Self::new("CONVERSION_FAILED", error.to_string()) - } - - pub(crate) fn output_write(error: impl std::fmt::Display) -> Self { - Self::new("OUTPUT_WRITE_FAILED", error.to_string()) - } -} - -#[derive(Debug, Serialize)] -pub(crate) struct ImplementationReport { - name: &'static str, - version: &'static str, -} - -impl Default for ImplementationReport { - fn default() -> Self { - Self { - name: "dicom-viewer-rust", - version: env!("CARGO_PKG_VERSION"), - } - } -} - -#[derive(Debug, Serialize)] -pub(crate) struct InputReport { - role: &'static str, - path: String, - bytes: u64, - sha256: String, -} - -impl InputReport { - pub(crate) fn from_bytes( - role: &'static str, - path: &Path, - bytes: &[u8], - ) -> Result { - let bytes_len = u64::try_from(bytes.len()).map_err(|_| { - ConversionError::new("INPUT_TOO_LARGE", "input byte length does not fit u64") - })?; - Ok(Self { - role, - path: path.to_string_lossy().into_owned(), - bytes: bytes_len, - sha256: sha256_bytes(bytes), - }) - } - - pub(crate) fn from_file(role: &'static str, path: &Path) -> Result { - let (bytes, sha256) = sha256_file(path)?; - Ok(Self { - role, - path: path.to_string_lossy().into_owned(), - bytes, - sha256, - }) - } - - pub(crate) fn from_path(role: &'static str, path: &Path) -> Result { - let metadata = std::fs::symlink_metadata(path) - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", path, error))?; - let (bytes, sha256) = if metadata.is_file() { - sha256_file(path)? - } else if metadata.is_dir() { - sha256_directory(path)? - } else { - return Err(ConversionError::new( - "INPUT_PATH_INVALID", - format!( - "input is neither a regular file nor a directory: {}", - path.display() - ), - )); - }; - Ok(Self { - role, - path: path.to_string_lossy().into_owned(), - bytes, - sha256, - }) - } -} - -#[derive(Debug, Serialize)] -pub(crate) struct OutputReport { - pub(crate) target: &'static str, - pub(crate) path: String, - pub(crate) sop_class_uid: &'static str, - pub(crate) sop_instance_uid: String, - pub(crate) series_instance_uid: String, - pub(crate) bytes: u64, - pub(crate) sha256: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) concatenation_uid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) concatenation_source_sop_instance_uid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) in_concatenation_number: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) in_concatenation_total_number: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) frame_offset: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) frame_count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) pixel_value_bytes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) pixel_sha256: Option, -} - -impl OutputReport { - pub(crate) fn for_file( - target: &'static str, - path: &Path, - report_path: &Path, - sop_class_uid: &'static str, - sop_instance_uid: &str, - series_instance_uid: &str, - ) -> Result { - let (bytes, sha256) = sha256_file(path)?; - Ok(Self { - target, - path: report_path.to_string_lossy().into_owned(), - sop_class_uid, - sop_instance_uid: sop_instance_uid.to_string(), - series_instance_uid: series_instance_uid.to_string(), - bytes, - sha256, - concatenation_uid: None, - concatenation_source_sop_instance_uid: None, - in_concatenation_number: None, - in_concatenation_total_number: None, - frame_offset: None, - frame_count: None, - pixel_value_bytes: None, - pixel_sha256: None, - }) - } - - pub(crate) fn with_parametric_map_part( - mut self, - plan: &wsi_dicom_annotations::ParametricMapPlan, - part: &wsi_dicom_annotations::ParametricMapPartPlan, - part_index: usize, - ) -> Result { - let concatenated = plan.parts().len() > 1; - self.concatenation_uid = plan.concatenation_uid().map(str::to_string); - self.concatenation_source_sop_instance_uid = plan - .concatenation_source_sop_instance_uid() - .map(str::to_string); - if concatenated { - self.in_concatenation_number = Some(u16::try_from(part_index + 1).map_err(|_| { - ConversionError::new( - "OUTPUT_REPORT_FAILED", - "PM concatenation part number exceeds DICOM US", - ) - })?); - self.in_concatenation_total_number = - Some(u16::try_from(plan.parts().len()).map_err(|_| { - ConversionError::new( - "OUTPUT_REPORT_FAILED", - "PM concatenation total exceeds DICOM US", - ) - })?); - } - self.frame_offset = Some(part.frame_offset()); - self.frame_count = Some(part.frame_count()); - self.pixel_value_bytes = Some(part.pixel_value_length()); - self.pixel_sha256 = Some(part.pixel_sha256().to_string()); - Ok(self) - } -} - -#[derive(Debug, Serialize)] -#[serde(untagged)] -pub(crate) enum CoverageReport { - Features { - target: &'static str, - feature_count: usize, - }, - Raster { - target: &'static str, - frame_count: u32, - channel_count: usize, - }, -} - -impl CoverageReport { - pub(crate) const fn features(target: &'static str, feature_count: usize) -> Self { - Self::Features { - target, - feature_count, - } - } - - pub(crate) const fn raster(frame_count: u32, channel_count: usize) -> Self { - Self::Raster { - target: "pm", - frame_count, - channel_count, - } - } -} - -#[derive(Debug, Serialize)] -pub(crate) struct DiagnosticReport { - code: String, - severity: &'static str, - path: String, - disposition: &'static str, - message: String, -} - -#[derive(Debug, Serialize)] -pub(crate) struct SuccessReport { - schema: &'static str, - schema_version: u32, - status: &'static str, - operation: &'static str, - implementation: ImplementationReport, - pub(crate) inputs: Vec, - pub(crate) outputs: Vec, - pub(crate) target_coverage: Vec, - pub(crate) normalizations: Vec, - pub(crate) losses: Vec, - pub(crate) semantic_digest: String, - pub(crate) timing_ms: BTreeMap<&'static str, f64>, - pub(crate) peak_tracked_heap_bytes: usize, -} - -impl SuccessReport { - pub(crate) fn new(operation: &'static str) -> Self { - Self { - schema: SCHEMA, - schema_version: SCHEMA_VERSION, - status: "ok", - operation, - implementation: ImplementationReport::default(), - inputs: Vec::new(), - outputs: Vec::new(), - target_coverage: Vec::new(), - normalizations: Vec::new(), - losses: Vec::new(), - semantic_digest: String::new(), - timing_ms: BTreeMap::new(), - peak_tracked_heap_bytes: 0, - } - } - - pub(crate) fn add_diagnostics(&mut self, diagnostics: &[InteroperabilityDiagnostic]) { - for diagnostic in diagnostics { - let report = DiagnosticReport::from(diagnostic); - match diagnostic.disposition() { - DiagnosticDisposition::Normalized => self.normalizations.push(report), - DiagnosticDisposition::WouldDrop => self.losses.push(report), - DiagnosticDisposition::Unsupported => self.losses.push(report), - } - } - } - - pub(crate) fn record_verification_completion(&mut self, started: Instant, peak_heap: usize) { - self.peak_tracked_heap_bytes = peak_heap; - self.timing_ms.insert( - "through_verification", - started.elapsed().as_secs_f64() * 1_000.0, - ); - } -} - -impl From<&InteroperabilityDiagnostic> for DiagnosticReport { - fn from(diagnostic: &InteroperabilityDiagnostic) -> Self { - Self { - code: diagnostic.code().to_string(), - severity: match diagnostic.severity() { - DiagnosticSeverity::Info => "info", - DiagnosticSeverity::Warning => "warning", - DiagnosticSeverity::Error => "error", - }, - path: diagnostic.path().to_string(), - disposition: match diagnostic.disposition() { - DiagnosticDisposition::Normalized => "normalized", - DiagnosticDisposition::WouldDrop => "would_drop", - DiagnosticDisposition::Unsupported => "unsupported", - }, - message: diagnostic.message().to_string(), - } - } -} - -#[derive(Serialize)] -struct ErrorBody<'a> { - code: &'static str, - message: &'a str, -} - -#[derive(Serialize)] -struct ErrorReport<'a> { - schema: &'static str, - schema_version: u32, - status: &'static str, - operation: &'static str, - implementation: ImplementationReport, - error: ErrorBody<'a>, -} - -pub(crate) fn emit_success(report: &SuccessReport, mut stdout: impl Write) -> i32 { - match write_json_line(&mut stdout, report) { - Ok(()) => 0, - Err(_) => 1, - } -} - -pub(crate) fn emit_error( - operation: &'static str, - error: &ConversionError, - mut stdout: impl Write, - mut stderr: impl Write, -) -> i32 { - if write_error_report(operation, error, &mut stdout).is_err() { - let _ = writeln!(stderr, "failed to serialize conversion report"); - return 1; - } - let _ = writeln!(stderr, "{operation} failed: {}", error.message); - 1 -} - -pub(crate) fn emit_usage_error( - operation: &'static str, - message: &str, - usage: &str, - mut stdout: impl Write, - mut stderr: impl Write, -) -> i32 { - let error = ConversionError::new("USAGE_ERROR", message); - if write_error_report(operation, &error, &mut stdout).is_err() { - let _ = writeln!(stderr, "failed to serialize conversion report"); - return 1; - } - let _ = writeln!(stderr, "{message}\n{usage}"); - 2 -} - -fn write_error_report( - operation: &'static str, - error: &ConversionError, - output: &mut impl Write, -) -> std::io::Result<()> { - let report = ErrorReport { - schema: SCHEMA, - schema_version: SCHEMA_VERSION, - status: "error", - operation, - implementation: ImplementationReport::default(), - error: ErrorBody { - code: error.code, - message: &error.message, - }, - }; - write_json_line(output, &report) -} - -pub(crate) fn write_manifest(path: &Path, report: &SuccessReport) -> Result<(), ConversionError> { - let file = File::create(path) - .map_err(|error| ConversionError::io("MANIFEST_WRITE_FAILED", path, error))?; - serde_json::to_writer_pretty(file, report).map_err(|error| { - ConversionError::new( - "MANIFEST_WRITE_FAILED", - format!("{}: {error}", path.display()), - ) - }) -} - -pub(crate) fn write_json_line( - output: &mut impl Write, - value: &impl Serialize, -) -> std::io::Result<()> { - serde_json::to_writer(&mut *output, value).map_err(std::io::Error::other)?; - writeln!(output) -} - -#[cfg(test)] -mod tests { - use std::io; - - use sha2::{Digest, Sha256}; - - use super::*; - - #[test] - fn input_report_hashes_the_exact_consumed_bytes() { - let bytes = b"profile bytes already read by the converter"; - - let report = InputReport::from_bytes("mapping", Path::new("mapping.json"), bytes).unwrap(); - let value = serde_json::to_value(report).unwrap(); - - assert_eq!(value["bytes"], bytes.len()); - assert_eq!(value["sha256"], format!("{:x}", Sha256::digest(bytes))); - } - - #[test] - fn file_and_directory_input_reports_hash_stable_consumed_content() { - let temporary = tempfile::tempdir().expect("temporary directory should be created"); - let root = temporary.path().join("array.zarr"); - std::fs::create_dir(&root).expect("input directory should be created"); - std::fs::write(root.join("z.json"), b"metadata").expect("metadata should be written"); - std::fs::create_dir(root.join("chunks")).expect("chunk directory should be created"); - std::fs::write(root.join("chunks/0"), b"pixels").expect("chunk should be written"); - - let file = InputReport::from_file("profile", &root.join("z.json")) - .expect("regular file should hash"); - assert_eq!(file.bytes, 8); - let directory = - InputReport::from_path("raster", &root).expect("directory should hash recursively"); - assert_eq!(directory.bytes, 14); - let repeated = InputReport::from_path("raster", &root) - .expect("unchanged directory should hash identically"); - assert_eq!(directory.sha256, repeated.sha256); - assert_eq!(sha256_bytes(b"metadata"), file.sha256); - - let missing = InputReport::from_path("raster", &root.join("missing")) - .expect_err("missing input should fail"); - assert_eq!(missing.code, "INPUT_READ_FAILED"); - } - - #[cfg(unix)] - #[test] - fn directory_input_rejects_symbolic_links() { - use std::os::unix::fs::symlink; - - let temporary = tempfile::tempdir().expect("temporary directory should be created"); - let root = temporary.path().join("array.zarr"); - std::fs::create_dir(&root).expect("input directory should be created"); - std::fs::write(temporary.path().join("outside"), b"pixels") - .expect("target should be written"); - symlink(temporary.path().join("outside"), root.join("chunk")) - .expect("test symlink should be created"); - - let error = InputReport::from_path("raster", &root) - .expect_err("symlinked input should fail closed"); - assert_eq!(error.code, "INPUT_PATH_INVALID"); - assert!(error.message.contains("symbolic link")); - } - - #[test] - fn report_writers_emit_one_json_object_and_surface_sink_failures() { - let temporary = tempfile::tempdir().expect("temporary directory should be created"); - let object_path = temporary.path().join("ann.dcm"); - std::fs::write(&object_path, b"dicom bytes").expect("output fixture should be written"); - let output = OutputReport::for_file( - "ann", - &object_path, - Path::new("ann.dcm"), - "1.2.840.10008.5.1.4.1.1.91.1", - "2.25.1", - "2.25.2", - ) - .expect("output report should hash the written object"); - assert_eq!(output.bytes, 11); - - let mut report = SuccessReport::new("convert-geojson"); - report - .inputs - .push(InputReport::from_bytes("mapping", Path::new("mapping.json"), b"{}").unwrap()); - report.outputs.push(output); - report - .target_coverage - .push(CoverageReport::features("ann", 1)); - report.target_coverage.push(CoverageReport::raster(2, 1)); - report.semantic_digest = "abc".into(); - report.record_verification_completion(Instant::now(), 4096); - - let mut stdout = Vec::new(); - assert_eq!(emit_success(&report, &mut stdout), 0); - assert_eq!(stdout.iter().filter(|byte| **byte == b'\n').count(), 1); - let value: serde_json::Value = serde_json::from_slice(&stdout).unwrap(); - assert_eq!(value["status"], "ok"); - assert_eq!(value["peak_tracked_heap_bytes"], 4096); - - let manifest = temporary.path().join("manifest.json"); - write_manifest(&manifest, &report).expect("manifest should serialize"); - assert_eq!( - serde_json::from_slice::(&std::fs::read(manifest).unwrap()).unwrap() - ["semantic_digest"], - "abc" - ); - - let error = ConversionError::new("BAD_INPUT", "broken input"); - let mut error_stdout = Vec::new(); - let mut error_stderr = Vec::new(); - assert_eq!( - emit_error( - "convert-geojson", - &error, - &mut error_stdout, - &mut error_stderr - ), - 1 - ); - assert!(String::from_utf8(error_stderr) - .unwrap() - .contains("broken input")); - - let mut usage_stdout = Vec::new(); - let mut usage_stderr = Vec::new(); - assert_eq!( - emit_usage_error( - "convert-raster", - "missing profile", - "usage text", - &mut usage_stdout, - &mut usage_stderr, - ), - 2 - ); - assert!(String::from_utf8(usage_stderr) - .unwrap() - .contains("usage text")); - - struct BrokenWriter; - impl Write for BrokenWriter { - fn write(&mut self, _: &[u8]) -> io::Result { - Err(io::Error::other("sink failed")) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - assert_eq!(emit_success(&report, BrokenWriter), 1); - assert_eq!( - emit_error("convert-geojson", &error, BrokenWriter, io::sink()), - 1 - ); - assert_eq!( - emit_usage_error( - "convert-raster", - "missing profile", - "usage text", - BrokenWriter, - io::sink(), - ), - 1 - ); - } -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/conversion_report/checksum.rs b/apps/dicom-viewer/src/bin/annotation_probe/conversion_report/checksum.rs deleted file mode 100644 index 34cb55e..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/conversion_report/checksum.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::fs::File; -use std::io::Read; -use std::path::Path; - -use sha2::{Digest, Sha256}; - -use super::ConversionError; - -pub(super) fn sha256_bytes(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -pub(super) fn sha256_file(path: &Path) -> Result<(u64, String), ConversionError> { - let mut file = - File::open(path).map_err(|error| ConversionError::io("INPUT_READ_FAILED", path, error))?; - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 128 * 1024]; - let mut bytes = 0_u64; - loop { - let count = file - .read(&mut buffer) - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", path, error))?; - if count == 0 { - break; - } - digest.update(&buffer[..count]); - bytes = bytes - .checked_add(u64::try_from(count).map_err(|_| { - ConversionError::new("INPUT_TOO_LARGE", "input read length does not fit u64") - })?) - .ok_or_else(|| ConversionError::new("INPUT_TOO_LARGE", "input size overflow"))?; - } - Ok((bytes, format!("{:x}", digest.finalize()))) -} - -pub(super) fn sha256_directory(path: &Path) -> Result<(u64, String), ConversionError> { - let mut digest = Sha256::new(); - digest.update(b"conversion-input-directory-v1\0"); - let mut bytes = 0_u64; - let mut entry_count = 0_u64; - hash_directory(path, path, 0, &mut entry_count, &mut bytes, &mut digest)?; - Ok((bytes, format!("{:x}", digest.finalize()))) -} - -fn hash_directory( - root: &Path, - directory: &Path, - depth: u16, - entry_count: &mut u64, - bytes: &mut u64, - digest: &mut Sha256, -) -> Result<(), ConversionError> { - if depth > 128 { - return Err(ConversionError::new( - "INPUT_PATH_INVALID", - format!( - "input directory nesting exceeds 128 levels: {}", - root.display() - ), - )); - } - let mut entries = std::fs::read_dir(directory) - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", directory, error))? - .collect::>>() - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", directory, error))?; - entries.sort_by_key(std::fs::DirEntry::file_name); - for entry in entries { - *entry_count = entry_count.checked_add(1).ok_or_else(|| { - ConversionError::new("INPUT_TOO_LARGE", "input directory entry count overflow") - })?; - if *entry_count > 1_000_000 { - return Err(ConversionError::new( - "INPUT_TOO_LARGE", - "input directory contains more than 1,000,000 entries", - )); - } - let path = entry.path(); - let metadata = std::fs::symlink_metadata(&path) - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", &path, error))?; - let relative = path.strip_prefix(root).map_err(|_| { - ConversionError::new( - "INPUT_PATH_INVALID", - format!("input entry escaped its root: {}", path.display()), - ) - })?; - let relative = relative.to_str().ok_or_else(|| { - ConversionError::new( - "INPUT_PATH_INVALID", - format!( - "input directory contains a non-UTF-8 path: {}", - path.display() - ), - ) - })?; - if metadata.file_type().is_symlink() { - return Err(ConversionError::new( - "INPUT_PATH_INVALID", - format!( - "input directory contains a symbolic link: {}", - path.display() - ), - )); - } - hash_component( - digest, - if metadata.is_dir() { b'D' } else { b'F' }, - relative, - )?; - if metadata.is_dir() { - hash_directory(root, &path, depth + 1, entry_count, bytes, digest)?; - } else if metadata.is_file() { - let (length, file_digest) = sha256_file(&path)?; - *bytes = bytes.checked_add(length).ok_or_else(|| { - ConversionError::new("INPUT_TOO_LARGE", "input directory byte count overflow") - })?; - digest.update(length.to_le_bytes()); - digest.update(file_digest.as_bytes()); - } else { - return Err(ConversionError::new( - "INPUT_PATH_INVALID", - format!( - "input directory contains a special file: {}", - path.display() - ), - )); - } - } - Ok(()) -} - -fn hash_component(digest: &mut Sha256, kind: u8, relative: &str) -> Result<(), ConversionError> { - let length = u64::try_from(relative.len()).map_err(|_| { - ConversionError::new("INPUT_TOO_LARGE", "input path length does not fit u64") - })?; - digest.update([kind]); - digest.update(length.to_le_bytes()); - digest.update(relative.as_bytes()); - Ok(()) -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/convert_geojson.rs b/apps/dicom-viewer/src/bin/annotation_probe/convert_geojson.rs deleted file mode 100644 index 65cbbb5..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/convert_geojson.rs +++ /dev/null @@ -1,586 +0,0 @@ -use std::ffi::OsString; -use std::io::Write; -use std::path::PathBuf; -use std::time::Instant; - -use dicom_viewer_core::frames_viewer_producer; -use wsi_dicom_annotations::{ - DicomAnnotationContext, DicomBundlePublication, DicomSinglePublication, PathologyAnnotationSet, - PathologyCoordinateSpace, PathologyDicomDocuments, PathologyDicomTarget as Target, - PathologyDocumentWriteError, -}; - -use crate::command::{next_path, next_utf8, set_once, USAGE}; -use crate::conversion_report::{ - emit_error, emit_success, emit_usage_error, write_manifest, ConversionError, CoverageReport, - InputReport, OutputReport, SuccessReport, -}; -use crate::publication::{publication_error, read_bounded_file}; -use crate::PEAK_ALLOC; - -const MAX_GEOJSON_BYTES: u64 = 64 * 1024 * 1024; -const MAX_MAPPING_BYTES: u64 = 4 * 1024 * 1024; -const ANN_STORAGE_UID: &str = "1.2.840.10008.5.1.4.1.1.91.1"; -const SEG_STORAGE_UID: &str = "1.2.840.10008.5.1.4.1.1.66.4"; -const COMPREHENSIVE_3D_SR_STORAGE_UID: &str = "1.2.840.10008.5.1.4.1.1.88.34"; - -#[derive(Debug)] -pub(crate) struct Arguments { - pub(crate) source: PathBuf, - pub(crate) canonical_source: PathBuf, - pub(crate) mapping: PathBuf, - pub(crate) coordinate_space: PathologyCoordinateSpace, - pub(crate) targets: Vec, - pub(crate) output: Option, - pub(crate) output_dir: Option, - pub(crate) allow_lossy: bool, - pub(crate) input: PathBuf, -} - -pub(crate) fn execute( - arguments: impl IntoIterator, - stdout: impl Write, - stderr: impl Write, -) -> i32 { - let arguments = match parse(arguments) { - Ok(arguments) => arguments, - Err(error) => { - return emit_usage_error("convert-geojson", &error, USAGE, stdout, stderr); - } - }; - match run(&arguments) { - Ok(report) => emit_success(&report, stdout), - Err(error) => emit_error("convert-geojson", &error, stdout, stderr), - } -} - -pub(crate) fn parse(arguments: impl IntoIterator) -> Result { - let mut arguments = arguments.into_iter(); - let mut source = None; - let mut canonical_source = None; - let mut mapping = None; - let mut coordinate_space = None; - let mut targets = Vec::new(); - let mut output = None; - let mut output_dir = None; - let mut allow_lossy = false; - let mut input = None; - while let Some(argument) = arguments.next() { - match argument.to_str() { - Some("--source") => { - let value = next_path(&mut arguments, "--source")?; - set_once(&mut source, value, "--source")?; - } - Some("--canonical-source") => { - let value = next_path(&mut arguments, "--canonical-source")?; - set_once(&mut canonical_source, value, "--canonical-source")?; - } - Some("--mapping") => { - let value = next_path(&mut arguments, "--mapping")?; - set_once(&mut mapping, value, "--mapping")?; - } - Some("--coordinate-space") => { - let value = next_utf8(&mut arguments, "--coordinate-space")?; - let parsed = match value.as_str() { - "level0-pixels" => PathologyCoordinateSpace::Level0Pixels, - "source-pixels" => PathologyCoordinateSpace::SourcePixels, - "slide-mm" => PathologyCoordinateSpace::SlideMillimeters, - _ => { - return Err( - "--coordinate-space requires level0-pixels, source-pixels, or slide-mm" - .into(), - ); - } - }; - set_once(&mut coordinate_space, parsed, "--coordinate-space")?; - } - Some("--target") => { - let value = next_utf8(&mut arguments, "--target")?; - let target = match value.as_str() { - "ann" => Target::Ann, - "seg" => Target::Seg, - "sr" => Target::Sr, - _ => return Err("--target requires ann, seg, or sr".into()), - }; - if targets.contains(&target) { - return Err(format!("duplicate --target {value}")); - } - targets.push(target); - } - Some("--output") => { - let value = next_path(&mut arguments, "--output")?; - set_once(&mut output, value, "--output")?; - } - Some("--output-dir") => { - let value = next_path(&mut arguments, "--output-dir")?; - set_once(&mut output_dir, value, "--output-dir")?; - } - Some("--allow-lossy") => allow_lossy = true, - Some(value) if value.starts_with('-') => { - return Err(format!("unknown option {value}")); - } - _ if input.is_none() => input = Some(PathBuf::from(argument)), - _ => return Err("only one GeoJSON input path may be supplied".into()), - } - } - let source = source.ok_or_else(|| "--source is required".to_string())?; - let mapping = mapping.ok_or_else(|| "--mapping is required".to_string())?; - let coordinate_space = - coordinate_space.ok_or_else(|| "--coordinate-space is required".to_string())?; - if targets.is_empty() { - return Err("at least one --target is required".into()); - } - validate_destination(targets.len(), output.as_ref(), output_dir.as_ref())?; - let input = input.ok_or_else(|| "a GeoJSON input path is required".to_string())?; - Ok(Arguments { - canonical_source: canonical_source.unwrap_or_else(|| source.clone()), - source, - mapping, - coordinate_space, - targets, - output, - output_dir, - allow_lossy, - input, - }) -} - -fn validate_destination( - target_count: usize, - output: Option<&PathBuf>, - output_dir: Option<&PathBuf>, -) -> Result<(), String> { - match (target_count, output, output_dir) { - (1, Some(_), None) | (2.., None, Some(_)) => Ok(()), - (1, None, None) => Err("one target requires --output".into()), - (2.., None, None) => Err("multiple targets require --output-dir".into()), - (1, None, Some(_)) => Err("one target requires --output, not --output-dir".into()), - (2.., Some(_), None) => Err("multiple targets require --output-dir, not --output".into()), - (_, Some(_), Some(_)) => Err("--output and --output-dir are mutually exclusive".into()), - (0, _, _) => unreachable!("target count was validated"), - } -} - -fn run(arguments: &Arguments) -> Result { - PEAK_ALLOC.reset_peak_usage(); - let started = Instant::now(); - let source = DicomAnnotationContext::from_source(&arguments.source).map_err(|error| { - ConversionError::new( - "SOURCE_READ_FAILED", - format!("source WSI could not be read: {error}"), - ) - })?; - let canonical_source = DicomAnnotationContext::from_source(&arguments.canonical_source) - .map_err(|error| { - ConversionError::new( - "CANONICAL_SOURCE_READ_FAILED", - format!("canonical source WSI could not be read: {error}"), - ) - })?; - let geojson = read_bounded_file(&arguments.input, MAX_GEOJSON_BYTES, "GeoJSON input")?; - let mapping = read_bounded_file(&arguments.mapping, MAX_MAPPING_BYTES, "mapping profile")?; - let annotations = PathologyAnnotationSet::from_json( - &geojson, - &mapping, - &source, - &canonical_source, - arguments.coordinate_space, - arguments.allow_lossy, - ) - .map_err(ConversionError::conversion)?; - let documents = PathologyDicomDocuments::build(&annotations, &arguments.targets) - .map_err(ConversionError::conversion)? - .with_producers( - frames_viewer_producer(9101, "WSI annotations").map_err(ConversionError::conversion)?, - frames_viewer_producer(9201, "WSI segmentations") - .map_err(ConversionError::conversion)?, - frames_viewer_producer(9301, "WSI measurement reports") - .map_err(ConversionError::conversion)?, - ); - let mut report = base_report(arguments, &annotations, &mapping, &geojson, started)?; - let protected = [ - arguments.source.as_path(), - arguments.canonical_source.as_path(), - arguments.mapping.as_path(), - arguments.input.as_path(), - ]; - if let Some(output) = &arguments.output { - let target = arguments.targets[0]; - let publication = DicomSinglePublication::new(output, target.file_name(), &protected) - .map_err(publication_error)?; - documents - .write_and_verify(target, publication.staged_file(), &source) - .map_err(pathology_write_error)?; - report.outputs.push(output_report( - &documents, - target, - publication.staged_file(), - publication.destination(), - )?); - report.record_verification_completion(started, PEAK_ALLOC.peak_usage()); - publication.publish().map_err(publication_error)?; - } else if let Some(output_dir) = &arguments.output_dir { - let publication = - DicomBundlePublication::new(output_dir, &protected).map_err(publication_error)?; - for &target in &arguments.targets { - let staged = publication.staging_path().join(target.file_name()); - documents - .write_and_verify(target, &staged, &source) - .map_err(pathology_write_error)?; - report.outputs.push(output_report( - &documents, - target, - &staged, - &publication.destination().join(target.file_name()), - )?); - } - report.record_verification_completion(started, PEAK_ALLOC.peak_usage()); - let manifest = publication.staging_path().join("manifest.json"); - write_manifest(&manifest, &report)?; - publication - .sync_staged_file(&manifest) - .map_err(publication_error)?; - publication.publish().map_err(publication_error)?; - } else { - return Err(ConversionError::new( - "INVALID_DESTINATION", - "conversion has no destination", - )); - } - Ok(report) -} - -fn output_report( - documents: &PathologyDicomDocuments, - target: Target, - staged_path: &std::path::Path, - report_path: &std::path::Path, -) -> Result { - let (sop_class_uid, sop_instance_uid, series_instance_uid) = match target { - Target::Ann => { - let document = documents.ann().ok_or_else(missing_document)?; - ( - ANN_STORAGE_UID, - document.sop_instance_uid(), - document.series_instance_uid(), - ) - } - Target::Seg => { - let document = documents.seg().ok_or_else(missing_document)?; - ( - SEG_STORAGE_UID, - document.sop_instance_uid(), - document.series_instance_uid(), - ) - } - Target::Sr => { - let document = documents.sr().ok_or_else(missing_document)?; - ( - COMPREHENSIVE_3D_SR_STORAGE_UID, - document.sop_instance_uid(), - document.series_instance_uid(), - ) - } - }; - OutputReport::for_file( - target.label(), - staged_path, - report_path, - sop_class_uid, - sop_instance_uid, - series_instance_uid, - ) -} - -fn pathology_write_error(error: PathologyDocumentWriteError) -> ConversionError { - match error { - PathologyDocumentWriteError::Write { source, .. } => ConversionError::output_write(source), - PathologyDocumentWriteError::Verification { source, .. } => { - ConversionError::new("OUTPUT_VERIFICATION_FAILED", source.to_string()) - } - PathologyDocumentWriteError::Mismatch(_) => { - ConversionError::new("OUTPUT_VERIFICATION_FAILED", error.to_string()) - } - PathologyDocumentWriteError::MissingTarget => ConversionError::new( - "INTERNAL_TARGET_MISMATCH", - "selected target document is missing", - ), - } -} - -fn base_report( - arguments: &Arguments, - annotations: &PathologyAnnotationSet, - mapping: &[u8], - geojson: &[u8], - started: Instant, -) -> Result { - let mut report = SuccessReport::new("convert-geojson"); - report.inputs = vec![ - InputReport::from_file("source", &arguments.source)?, - InputReport::from_file("canonical_source", &arguments.canonical_source)?, - InputReport::from_bytes("mapping", &arguments.mapping, mapping)?, - InputReport::from_bytes("geojson", &arguments.input, geojson)?, - ]; - report.target_coverage = arguments - .targets - .iter() - .map(|target| CoverageReport::features(target.label(), annotations.feature_count())) - .collect(); - report.add_diagnostics(annotations.diagnostics()); - report.semantic_digest = annotations.semantic_sha256(); - report - .timing_ms - .insert("conversion", started.elapsed().as_secs_f64() * 1_000.0); - Ok(report) -} - -fn missing_document() -> ConversionError { - ConversionError::new( - "INTERNAL_TARGET_MISMATCH", - "selected target document is missing", - ) -} - -#[cfg(test)] -mod tests { - use dicom_dictionary_std::tags; - - use super::*; - use crate::legacy::tests::write_source_wsi; - - #[test] - fn output_shape_tracks_target_count() { - let single = parse( - [ - "--source", - "source.dcm", - "--mapping", - "mapping.json", - "--coordinate-space", - "level0-pixels", - "--target", - "ann", - "--output", - "ann.dcm", - "annotations.geojson", - ] - .map(OsString::from), - ) - .unwrap(); - assert_eq!(single.targets, vec![Target::Ann]); - assert_eq!(single.output, Some(PathBuf::from("ann.dcm"))); - - let error = parse( - [ - "--source", - "source.dcm", - "--mapping", - "mapping.json", - "--coordinate-space", - "level0-pixels", - "--target", - "ann", - "--target", - "seg", - "--output", - "one.dcm", - "annotations.geojson", - ] - .map(OsString::from), - ) - .unwrap_err(); - assert_eq!(error, "multiple targets require --output-dir, not --output"); - } - - #[test] - fn rejects_repeated_valued_options() { - let error = parse( - [ - "--source", - "source-a.dcm", - "--source", - "source-b.dcm", - "--mapping", - "mapping.json", - "--coordinate-space", - "level0-pixels", - "--target", - "ann", - "--output", - "ann.dcm", - "annotations.geojson", - ] - .map(OsString::from), - ) - .unwrap_err(); - - assert_eq!(error, "--source may be supplied only once"); - } - - #[test] - fn publishes_verified_three_object_bundle_and_matching_manifest() { - let directory = tempfile::tempdir().unwrap(); - let source = directory.path().join("source.dcm"); - let mapping = directory.path().join("mapping.json"); - let geojson = directory.path().join("annotations.geojson"); - let output = directory.path().join("bundle"); - write_source_wsi(&source); - std::fs::write(&mapping, MAPPING).unwrap(); - std::fs::write(&geojson, GEOJSON).unwrap(); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - - let exit = execute( - [ - "--source", - source.to_str().unwrap(), - "--mapping", - mapping.to_str().unwrap(), - "--coordinate-space", - "level0-pixels", - "--target", - "ann", - "--target", - "seg", - "--target", - "sr", - "--output-dir", - output.to_str().unwrap(), - geojson.to_str().unwrap(), - ] - .map(OsString::from), - &mut stdout, - &mut stderr, - ); - - assert_eq!(exit, 0, "{}", String::from_utf8_lossy(&stderr)); - assert!(stderr.is_empty()); - for name in ["ann.dcm", "seg.dcm", "sr.dcm", "manifest.json"] { - assert!(output.join(name).is_file(), "missing {name}"); - } - for (name, series_number) in [("ann.dcm", "9101"), ("seg.dcm", "9201"), ("sr.dcm", "9301")] - { - let object = dicom_object::open_file(output.join(name)).unwrap(); - assert_eq!( - object - .element(tags::MANUFACTURER) - .unwrap() - .to_str() - .unwrap(), - "Frames" - ); - assert_eq!( - object - .element(tags::MANUFACTURER_MODEL_NAME) - .unwrap() - .to_str() - .unwrap(), - "DICOM Viewer" - ); - assert_eq!( - object - .element(tags::SERIES_NUMBER) - .unwrap() - .to_str() - .unwrap(), - series_number - ); - } - let report: serde_json::Value = serde_json::from_slice(&stdout).unwrap(); - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(output.join("manifest.json")).unwrap()).unwrap(); - assert_eq!(report, manifest); - assert_eq!(report["status"], "ok"); - assert_eq!(report["semantic_digest"].as_str().unwrap().len(), 64); - assert_eq!( - report["outputs"] - .as_array() - .unwrap() - .iter() - .map(|output| output["target"].as_str().unwrap()) - .collect::>(), - ["ann", "seg", "sr"] - ); - } - - #[test] - fn conversion_failure_does_not_publish_a_partial_bundle() { - let directory = tempfile::tempdir().unwrap(); - let source = directory.path().join("source.dcm"); - let mapping = directory.path().join("mapping.json"); - let geojson = directory.path().join("point.geojson"); - let output = directory.path().join("bundle"); - write_source_wsi(&source); - std::fs::write(&mapping, MAPPING).unwrap(); - std::fs::write( - &geojson, - br#"{"type":"FeatureCollection","features":[{"type":"Feature","id":"2.25.70","geometry":{"type":"Point","coordinates":[1,1]},"properties":{"classification":{"name":"tumor"}}}]}"#, - ) - .unwrap(); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - - let exit = execute( - [ - "--source", - source.to_str().unwrap(), - "--mapping", - mapping.to_str().unwrap(), - "--coordinate-space", - "level0-pixels", - "--target", - "ann", - "--target", - "seg", - "--output-dir", - output.to_str().unwrap(), - geojson.to_str().unwrap(), - ] - .map(OsString::from), - &mut stdout, - &mut stderr, - ); - - assert_eq!(exit, 1); - assert!(!output.exists()); - assert_eq!( - serde_json::from_slice::(&stdout).unwrap()["error"]["code"], - "CONVERSION_FAILED" - ); - } - - const MAPPING: &[u8] = br#" - { - "schema_version":1, - "labels":{ - "tumor":{ - "category":{"code_value":"M-01000","coding_scheme_designator":"SRT","code_meaning":"Morphologically Altered Structure"}, - "property_type":{"code_value":"108369006","coding_scheme_designator":"SCT","code_meaning":"Neoplasm"}, - "generation_type":"MANUAL", - "recommended_display_cielab":[40000,30000,20000], - "segment_label":"Tumor" - } - }, - "measurements":{ - "Area":{ - "concept":{"code_value":"AREA","coding_scheme_designator":"99WSI","code_meaning":"Area"}, - "unit":{"code_value":"mm2","coding_scheme_designator":"UCUM","code_meaning":"square millimeter"} - } - }, - "sr":{ - "report_title":{"code_value":"126000","coding_scheme_designator":"DCM","code_meaning":"Imaging Measurement Report"}, - "procedures_reported":[{"code_value":"P5-09051","coding_scheme_designator":"SRT","code_meaning":"Histopathology procedure"}] - } - } - "#; - - const GEOJSON: &[u8] = br#" - {"type":"FeatureCollection","features":[{ - "type":"Feature", - "id":"2.25.71", - "geometry":{"type":"Polygon","coordinates":[[[1,1],[6,1],[6,6],[1,6],[1,1]]]}, - "properties":{"classification":{"name":"tumor"},"measurements":{"Area":25.0}} - }]} - "#; -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/convert_raster.rs b/apps/dicom-viewer/src/bin/annotation_probe/convert_raster.rs deleted file mode 100644 index 00c1dc5..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/convert_raster.rs +++ /dev/null @@ -1,598 +0,0 @@ -use std::ffi::OsString; -use std::io::Write; -use std::path::PathBuf; -use std::time::Instant; - -use dicom_viewer_core::frames_viewer_producer; -use wsi_dicom_annotations::{ - DicomAnnotationContext, DicomBundlePublication, DicomSinglePublication, ParametricMapDocument, - ParametricMapInstance, ParametricMapPlan, RasterChannelSelection, RasterProfile, -}; - -use crate::command::{next_path, next_utf8, set_once, USAGE}; -use crate::conversion_report::{ - emit_error, emit_success, emit_usage_error, write_manifest, ConversionError, CoverageReport, - InputReport, OutputReport, SuccessReport, -}; -use crate::publication::{publication_error, read_bounded_file}; -use crate::PEAK_ALLOC; - -pub(crate) const DEFAULT_MAX_INSTANCE_BYTES: u64 = 2_000_000_000; -const MAX_PROFILE_BYTES: u64 = 4 * 1024 * 1024; -const PARAMETRIC_MAP_STORAGE_UID: &str = "1.2.840.10008.5.1.4.1.1.30"; - -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum ChannelSelection { - One(String), - All, -} - -#[derive(Debug)] -pub(crate) struct Arguments { - pub(crate) source: PathBuf, - pub(crate) canonical_source: PathBuf, - pub(crate) profile: PathBuf, - pub(crate) channel: Option, - pub(crate) output: Option, - pub(crate) output_dir: Option, - pub(crate) max_instance_bytes: u64, - pub(crate) input: PathBuf, -} - -pub(crate) fn execute( - arguments: impl IntoIterator, - stdout: impl Write, - stderr: impl Write, -) -> i32 { - let arguments = match parse(arguments) { - Ok(arguments) => arguments, - Err(error) => { - return emit_usage_error("convert-raster", &error, USAGE, stdout, stderr); - } - }; - match run(&arguments) { - Ok(report) => emit_success(&report, stdout), - Err(error) => emit_error("convert-raster", &error, stdout, stderr), - } -} - -pub(crate) fn parse(arguments: impl IntoIterator) -> Result { - let mut arguments = arguments.into_iter(); - let mut source = None; - let mut canonical_source = None; - let mut profile = None; - let mut channel = None; - let mut output = None; - let mut output_dir = None; - let mut max_instance_bytes = None; - let mut input = None; - while let Some(argument) = arguments.next() { - match argument.to_str() { - Some("--source") => { - let value = next_path(&mut arguments, "--source")?; - set_once(&mut source, value, "--source")?; - } - Some("--canonical-source") => { - let value = next_path(&mut arguments, "--canonical-source")?; - set_once(&mut canonical_source, value, "--canonical-source")?; - } - Some("--profile") => { - let value = next_path(&mut arguments, "--profile")?; - set_once(&mut profile, value, "--profile")?; - } - Some("--channel") => { - if channel.is_some() { - return Err("--channel and --all-channels are mutually exclusive".into()); - } - channel = Some(ChannelSelection::One(next_utf8( - &mut arguments, - "--channel", - )?)); - } - Some("--all-channels") => { - if channel.is_some() { - return Err("--channel and --all-channels are mutually exclusive".into()); - } - channel = Some(ChannelSelection::All); - } - Some("--output") => { - let value = next_path(&mut arguments, "--output")?; - set_once(&mut output, value, "--output")?; - } - Some("--output-dir") => { - let value = next_path(&mut arguments, "--output-dir")?; - set_once(&mut output_dir, value, "--output-dir")?; - } - Some("--max-instance-bytes") => { - let value = next_utf8(&mut arguments, "--max-instance-bytes")?; - let parsed = value.parse().map_err(|_| { - "--max-instance-bytes requires a positive integer byte count".to_string() - })?; - if parsed == 0 { - return Err( - "--max-instance-bytes requires a positive integer byte count".into(), - ); - } - set_once(&mut max_instance_bytes, parsed, "--max-instance-bytes")?; - } - Some(value) if value.starts_with('-') => { - return Err(format!("unknown option {value}")); - } - _ if input.is_none() => input = Some(PathBuf::from(argument)), - _ => return Err("only one raster input path may be supplied".into()), - } - } - let source = source.ok_or_else(|| "--source is required".to_string())?; - let profile = profile.ok_or_else(|| "--profile is required".to_string())?; - match (&output, &output_dir) { - (Some(_), None) | (None, Some(_)) => {} - (None, None) => return Err("--output or --output-dir is required".into()), - (Some(_), Some(_)) => { - return Err("--output and --output-dir are mutually exclusive".into()); - } - } - let input = input.ok_or_else(|| "a raster input path is required".to_string())?; - Ok(Arguments { - canonical_source: canonical_source.unwrap_or_else(|| source.clone()), - source, - profile, - channel, - output, - output_dir, - max_instance_bytes: max_instance_bytes.unwrap_or(DEFAULT_MAX_INSTANCE_BYTES), - input, - }) -} - -fn run(arguments: &Arguments) -> Result { - PEAK_ALLOC.reset_peak_usage(); - let started = Instant::now(); - let source = DicomAnnotationContext::from_source(&arguments.source).map_err(|error| { - ConversionError::new( - "SOURCE_READ_FAILED", - format!("source WSI could not be read: {error}"), - ) - })?; - let canonical_source = DicomAnnotationContext::from_source(&arguments.canonical_source) - .map_err(|error| { - ConversionError::new( - "CANONICAL_SOURCE_READ_FAILED", - format!("canonical source WSI could not be read: {error}"), - ) - })?; - let profile_bytes = read_bounded_file(&arguments.profile, MAX_PROFILE_BYTES, "raster profile")?; - let profile = RasterProfile::from_json(&profile_bytes).map_err(ConversionError::conversion)?; - let channel_selection = resolve_channel_selection(&profile, arguments.channel.as_ref())?; - let document = ParametricMapDocument::open( - source, - canonical_source, - profile, - &arguments.input, - channel_selection, - ) - .map_err(ConversionError::conversion)? - .with_producer( - frames_viewer_producer(9401, "WSI parametric maps").map_err(ConversionError::conversion)?, - ); - let plan = document - .plan(arguments.max_instance_bytes) - .map_err(ConversionError::conversion)?; - let mut report = base_report(arguments, &document, &profile_bytes, started)?; - let protected = [ - arguments.source.as_path(), - arguments.canonical_source.as_path(), - arguments.profile.as_path(), - arguments.input.as_path(), - ]; - if let Some(output) = &arguments.output { - write_single_output(&document, &plan, output, &protected, &mut report, started)?; - } else if let Some(output_dir) = &arguments.output_dir { - write_bundle_output( - &document, - &plan, - output_dir, - &protected, - &mut report, - started, - )?; - } else { - return Err(ConversionError::new( - "INVALID_DESTINATION", - "conversion has no destination", - )); - } - Ok(report) -} - -fn resolve_channel_selection( - profile: &RasterProfile, - selection: Option<&ChannelSelection>, -) -> Result { - match selection { - None => Ok(RasterChannelSelection::Auto), - Some(ChannelSelection::All) => Ok(RasterChannelSelection::All), - Some(ChannelSelection::One(value)) => { - if (0..profile.channel_count()) - .any(|index| profile.channel_name(index) == Some(value.as_str())) - { - return Ok(RasterChannelSelection::Name(value.clone())); - } - value - .parse::() - .map(RasterChannelSelection::Index) - .map_err(|_| { - ConversionError::new( - "CHANNEL_INVALID", - format!("channel {value:?} is neither an exact declared name nor an index"), - ) - }) - } - } -} - -fn write_single_output( - document: &ParametricMapDocument, - plan: &ParametricMapPlan, - output: &std::path::Path, - protected: &[&std::path::Path], - report: &mut SuccessReport, - started: Instant, -) -> Result<(), ConversionError> { - if plan.parts().len() != 1 { - return Err(ConversionError::new( - "OUTPUT_REQUIRES_DIRECTORY", - format!( - "Parametric Map requires {} concatenation parts; use --output-dir", - plan.parts().len() - ), - )); - } - let publication = - DicomSinglePublication::new(output, "pm.dcm", protected).map_err(publication_error)?; - let paths = [publication.staged_file()]; - let instances = document - .write_planned_parts(plan, &paths) - .map_err(ConversionError::output_write)?; - let instance = instances.first().ok_or_else(|| { - ConversionError::new( - "OUTPUT_WRITE_FAILED", - "single-instance PM write returned no instance", - ) - })?; - report.outputs.push(pm_output_report( - publication.staged_file(), - publication.destination(), - instance, - plan, - 0, - )?); - report.record_verification_completion(started, PEAK_ALLOC.peak_usage()); - publication.publish().map_err(publication_error)?; - Ok(()) -} - -fn write_bundle_output( - document: &ParametricMapDocument, - plan: &ParametricMapPlan, - output_dir: &std::path::Path, - protected: &[&std::path::Path], - report: &mut SuccessReport, - started: Instant, -) -> Result<(), ConversionError> { - let publication = - DicomBundlePublication::new(output_dir, protected).map_err(publication_error)?; - let names = (1..=plan.parts().len()) - .map(|number| format!("pm-{number:04}.dcm")) - .collect::>(); - let staged_paths = names - .iter() - .map(|name| publication.staging_path().join(name)) - .collect::>(); - let instances = document - .write_planned_parts(plan, &staged_paths) - .map_err(ConversionError::output_write)?; - if instances.len() != plan.parts().len() { - return Err(ConversionError::new( - "OUTPUT_WRITE_FAILED", - "PM writer returned a different number of instances than planned", - )); - } - for (index, ((name, staged), instance)) in - names.iter().zip(&staged_paths).zip(&instances).enumerate() - { - report.outputs.push(pm_output_report( - staged, - &publication.destination().join(name), - instance, - plan, - index, - )?); - } - report.record_verification_completion(started, PEAK_ALLOC.peak_usage()); - let manifest = publication.staging_path().join("manifest.json"); - write_manifest(&manifest, report)?; - publication - .sync_staged_file(&manifest) - .map_err(publication_error)?; - publication.publish().map_err(publication_error)?; - Ok(()) -} - -fn base_report( - arguments: &Arguments, - document: &ParametricMapDocument, - profile_bytes: &[u8], - started: Instant, -) -> Result { - let mut report = SuccessReport::new("convert-raster"); - report.inputs = vec![ - InputReport::from_file("source", &arguments.source)?, - InputReport::from_file("canonical_source", &arguments.canonical_source)?, - InputReport::from_bytes("raster_profile", &arguments.profile, profile_bytes)?, - InputReport::from_path("raster", &arguments.input)?, - ]; - report.target_coverage = vec![CoverageReport::raster( - document.frame_count(), - document.selected_channel_count(), - )]; - report.semantic_digest = document.semantic_digest().to_string(); - report.add_diagnostics(document.diagnostics()); - report - .timing_ms - .insert("conversion", started.elapsed().as_secs_f64() * 1_000.0); - Ok(report) -} - -fn pm_output_report( - staged_path: &std::path::Path, - report_path: &std::path::Path, - instance: &ParametricMapInstance, - plan: &ParametricMapPlan, - part_index: usize, -) -> Result { - let part = plan.parts().get(part_index).ok_or_else(|| { - ConversionError::new( - "OUTPUT_REPORT_FAILED", - "PM output has no matching planned part", - ) - })?; - OutputReport::for_file( - "pm", - staged_path, - report_path, - PARAMETRIC_MAP_STORAGE_UID, - instance.sop_instance_uid(), - instance.series_instance_uid(), - ) - .and_then(|report| report.with_parametric_map_part(plan, part, part_index)) -} - -#[cfg(test)] -mod tests { - use std::fs::File; - use std::io::Write as _; - - use dicom_dictionary_std::{tags, uids}; - - use super::*; - use crate::legacy::tests::write_source_wsi; - - #[test] - fn defaults_instance_limit_and_rejects_conflicting_channels() { - let parsed = parse( - [ - "--source", - "source.dcm", - "--profile", - "profile.json", - "--output", - "map.dcm", - "map.npy", - ] - .map(OsString::from), - ) - .unwrap(); - assert_eq!(parsed.max_instance_bytes, DEFAULT_MAX_INSTANCE_BYTES); - assert_eq!(parsed.channel, None); - - let error = parse( - [ - "--source", - "source.dcm", - "--profile", - "profile.json", - "--channel", - "0", - "--all-channels", - "--output", - "map.dcm", - "map.npy", - ] - .map(OsString::from), - ) - .unwrap_err(); - assert_eq!(error, "--channel and --all-channels are mutually exclusive"); - } - - #[test] - fn rejects_repeated_valued_options() { - let error = parse( - [ - "--source", - "source.dcm", - "--profile", - "profile-a.json", - "--profile", - "profile-b.json", - "--output", - "map.dcm", - "map.npy", - ] - .map(OsString::from), - ) - .unwrap_err(); - - assert_eq!(error, "--profile may be supplied only once"); - } - - #[test] - fn converts_single_channel_npy_and_emits_one_success_report() { - let directory = tempfile::tempdir().unwrap(); - let source = directory.path().join("source.dcm"); - let profile = directory.path().join("profile.json"); - let input = directory.path().join("probability.npy"); - let output = directory.path().join("map.dcm"); - write_source_wsi(&source); - std::fs::write(&profile, profile_json(false)).unwrap(); - write_npy(&input, &[2, 2], &[0.0, 0.25, f32::NAN, 1.0]); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - - let exit = execute( - [ - "--source", - source.to_str().unwrap(), - "--profile", - profile.to_str().unwrap(), - "--output", - output.to_str().unwrap(), - input.to_str().unwrap(), - ] - .map(OsString::from), - &mut stdout, - &mut stderr, - ); - - assert_eq!(exit, 0, "{}", String::from_utf8_lossy(&stderr)); - let report: serde_json::Value = serde_json::from_slice(&stdout).unwrap(); - assert_eq!(report["schema"], "conversion-report-v1"); - assert_eq!(report["operation"], "convert-raster"); - let published_output = output.canonicalize().unwrap(); - assert_eq!( - report["outputs"][0]["path"], - published_output.to_string_lossy().as_ref() - ); - assert_eq!(report["target_coverage"][0]["frame_count"], 1); - assert_eq!( - report["normalizations"][0]["code"], - "RASTER_NAN_CANONICALIZED" - ); - let object = dicom_object::open_file(&output).unwrap(); - assert_eq!( - object.meta().media_storage_sop_class_uid(), - uids::PARAMETRIC_MAP_STORAGE - ); - assert_eq!( - object - .element(tags::MANUFACTURER) - .unwrap() - .to_str() - .unwrap(), - "Frames" - ); - assert_eq!( - object - .element(tags::MANUFACTURER_MODEL_NAME) - .unwrap() - .to_str() - .unwrap(), - "DICOM Viewer" - ); - } - - #[test] - fn publishes_deterministically_named_all_channel_bundle() { - let directory = tempfile::tempdir().unwrap(); - let source = directory.path().join("source.dcm"); - let profile = directory.path().join("profile.json"); - let input = directory.path().join("probability.npy"); - let output = directory.path().join("maps"); - write_source_wsi(&source); - std::fs::write(&profile, profile_json(true)).unwrap(); - write_npy( - &input, - &[2, 2, 2], - &[0.0, 1.0, 0.25, 0.75, 0.5, 0.5, 1.0, 0.0], - ); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - - let exit = execute( - [ - "--source", - source.to_str().unwrap(), - "--profile", - profile.to_str().unwrap(), - "--all-channels", - "--output-dir", - output.to_str().unwrap(), - input.to_str().unwrap(), - ] - .map(OsString::from), - &mut stdout, - &mut stderr, - ); - - assert_eq!(exit, 0, "{}", String::from_utf8_lossy(&stderr)); - assert!(output.join("pm-0001.dcm").is_file()); - assert!(output.join("manifest.json").is_file()); - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(output.join("manifest.json")).unwrap()).unwrap(); - assert_eq!(manifest["target_coverage"][0]["channel_count"], 2); - assert_eq!(manifest["target_coverage"][0]["frame_count"], 2); - assert_eq!(manifest["outputs"].as_array().unwrap().len(), 1); - let published_output = output.canonicalize().unwrap().join("pm-0001.dcm"); - assert_eq!( - manifest["outputs"][0]["path"], - published_output.to_string_lossy().as_ref() - ); - } - - fn write_npy(path: &std::path::Path, shape: &[usize], values: &[f32]) { - let shape = shape - .iter() - .map(usize::to_string) - .collect::>() - .join(", "); - let mut header = - format!("{{'descr': ' String { - let axes = if multichannel { - r#"["y","x","channel"]"# - } else { - r#"["y","x"]"# - }; - let second = if multichannel { - r#",{"name":"stroma","quantity":{"code_value":"STROMA","coding_scheme_designator":"99WSI","code_meaning":"Stroma probability"},"unit":{"code_value":"1","coding_scheme_designator":"UCUM","code_meaning":"no units"}}"# - } else { - "" - }; - format!( - r#"{{ - "schema_version":1, - "input_format":"npy", - "dtype":"float32", - "axes":{axes}, - "grid_origin":{{"x":0.0,"y":0.0}}, - "sample_spacing":{{"x":1.0,"y":1.0}}, - "coordinate_space":"level0-pixels", - "channels":[{{"name":"tumor","quantity":{{"code_value":"TUMOR","coding_scheme_designator":"99WSI","code_meaning":"Tumor probability"}},"unit":{{"code_value":"1","coding_scheme_designator":"UCUM","code_meaning":"no units"}}}}{second}], - "algorithm":{{"family":{{"code_value":"123110","coding_scheme_designator":"DCM","code_meaning":"Artificial Intelligence"}},"name":"Example model","version":"1.0"}} - }}"# - ) - } -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/legacy/mod.rs b/apps/dicom-viewer/src/bin/annotation_probe/legacy/mod.rs deleted file mode 100644 index 4e1b383..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/legacy/mod.rs +++ /dev/null @@ -1,385 +0,0 @@ -#![forbid(unsafe_code)] - -mod report; -mod schema; - -#[cfg(test)] -pub(crate) mod tests; - -use std::ffi::OsString; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use wsi_dicom_annotations::{ - annotation_object_kind, AnnotationDocument, AnnotationObjectKind, DicomAnnotationContext, - Error as AnnotationError, SegmentationDocument, -}; - -use crate::command::next_path; -use crate::conversion_report::write_json_line; -use crate::PEAK_ALLOC; - -use self::report::{ - build_ann_report, build_seg_report, diagnostic_reports, file_report, operation_error_report, - success_report, -}; -use self::schema::{SemanticReport, SuccessReport}; - -const SCHEMA_VERSION: u32 = 1; -const USAGE: &str = "usage:\n annotation_probe inspect --source [--canonical-source ] [--payload full|digest] \n annotation_probe roundtrip --source [--canonical-source ] --output [--allow-lossy] [--payload full|digest] "; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Operation { - Inspect, - Roundtrip, -} - -impl Operation { - const fn label(self) -> &'static str { - match self { - Self::Inspect => "inspect", - Self::Roundtrip => "roundtrip", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PayloadMode { - Full, - Digest, -} - -impl PayloadMode { - const fn label(self) -> &'static str { - match self { - Self::Full => "full", - Self::Digest => "digest", - } - } -} - -#[derive(Debug)] -struct Arguments { - operation: Operation, - source: PathBuf, - canonical_source: PathBuf, - output: Option, - allow_lossy: bool, - payload: PayloadMode, - input: PathBuf, -} - -#[derive(Debug)] -struct ProbeError { - code: &'static str, - message: String, -} - -impl ProbeError { - fn rewrite(object_kind: &str, error: AnnotationError) -> Self { - let code = if matches!(error, AnnotationError::Unsupported(_)) { - "REWRITE_UNSUPPORTED" - } else { - "OPERATION_FAILED" - }; - Self { - code, - message: format!("{object_kind} rewrite failed: {error}"), - } - } -} - -impl From for ProbeError { - fn from(message: String) -> Self { - Self { - code: "OPERATION_FAILED", - message, - } - } -} - -pub(crate) fn execute( - arguments: impl IntoIterator, - mut stdout: impl Write, - mut stderr: impl Write, -) -> i32 { - let arguments = match parse_arguments(arguments) { - Ok(arguments) => arguments, - Err(error) => { - let _ = writeln!(stderr, "{error}\n{USAGE}"); - return 2; - } - }; - PEAK_ALLOC.reset_peak_usage(); - match run_probe(&arguments) { - Ok(report) => { - for diagnostic in &report.diagnostics { - let _ = writeln!( - stderr, - "[{}] {} {}: {}", - diagnostic.severity, diagnostic.code, diagnostic.path, diagnostic.message - ); - } - if write_json_line(&mut stdout, &report).is_ok() { - 0 - } else { - let _ = writeln!(stderr, "failed to serialize probe report"); - 1 - } - } - Err(error) => { - let report = operation_error_report(&arguments, error.code, error.message.clone()); - let _ = write_json_line(&mut stdout, &report); - let _ = writeln!(stderr, "annotation probe failed: {}", error.message); - 1 - } - } -} - -fn parse_arguments(arguments: impl IntoIterator) -> Result { - let mut arguments = arguments.into_iter(); - let operation = match arguments.next().as_deref().and_then(|value| value.to_str()) { - Some("inspect") => Operation::Inspect, - Some("roundtrip") => Operation::Roundtrip, - _ => return Err("the first argument must be inspect or roundtrip".into()), - }; - let mut source = None; - let mut canonical_source = None; - let mut output = None; - let mut allow_lossy = false; - let mut payload = PayloadMode::Full; - let mut input = None; - while let Some(argument) = arguments.next() { - match argument.to_str() { - Some("--source") => { - source = Some(next_path(&mut arguments, "--source")?); - } - Some("--canonical-source") => { - canonical_source = Some(next_path(&mut arguments, "--canonical-source")?); - } - Some("--output") => { - output = Some(next_path(&mut arguments, "--output")?); - } - Some("--allow-lossy") => allow_lossy = true, - Some("--payload") => { - let value = arguments - .next() - .ok_or_else(|| "--payload requires full or digest".to_string())?; - payload = match value.to_str() { - Some("full") => PayloadMode::Full, - Some("digest") => PayloadMode::Digest, - _ => return Err("--payload requires full or digest".into()), - }; - } - Some(value) if value.starts_with('-') => { - return Err(format!("unknown option {value}")); - } - _ if input.is_none() => input = Some(PathBuf::from(argument)), - _ => return Err("only one ANN or SEG input path may be supplied".into()), - } - } - let source = source.ok_or_else(|| "--source is required".to_string())?; - let input = input.ok_or_else(|| "an ANN or SEG input path is required".to_string())?; - match operation { - Operation::Inspect if output.is_some() || allow_lossy => { - return Err("--output and --allow-lossy are only valid for roundtrip".into()); - } - Operation::Roundtrip if output.is_none() => { - return Err("roundtrip requires --output".into()); - } - _ => {} - } - if output.as_ref().is_some_and(|path| path == &input) { - return Err("roundtrip output must differ from the input object".into()); - } - Ok(Arguments { - operation, - canonical_source: canonical_source.unwrap_or_else(|| source.clone()), - source, - output, - allow_lossy, - payload, - input, - }) -} - -fn run_probe(arguments: &Arguments) -> Result { - let input_bytes = file_size(&arguments.input)?; - let parse_started = Instant::now(); - let source = DicomAnnotationContext::from_source(&arguments.source) - .map_err(|error| format!("source WSI could not be read: {error}"))?; - let canonical_source = DicomAnnotationContext::from_source(&arguments.canonical_source) - .map_err(|error| format!("canonical source WSI could not be read: {error}"))?; - let kind = annotation_object_kind(&arguments.input) - .map_err(|error| format!("input type could not be identified: {error}"))?; - - match kind { - AnnotationObjectKind::Annotation => run_ann( - arguments, - &source, - &canonical_source, - input_bytes, - parse_started, - ), - AnnotationObjectKind::Segmentation => run_seg( - arguments, - &source, - &canonical_source, - input_bytes, - parse_started, - ), - } -} - -fn run_ann( - arguments: &Arguments, - source: &DicomAnnotationContext, - canonical_source: &DicomAnnotationContext, - input_bytes: u64, - parse_started: Instant, -) -> Result { - let input_document = AnnotationDocument::read_ann(&arguments.input, source) - .map_err(|error| format!("ANN parse failed: {error}"))?; - let parse_ms = elapsed_ms(parse_started.elapsed()); - let input_file = file_report( - &arguments.input, - input_bytes, - input_document.sop_instance_uid(), - input_document.series_instance_uid(), - ); - let input_diagnostics = diagnostic_reports(input_document.diagnostics()); - let (document, output, write_ms, verify_ms) = match arguments.operation { - Operation::Inspect => (input_document, None, 0.0, 0.0), - Operation::Roundtrip => { - let Some(output_path) = arguments.output.as_ref() else { - return Err("roundtrip output path was not validated".to_owned().into()); - }; - reject_source_aliases(arguments, output_path)?; - let revised = input_document.revised(); - let write_started = Instant::now(); - revised - .write_ann_with_loss_policy(output_path, arguments.allow_lossy) - .map_err(|error| ProbeError::rewrite("ANN", error))?; - let write_ms = elapsed_ms(write_started.elapsed()); - let verify_started = Instant::now(); - let output_document = AnnotationDocument::read_ann(output_path, source) - .map_err(|error| format!("rewritten ANN verification failed: {error}"))?; - let verify_ms = elapsed_ms(verify_started.elapsed()); - let output = Some(file_report( - output_path, - file_size(output_path)?, - output_document.sop_instance_uid(), - output_document.series_instance_uid(), - )); - (output_document, output, write_ms, verify_ms) - } - }; - let canonicalize_started = Instant::now(); - let semantic = SemanticReport::Ann(build_ann_report( - &document, - canonical_source, - arguments.payload, - )?); - Ok(success_report( - arguments, - input_file, - output, - semantic, - input_diagnostics, - parse_ms, - write_ms, - verify_ms, - elapsed_ms(canonicalize_started.elapsed()), - )) -} - -fn run_seg( - arguments: &Arguments, - source: &DicomAnnotationContext, - canonical_source: &DicomAnnotationContext, - input_bytes: u64, - parse_started: Instant, -) -> Result { - let input_document = SegmentationDocument::read_seg(&arguments.input, source) - .map_err(|error| format!("SEG parse failed: {error}"))?; - let parse_ms = elapsed_ms(parse_started.elapsed()); - let input_file = file_report( - &arguments.input, - input_bytes, - input_document.sop_instance_uid(), - input_document.series_instance_uid(), - ); - let input_diagnostics = diagnostic_reports(input_document.diagnostics()); - let (document, output, write_ms, verify_ms) = match arguments.operation { - Operation::Inspect => (input_document, None, 0.0, 0.0), - Operation::Roundtrip => { - let Some(output_path) = arguments.output.as_ref() else { - return Err("roundtrip output path was not validated".to_owned().into()); - }; - reject_source_aliases(arguments, output_path)?; - let revised = input_document.revised(); - let write_started = Instant::now(); - revised - .write_seg_with_loss_policy(output_path, arguments.allow_lossy) - .map_err(|error| ProbeError::rewrite("SEG", error))?; - let write_ms = elapsed_ms(write_started.elapsed()); - let verify_started = Instant::now(); - let output_document = SegmentationDocument::read_seg(output_path, source) - .map_err(|error| format!("rewritten SEG verification failed: {error}"))?; - let verify_ms = elapsed_ms(verify_started.elapsed()); - let output = Some(file_report( - output_path, - file_size(output_path)?, - output_document.sop_instance_uid(), - output_document.series_instance_uid(), - )); - (output_document, output, write_ms, verify_ms) - } - }; - let canonicalize_started = Instant::now(); - let semantic = SemanticReport::Seg(build_seg_report( - &document, - canonical_source, - arguments.payload, - )?); - Ok(success_report( - arguments, - input_file, - output, - semantic, - input_diagnostics, - parse_ms, - write_ms, - verify_ms, - elapsed_ms(canonicalize_started.elapsed()), - )) -} - -fn reject_source_aliases(arguments: &Arguments, output: &Path) -> Result<(), String> { - for (name, path) in [ - ("source", &arguments.source), - ("canonical source", &arguments.canonical_source), - ] { - if output == path - || output - .canonicalize() - .ok() - .zip(path.canonicalize().ok()) - .is_some_and(|(output, source)| output == source) - { - return Err(format!("roundtrip output cannot replace the {name} WSI")); - } - } - Ok(()) -} - -fn file_size(path: &Path) -> Result { - std::fs::metadata(path) - .map(|metadata| metadata.len()) - .map_err(|error| format!("could not read {} metadata: {error}", path.display())) -} - -fn elapsed_ms(duration: Duration) -> f64 { - duration.as_secs_f64() * 1_000.0 -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/legacy/report/mod.rs b/apps/dicom-viewer/src/bin/annotation_probe/legacy/report/mod.rs deleted file mode 100644 index 6acf5b9..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/legacy/report/mod.rs +++ /dev/null @@ -1,514 +0,0 @@ -use std::path::Path; - -use sha2::{Digest, Sha256}; -use wsi_dicom_annotations::{ - AlgorithmIdentification, AnnotationDocument, AnnotationGeometry, AnnotationGroup, - AnnotationMeasurement, DiagnosticDisposition, DiagnosticSeverity, DicomAnnotationContext, - DicomCode, DicomCodeValueKind, InteroperabilityDiagnostic, SegmentationDocument, - SegmentationKind, SegmentationSegment, -}; - -use crate::PEAK_ALLOC; - -use super::{schema::*, Arguments, PayloadMode, SCHEMA_VERSION}; - -pub(super) fn operation_error_report( - arguments: &Arguments, - code: &'static str, - message: String, -) -> ErrorReport { - ErrorReport { - schema_version: SCHEMA_VERSION, - status: "error", - operation: arguments.operation.label(), - implementation: implementation_report(), - error: ErrorBody { code, message }, - } -} - -#[allow(clippy::too_many_arguments)] -pub(super) fn success_report( - arguments: &Arguments, - input: FileReport, - output: Option, - semantic: SemanticReport, - diagnostics: Vec, - parse_ms: f64, - write_ms: f64, - verify_ms: f64, - canonicalize_ms: f64, -) -> SuccessReport { - SuccessReport { - schema_version: SCHEMA_VERSION, - status: "ok", - operation: arguments.operation.label(), - implementation: implementation_report(), - input, - output, - payload: arguments.payload.label(), - semantic, - diagnostics, - runtime: RuntimeReport { - parse_ms, - write_ms, - verify_ms, - canonicalize_ms, - peak_tracked_heap_bytes: PEAK_ALLOC.peak_usage(), - }, - } -} - -fn implementation_report() -> ImplementationReport { - ImplementationReport { - name: "dicom-viewer", - version: env!("CARGO_PKG_VERSION"), - } -} - -pub(super) fn build_ann_report( - document: &AnnotationDocument, - canonical_source: &DicomAnnotationContext, - payload: PayloadMode, -) -> Result { - let mut groups = document.groups().iter().collect::>(); - groups.sort_by_key(|group| group.uid()); - let groups = groups - .into_iter() - .map(|group| build_group_report(document, canonical_source, group, payload)) - .collect::, _>>()?; - Ok(AnnReport { - sop_instance_uid: document.sop_instance_uid().to_string(), - series_instance_uid: document.series_instance_uid().to_string(), - coordinate_type: document.coordinate_type().to_string(), - pixel_origin_interpretation: document.pixel_origin_interpretation().map(str::to_string), - referenced_frame_number: document.referenced_frame_number(), - content: ContentReport { - label: document.content_label().to_string(), - description: document.content_description().to_string(), - creator_name: document.content_creator_name().map(str::to_string), - }, - source: source_report(document.source(), canonical_source), - groups, - }) -} - -fn build_group_report( - document: &AnnotationDocument, - canonical_source: &DicomAnnotationContext, - group: &AnnotationGroup, - payload: PayloadMode, -) -> Result { - let (dimensions, native_coordinates, indices) = flatten_geometry(group.geometry())?; - let geometry = geometry_report( - document, - canonical_source, - group, - dimensions, - native_coordinates, - indices, - payload, - )?; - Ok(AnnotationGroupReport { - uid: group.uid().to_string(), - label: group.label().to_string(), - description: group.description().to_string(), - generation_type: group.generation_type().dicom_value(), - algorithms: group.algorithms().iter().map(algorithm_report).collect(), - category: code_report(group.category()), - property_type: code_report(group.property_type()), - property_type_modifiers: group - .property_type_modifiers() - .iter() - .map(code_report) - .collect(), - anatomic_regions: group.anatomic_regions().iter().map(code_report).collect(), - primary_anatomic_structures: group - .primary_anatomic_structures() - .iter() - .map(code_report) - .collect(), - applies_to_all_optical_paths: group.applies_to_all_optical_paths(), - referenced_optical_paths: group.referenced_optical_paths().to_vec(), - applies_to_all_z_planes: group.applies_to_all_z_planes(), - common_z_coordinates_mm: group.common_z_coordinates().to_vec(), - recommended_display_cielab: group.recommended_display_cielab(), - graphic_type: group.geometry().graphic_type().dicom_value(), - annotation_count: group.annotation_count(), - measurements: group - .measurements() - .iter() - .map(measurement_report) - .collect(), - geometry, - }) -} - -fn flatten_geometry(geometry: &AnnotationGeometry) -> Result<(usize, Vec, Vec), String> { - match geometry { - AnnotationGeometry::Points(points) => Ok(( - 2, - points.iter().flat_map(|point| [point.x, point.y]).collect(), - Vec::new(), - )), - AnnotationGeometry::Polygons(polygons) => { - let mut coordinates = Vec::new(); - let mut indices = Vec::with_capacity(polygons.len()); - for polygon in polygons { - indices.push( - u32::try_from(coordinates.len() + 1) - .map_err(|_| "ANN primitive index exceeds u32".to_string())?, - ); - coordinates.extend(polygon.iter().flat_map(|point| [point.x, point.y])); - } - Ok((2, coordinates, indices)) - } - AnnotationGeometry::ReadOnly { - coordinates, - primitive_point_indices, - coordinate_dimensions, - .. - } => Ok(( - *coordinate_dimensions, - coordinates.clone(), - primitive_point_indices.clone(), - )), - } -} - -#[allow(clippy::too_many_arguments)] -fn geometry_report( - document: &AnnotationDocument, - canonical_source: &DicomAnnotationContext, - group: &AnnotationGroup, - native_dimensions: usize, - native_coordinates: Vec, - primitive_point_indices: Vec, - payload: PayloadMode, -) -> Result { - let canonical_dimensions = if document.coordinate_type() == "3D" { - 3 - } else { - 2 - }; - match payload { - PayloadMode::Full => { - let canonical_coordinates = canonical_coordinate_iter( - document, - canonical_source, - group, - native_dimensions, - &native_coordinates, - )? - .collect(); - Ok(GeometryReport::Full { - native_dimensions, - canonical_dimensions, - native_coordinates, - canonical_level0_coordinates: canonical_coordinates, - primitive_point_indices, - }) - } - PayloadMode::Digest => { - let native_sha256 = digest_f64(native_dimensions, native_coordinates.iter().copied()); - let mut canonical_digest = Sha256::new(); - canonical_digest.update((canonical_dimensions as u64).to_le_bytes()); - let mut canonical_coordinate_count = 0_usize; - for value in canonical_coordinate_iter( - document, - canonical_source, - group, - native_dimensions, - &native_coordinates, - )? { - canonical_digest.update(value.to_bits().to_le_bytes()); - canonical_coordinate_count += 1; - } - Ok(GeometryReport::Digest { - native_dimensions, - canonical_dimensions, - native_coordinate_count: native_coordinates.len(), - canonical_coordinate_count, - native_sha256, - canonical_level0_sha256: format!("{:x}", canonical_digest.finalize()), - primitive_point_indices, - }) - } - } -} - -fn canonical_coordinate_iter( - document: &AnnotationDocument, - canonical_source: &DicomAnnotationContext, - group: &AnnotationGroup, - native_dimensions: usize, - native_coordinates: &[f64], -) -> Result, String> { - let mut values = Vec::new(); - if document.coordinate_type() == "2D" { - for point in native_coordinates.chunks_exact(2) { - let canonical = document - .canonical_level0_pixel(canonical_source, point[0], point[1], None) - .map_err(|error| format!("coordinate canonicalization failed: {error}"))?; - values.extend([canonical.x, canonical.y]); - } - } else if native_dimensions == 3 { - for point in native_coordinates.chunks_exact(3) { - let canonical = document - .canonical_level0_pixel(canonical_source, point[0], point[1], Some(point[2])) - .map_err(|error| format!("coordinate canonicalization failed: {error}"))?; - values.extend([canonical.x, canonical.y, point[2]]); - } - } else { - for point in native_coordinates.chunks_exact(2) { - for z in group.common_z_coordinates() { - let canonical = document - .canonical_level0_pixel(canonical_source, point[0], point[1], Some(*z)) - .map_err(|error| format!("coordinate canonicalization failed: {error}"))?; - values.extend([canonical.x, canonical.y, *z]); - } - } - } - Ok(values.into_iter()) -} - -pub(super) fn build_seg_report( - document: &SegmentationDocument, - canonical_source: &DicomAnnotationContext, - payload: PayloadMode, -) -> Result { - let mut segments = document.segments().iter().enumerate().collect::>(); - segments.sort_by_key(|(index, segment)| { - segment - .source_segment_number() - .unwrap_or_else(|| u16::try_from(index + 1).unwrap_or(u16::MAX)) - }); - let segments = segments - .into_iter() - .map(|(index, segment)| segment_report(index, segment)) - .collect(); - let sha256 = document - .mask_digest() - .map_err(|error| format!("SEG digest failed: {error}"))?; - let masks = seg_mask_report(document, payload, sha256)?; - Ok(SegReport { - sop_instance_uid: document.sop_instance_uid().to_string(), - series_instance_uid: document.series_instance_uid().to_string(), - segmentation_kind: segmentation_kind_label(document.kind()), - content: ContentReport { - label: document.content_label().to_string(), - description: document.content_description().to_string(), - creator_name: document.content_creator_name().map(str::to_string), - }, - source: source_report(document.source(), canonical_source), - segments, - masks, - }) -} - -fn seg_mask_report( - document: &SegmentationDocument, - payload: PayloadMode, - sha256: String, -) -> Result { - if document.kind() == SegmentationKind::Fractional { - let runs = document - .fractional_runs() - .map_err(|error| format!("fractional SEG normalization failed: {error}"))?; - Ok(match payload { - PayloadMode::Full => SegMaskReport::FullFractional { - sha256, - runs: runs - .into_iter() - .map(|run| FractionalRunReport { - segment_number: run.segment_number(), - row: run.row(), - column_start: run.column_start(), - maximum_fractional_value: run.maximum_fractional_value(), - values: run.values().to_vec(), - }) - .collect(), - }, - PayloadMode::Digest => SegMaskReport::Digest { - sha256, - run_count: runs.len(), - }, - }) - } else { - let runs = document - .binary_runs() - .map_err(|error| format!("binary SEG normalization failed: {error}"))?; - Ok(match payload { - PayloadMode::Full => SegMaskReport::FullBinary { - sha256, - runs: runs - .into_iter() - .map(|run| BinaryRunReport { - segment_number: run.segment_number(), - row: run.row(), - column_start: run.column_start(), - length: run.length(), - }) - .collect(), - }, - PayloadMode::Digest => SegMaskReport::Digest { - sha256, - run_count: runs.len(), - }, - }) - } -} - -fn segment_report(index: usize, segment: &SegmentationSegment) -> SegmentReport { - let number = segment - .source_segment_number() - .unwrap_or_else(|| u16::try_from(index + 1).unwrap_or(u16::MAX)); - SegmentReport { - number, - label: segment.label().to_string(), - description: segment.description().to_string(), - generation_type: segment.generation_type().dicom_value(), - algorithms: if number == 0 { - Vec::new() - } else { - segment.algorithms().iter().map(algorithm_report).collect() - }, - category: code_report(segment.category()), - property_type: code_report(segment.property_type()), - property_type_modifiers: segment - .property_type_modifiers() - .iter() - .map(code_report) - .collect(), - tracking_id: segment.tracking_id().map(str::to_string), - tracking_uid: segment.tracking_uid().map(str::to_string), - anatomic_regions: segment.anatomic_regions().iter().map(code_report).collect(), - primary_anatomic_structures: segment - .primary_anatomic_structures() - .iter() - .map(code_report) - .collect(), - recommended_display_cielab: segment.recommended_display_cielab(), - } -} - -fn source_report( - source: &DicomAnnotationContext, - canonical_source: &DicomAnnotationContext, -) -> SourceReport { - let (columns, rows) = source.total_pixel_matrix_dimensions(); - let (tile_columns, tile_rows) = source.tile_dimensions(); - let (canonical_columns, canonical_rows) = canonical_source.total_pixel_matrix_dimensions(); - SourceReport { - sop_class_uid: source.sop_class_uid().to_string(), - sop_instance_uid: source.sop_instance_uid().to_string(), - series_instance_uid: source.series_instance_uid().to_string(), - study_instance_uid: source.study_instance_uid().to_string(), - frame_of_reference_uid: source.frame_of_reference_uid().map(str::to_string), - total_pixel_matrix_columns: columns, - total_pixel_matrix_rows: rows, - tile_columns, - tile_rows, - pixel_spacing: source.pixel_spacing(), - canonical_total_pixel_matrix_columns: canonical_columns, - canonical_total_pixel_matrix_rows: canonical_rows, - canonical_pixel_spacing: canonical_source.pixel_spacing(), - } -} - -fn measurement_report(measurement: &AnnotationMeasurement) -> MeasurementReport { - MeasurementReport { - concept: code_report(measurement.concept()), - units: code_report(measurement.units()), - values: measurement.values().to_vec(), - annotation_indices: measurement.annotation_indices().map(<[u32]>::to_vec), - } -} - -fn code_report(code: &DicomCode) -> CodeReport { - CodeReport { - value: code.value().to_string(), - value_kind: match code.value_kind() { - DicomCodeValueKind::Short => "short", - DicomCodeValueKind::Long => "long", - DicomCodeValueKind::Urn => "urn", - }, - scheme: code.scheme().to_string(), - coding_scheme_version: code.coding_scheme_version().map(str::to_string), - meaning: code.meaning().to_string(), - context_identifier: code.context_identifier().map(str::to_string), - context_uid: code.context_uid().map(str::to_string), - mapping_resource: code.mapping_resource().map(str::to_string), - mapping_resource_uid: code.mapping_resource_uid().map(str::to_string), - context_group_version: code.context_group_version().map(str::to_string), - context_group_local_version: code.context_group_local_version().map(str::to_string), - context_group_extension: code.context_group_extension(), - context_group_extension_creator_uid: code - .context_group_extension_creator_uid() - .map(str::to_string), - } -} - -fn algorithm_report(algorithm: &AlgorithmIdentification) -> AlgorithmReport { - AlgorithmReport { - family: code_report(algorithm.family()), - name_code: algorithm.name_code().map(code_report), - name: algorithm.name().to_string(), - version: algorithm.version().to_string(), - parameters: algorithm.parameters().map(str::to_string), - source: algorithm.source().map(str::to_string), - } -} - -pub(super) fn diagnostic_reports( - diagnostics: &[InteroperabilityDiagnostic], -) -> Vec { - diagnostics - .iter() - .map(|diagnostic| DiagnosticReport { - code: diagnostic.code().to_string(), - severity: match diagnostic.severity() { - DiagnosticSeverity::Info => "info", - DiagnosticSeverity::Warning => "warning", - DiagnosticSeverity::Error => "error", - }, - path: diagnostic.path().to_string(), - disposition: match diagnostic.disposition() { - DiagnosticDisposition::Normalized => "normalized", - DiagnosticDisposition::WouldDrop => "would_drop", - DiagnosticDisposition::Unsupported => "unsupported", - }, - message: diagnostic.message().to_string(), - }) - .collect() -} - -pub(super) fn file_report( - path: &Path, - bytes: u64, - sop_instance_uid: &str, - series_instance_uid: &str, -) -> FileReport { - FileReport { - path: path.to_string_lossy().into_owned(), - bytes, - sop_instance_uid: sop_instance_uid.to_string(), - series_instance_uid: series_instance_uid.to_string(), - } -} - -fn segmentation_kind_label(kind: SegmentationKind) -> &'static str { - match kind { - SegmentationKind::Binary => "binary", - SegmentationKind::LabelMap => "labelmap", - SegmentationKind::Fractional => "fractional", - } -} - -fn digest_f64(dimensions: usize, values: impl IntoIterator) -> String { - let mut digest = Sha256::new(); - digest.update((dimensions as u64).to_le_bytes()); - for value in values { - digest.update(value.to_bits().to_le_bytes()); - } - format!("{:x}", digest.finalize()) -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/legacy/schema.rs b/apps/dicom-viewer/src/bin/annotation_probe/legacy/schema.rs deleted file mode 100644 index 7e6caf0..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/legacy/schema.rs +++ /dev/null @@ -1,247 +0,0 @@ -use serde::Serialize; - -#[derive(Serialize)] -pub(super) struct ImplementationReport { - pub(super) name: &'static str, - pub(super) version: &'static str, -} - -#[derive(Serialize)] -pub(super) struct FileReport { - pub(super) path: String, - pub(super) bytes: u64, - pub(super) sop_instance_uid: String, - pub(super) series_instance_uid: String, -} - -#[derive(Serialize)] -pub(super) struct RuntimeReport { - pub(super) parse_ms: f64, - pub(super) write_ms: f64, - pub(super) verify_ms: f64, - pub(super) canonicalize_ms: f64, - pub(super) peak_tracked_heap_bytes: usize, -} - -#[derive(Serialize)] -pub(super) struct SuccessReport { - pub(super) schema_version: u32, - pub(super) status: &'static str, - pub(super) operation: &'static str, - pub(super) implementation: ImplementationReport, - pub(super) input: FileReport, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) output: Option, - pub(super) payload: &'static str, - pub(super) semantic: SemanticReport, - pub(super) diagnostics: Vec, - pub(super) runtime: RuntimeReport, -} - -#[derive(Serialize)] -pub(super) struct ErrorBody { - pub(super) code: &'static str, - pub(super) message: String, -} - -#[derive(Serialize)] -pub(super) struct ErrorReport { - pub(super) schema_version: u32, - pub(super) status: &'static str, - pub(super) operation: &'static str, - pub(super) implementation: ImplementationReport, - pub(super) error: ErrorBody, -} - -#[derive(Serialize)] -#[serde(tag = "object_type", content = "data")] -pub(super) enum SemanticReport { - Ann(AnnReport), - Seg(SegReport), -} - -#[derive(Serialize)] -pub(super) struct SourceReport { - pub(super) sop_class_uid: String, - pub(super) sop_instance_uid: String, - pub(super) series_instance_uid: String, - pub(super) study_instance_uid: String, - pub(super) frame_of_reference_uid: Option, - pub(super) total_pixel_matrix_columns: u32, - pub(super) total_pixel_matrix_rows: u32, - pub(super) tile_columns: u16, - pub(super) tile_rows: u16, - pub(super) pixel_spacing: Option<[f64; 2]>, - pub(super) canonical_total_pixel_matrix_columns: u32, - pub(super) canonical_total_pixel_matrix_rows: u32, - pub(super) canonical_pixel_spacing: Option<[f64; 2]>, -} - -#[derive(Serialize)] -pub(super) struct ContentReport { - pub(super) label: String, - pub(super) description: String, - pub(super) creator_name: Option, -} - -#[derive(Serialize)] -pub(super) struct AnnReport { - pub(super) sop_instance_uid: String, - pub(super) series_instance_uid: String, - pub(super) coordinate_type: String, - pub(super) pixel_origin_interpretation: Option, - pub(super) referenced_frame_number: Option, - pub(super) content: ContentReport, - pub(super) source: SourceReport, - pub(super) groups: Vec, -} - -#[derive(Serialize)] -pub(super) struct AnnotationGroupReport { - pub(super) uid: String, - pub(super) label: String, - pub(super) description: String, - pub(super) generation_type: &'static str, - pub(super) algorithms: Vec, - pub(super) category: CodeReport, - pub(super) property_type: CodeReport, - pub(super) property_type_modifiers: Vec, - pub(super) anatomic_regions: Vec, - pub(super) primary_anatomic_structures: Vec, - pub(super) applies_to_all_optical_paths: bool, - pub(super) referenced_optical_paths: Vec, - pub(super) applies_to_all_z_planes: bool, - pub(super) common_z_coordinates_mm: Vec, - pub(super) recommended_display_cielab: [u16; 3], - pub(super) graphic_type: &'static str, - pub(super) annotation_count: usize, - pub(super) measurements: Vec, - pub(super) geometry: GeometryReport, -} - -#[derive(Serialize)] -#[serde(tag = "mode")] -pub(super) enum GeometryReport { - Full { - native_dimensions: usize, - canonical_dimensions: usize, - native_coordinates: Vec, - canonical_level0_coordinates: Vec, - primitive_point_indices: Vec, - }, - Digest { - native_dimensions: usize, - canonical_dimensions: usize, - native_coordinate_count: usize, - canonical_coordinate_count: usize, - native_sha256: String, - canonical_level0_sha256: String, - primitive_point_indices: Vec, - }, -} - -#[derive(Serialize)] -pub(super) struct MeasurementReport { - pub(super) concept: CodeReport, - pub(super) units: CodeReport, - pub(super) values: Vec, - pub(super) annotation_indices: Option>, -} - -#[derive(Serialize)] -pub(super) struct CodeReport { - pub(super) value: String, - pub(super) value_kind: &'static str, - pub(super) scheme: String, - pub(super) coding_scheme_version: Option, - pub(super) meaning: String, - pub(super) context_identifier: Option, - pub(super) context_uid: Option, - pub(super) mapping_resource: Option, - pub(super) mapping_resource_uid: Option, - pub(super) context_group_version: Option, - pub(super) context_group_local_version: Option, - pub(super) context_group_extension: Option, - pub(super) context_group_extension_creator_uid: Option, -} - -#[derive(Serialize)] -pub(super) struct AlgorithmReport { - pub(super) family: CodeReport, - pub(super) name_code: Option, - pub(super) name: String, - pub(super) version: String, - pub(super) parameters: Option, - pub(super) source: Option, -} - -#[derive(Serialize)] -pub(super) struct SegReport { - pub(super) sop_instance_uid: String, - pub(super) series_instance_uid: String, - pub(super) segmentation_kind: &'static str, - pub(super) content: ContentReport, - pub(super) source: SourceReport, - pub(super) segments: Vec, - pub(super) masks: SegMaskReport, -} - -#[derive(Serialize)] -pub(super) struct SegmentReport { - pub(super) number: u16, - pub(super) label: String, - pub(super) description: String, - pub(super) generation_type: &'static str, - pub(super) algorithms: Vec, - pub(super) category: CodeReport, - pub(super) property_type: CodeReport, - pub(super) property_type_modifiers: Vec, - pub(super) tracking_id: Option, - pub(super) tracking_uid: Option, - pub(super) anatomic_regions: Vec, - pub(super) primary_anatomic_structures: Vec, - pub(super) recommended_display_cielab: [u16; 3], -} - -#[derive(Serialize)] -#[serde(tag = "mode")] -pub(super) enum SegMaskReport { - FullBinary { - sha256: String, - runs: Vec, - }, - FullFractional { - sha256: String, - runs: Vec, - }, - Digest { - sha256: String, - run_count: usize, - }, -} - -#[derive(Serialize)] -pub(super) struct BinaryRunReport { - pub(super) segment_number: u16, - pub(super) row: u32, - pub(super) column_start: u32, - pub(super) length: u32, -} - -#[derive(Serialize)] -pub(super) struct FractionalRunReport { - pub(super) segment_number: u16, - pub(super) row: u32, - pub(super) column_start: u32, - pub(super) maximum_fractional_value: u16, - pub(super) values: Vec, -} - -#[derive(Serialize)] -pub(super) struct DiagnosticReport { - pub(super) code: String, - pub(super) severity: &'static str, - pub(super) path: String, - pub(super) disposition: &'static str, - pub(super) message: String, -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/legacy/tests.rs b/apps/dicom-viewer/src/bin/annotation_probe/legacy/tests.rs deleted file mode 100644 index 40ab493..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/legacy/tests.rs +++ /dev/null @@ -1,357 +0,0 @@ -use super::*; -use dicom_core::value::{DataSetSequence, PrimitiveValue, Value}; -use dicom_core::{DataElement, Length, VR}; -use dicom_dictionary_std::{tags, uids}; -use dicom_object::{FileMetaTableBuilder, InMemDicomObject}; -use wsi_dicom_annotations::{ - AlgorithmIdentification, AnnotationGroup, AnnotationMeasurement, DicomCode, GenerationType, - Point2, SegmentationDocument, SegmentationSegment, -}; - -#[test] -fn parses_inspect_defaults() { - let arguments = parse_arguments( - ["inspect", "--source", "source.dcm", "annotations.dcm"] - .into_iter() - .map(OsString::from) - .collect::>(), - ) - .unwrap(); - - assert_eq!(arguments.operation, Operation::Inspect); - assert_eq!(arguments.payload, PayloadMode::Full); - assert_eq!(arguments.canonical_source, PathBuf::from("source.dcm")); - assert!(arguments.output.is_none()); -} - -#[test] -fn roundtrip_requires_a_distinct_output() { - assert!(parse_arguments( - ["roundtrip", "--source", "source.dcm", "annotations.dcm"] - .into_iter() - .map(OsString::from) - .collect::>() - ) - .is_err()); - assert!(parse_arguments( - [ - "roundtrip", - "--source", - "source.dcm", - "--output", - "annotations.dcm", - "annotations.dcm", - ] - .into_iter() - .map(OsString::from) - .collect::>() - ) - .is_err()); -} - -#[test] -fn parser_rejects_every_ambiguous_or_incomplete_legacy_shape() { - let parse = |values: &[&str]| { - parse_arguments( - values - .iter() - .copied() - .map(OsString::from) - .collect::>(), - ) - }; - for arguments in [ - vec![], - vec!["convert"], - vec!["inspect", "--source"], - vec!["inspect", "--canonical-source"], - vec!["roundtrip", "--output"], - vec!["inspect", "--payload"], - vec!["inspect", "--payload", "brief"], - vec!["inspect", "--unknown"], - vec!["inspect", "--source", "source", "one", "two"], - vec!["inspect", "annotations"], - vec!["inspect", "--source", "source"], - vec![ - "inspect", - "--source", - "source", - "--output", - "out", - "annotations", - ], - vec![ - "inspect", - "--source", - "source", - "--allow-lossy", - "annotations", - ], - ] { - assert!(parse(&arguments).is_err(), "accepted {arguments:?}"); - } - - let parsed = parse(&[ - "roundtrip", - "--source", - "source", - "--canonical-source", - "level-zero", - "--payload", - "digest", - "--allow-lossy", - "--output", - "out", - "annotations", - ]) - .unwrap(); - assert_eq!(parsed.canonical_source, PathBuf::from("level-zero")); - assert_eq!(parsed.payload, PayloadMode::Digest); - assert!(parsed.allow_lossy); -} - -#[test] -fn operation_failures_emit_machine_readable_json() { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - - let exit = crate::command::execute( - [ - "inspect", - "--source", - "missing-source.dcm", - "missing-ann.dcm", - ] - .into_iter() - .map(OsString::from) - .collect::>(), - &mut stdout, - &mut stderr, - ); - - assert_eq!(exit, 1); - let report: serde_json::Value = serde_json::from_slice(&stdout).unwrap(); - assert_eq!(report["status"], "error"); - assert_eq!(report["operation"], "inspect"); - assert!(!stderr.is_empty()); -} - -#[test] -fn inspect_and_roundtrip_emit_stable_ann_and_seg_semantics() { - let directory = tempfile::tempdir().unwrap(); - let source = directory.path().join("source.dcm"); - let ann = directory.path().join("annotations.dcm"); - let ann_output = directory.path().join("annotations-roundtrip.dcm"); - let seg = directory.path().join("segmentation.dcm"); - let seg_output = directory.path().join("segmentation-roundtrip.dcm"); - write_source_wsi(&source); - let context = DicomAnnotationContext::from_source(&source).unwrap(); - let category = code("MORPH", "Morphology"); - let property = code("TUMOR", "Tumor"); - let algorithm = AlgorithmIdentification::new( - code("AI", "Artificial intelligence"), - "test-classifier", - "1.0", - ) - .unwrap() - .with_name_code(code("MODEL", "Model")) - .with_parameters("threshold=0.5") - .unwrap() - .with_source("test fixture") - .unwrap(); - let mut cells = AnnotationGroup::points( - "Cells", - category.clone(), - property.clone(), - [1, 2, 3], - vec![Point2::new(1.0, 2.0), Point2::new(3.0, 4.0)], - ) - .unwrap() - .with_description("Automated cell detections") - .unwrap() - .with_generation(GenerationType::Automatic, vec![algorithm]) - .unwrap() - .with_property_type_modifiers(vec![code("INV", "Invasive")]) - .with_anatomic_regions(vec![code("BREAST", "Breast")]) - .with_primary_anatomic_structures(vec![code("LOBULE", "Lobule")]); - cells - .add_measurement(AnnotationMeasurement::new( - code("AREA", "Area"), - DicomCode::new("mm2", "UCUM", "square millimeter").unwrap(), - vec![2.5, 4.5], - )) - .unwrap(); - AnnotationDocument::new(context.clone(), vec![cells]) - .unwrap() - .write_ann(&ann) - .unwrap(); - SegmentationDocument::binary( - context, - vec![SegmentationSegment::new( - "Tumor", - category, - property, - [1, 2, 3], - vec![vec![ - Point2::new(0.0, 0.0), - Point2::new(4.0, 0.0), - Point2::new(4.0, 4.0), - Point2::new(0.0, 4.0), - ]], - Vec::new(), - ) - .unwrap()], - ) - .unwrap() - .write_seg(&seg) - .unwrap(); - - let ann_inspect = invoke([ - "inspect", - "--source", - source.to_str().unwrap(), - ann.to_str().unwrap(), - ]); - let ann_roundtrip = invoke([ - "roundtrip", - "--source", - source.to_str().unwrap(), - "--payload", - "digest", - "--output", - ann_output.to_str().unwrap(), - ann.to_str().unwrap(), - ]); - assert_eq!(ann_inspect["status"], "ok"); - assert_eq!(ann_roundtrip["status"], "ok"); - assert_eq!(ann_inspect["semantic"]["object_type"], "Ann"); - assert_eq!( - ann_inspect["semantic"]["data"]["groups"][0]["measurements"][0]["values"], - serde_json::json!([2.5, 4.5]) - ); - assert_eq!( - ann_inspect["semantic"]["data"]["groups"][0]["algorithms"][0]["name"], - "test-classifier" - ); - assert_ne!( - ann_roundtrip["input"]["sop_instance_uid"], - ann_roundtrip["output"]["sop_instance_uid"] - ); - assert_eq!( - ann_inspect["semantic"]["data"]["groups"][0]["uid"], - ann_roundtrip["semantic"]["data"]["groups"][0]["uid"] - ); - - let seg_inspect = invoke([ - "inspect", - "--source", - source.to_str().unwrap(), - seg.to_str().unwrap(), - ]); - let seg_roundtrip = invoke([ - "roundtrip", - "--source", - source.to_str().unwrap(), - "--output", - seg_output.to_str().unwrap(), - seg.to_str().unwrap(), - ]); - assert_eq!(seg_inspect["semantic"]["object_type"], "Seg"); - assert_eq!( - seg_inspect["semantic"]["data"]["masks"]["sha256"], - seg_roundtrip["semantic"]["data"]["masks"]["sha256"] - ); - assert_ne!( - seg_roundtrip["input"]["sop_instance_uid"], - seg_roundtrip["output"]["sop_instance_uid"] - ); -} - -fn invoke(arguments: [&str; N]) -> serde_json::Value { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let exit = crate::command::execute( - arguments - .into_iter() - .map(OsString::from) - .collect::>(), - &mut stdout, - &mut stderr, - ); - assert_eq!(exit, 0, "{}", String::from_utf8_lossy(&stderr)); - serde_json::from_slice(&stdout).unwrap() -} - -fn code(value: &str, meaning: &str) -> DicomCode { - DicomCode::new(value, "99FRAMES", meaning).unwrap() -} - -pub(crate) fn write_source_wsi(path: &Path) { - const SOP_UID: &str = "1.2.826.0.1.3680043.10.777.501"; - let mut origin = InMemDicomObject::new_empty(); - origin.put(DataElement::new( - tags::X_OFFSET_IN_SLIDE_COORDINATE_SYSTEM, - VR::DS, - "0", - )); - origin.put(DataElement::new( - tags::Y_OFFSET_IN_SLIDE_COORDINATE_SYSTEM, - VR::DS, - "0", - )); - let mut object = InMemDicomObject::new_empty(); - for element in [ - DataElement::new( - tags::SOP_CLASS_UID, - VR::UI, - uids::VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE, - ), - DataElement::new(tags::SOP_INSTANCE_UID, VR::UI, SOP_UID), - DataElement::new(tags::STUDY_INSTANCE_UID, VR::UI, "2.25.502"), - DataElement::new(tags::SERIES_INSTANCE_UID, VR::UI, "2.25.503"), - DataElement::new(tags::FRAME_OF_REFERENCE_UID, VR::UI, "2.25.504"), - DataElement::new(tags::PATIENT_NAME, VR::PN, "Research^Slide"), - DataElement::new(tags::PATIENT_ID, VR::LO, "R-1"), - DataElement::new(tags::STUDY_DATE, VR::DA, "20260813"), - DataElement::new(tags::STUDY_TIME, VR::TM, "120000"), - DataElement::new(tags::STUDY_ID, VR::SH, "STUDY-1"), - DataElement::new(tags::ACCESSION_NUMBER, VR::SH, ""), - DataElement::new(tags::ROWS, VR::US, PrimitiveValue::from(4_u16)), - DataElement::new(tags::COLUMNS, VR::US, PrimitiveValue::from(4_u16)), - DataElement::new( - tags::TOTAL_PIXEL_MATRIX_ROWS, - VR::UL, - PrimitiveValue::from(8_u32), - ), - DataElement::new( - tags::TOTAL_PIXEL_MATRIX_COLUMNS, - VR::UL, - PrimitiveValue::from(8_u32), - ), - DataElement::new(tags::IMAGE_ORIENTATION_SLIDE, VR::DS, "1\\0\\0\\0\\1\\0"), - DataElement::new(tags::PIXEL_SPACING, VR::DS, "0.00025\\0.00025"), - DataElement::new(tags::SLICE_THICKNESS, VR::DS, "0.001"), - ] { - object.put(element); - } - object.put(DataElement::new( - tags::TOTAL_PIXEL_MATRIX_ORIGIN_SEQUENCE, - VR::SQ, - Value::from(DataSetSequence::new(vec![origin], Length::UNDEFINED)), - )); - object.put(DataElement::new( - tags::PIXEL_DATA, - VR::OB, - PrimitiveValue::from(vec![0_u8; 48]), - )); - object - .with_meta( - FileMetaTableBuilder::new() - .media_storage_sop_class_uid(uids::VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE) - .media_storage_sop_instance_uid(SOP_UID) - .transfer_syntax(uids::EXPLICIT_VR_LITTLE_ENDIAN), - ) - .unwrap() - .write_to_file(path) - .unwrap(); -} diff --git a/apps/dicom-viewer/src/bin/annotation_probe/publication.rs b/apps/dicom-viewer/src/bin/annotation_probe/publication.rs deleted file mode 100644 index d305070..0000000 --- a/apps/dicom-viewer/src/bin/annotation_probe/publication.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::fs; -use std::io::Read; -use std::path::Path; - -use wsi_dicom_annotations::DicomPublicationError; - -use crate::conversion_report::ConversionError; - -pub(crate) fn publication_error(error: DicomPublicationError) -> ConversionError { - ConversionError::new(error.code(), error.to_string()) -} - -pub(crate) fn read_bounded_file( - path: &Path, - maximum_bytes: u64, - description: &str, -) -> Result, ConversionError> { - let metadata = fs::metadata(path) - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", path, error))?; - if !metadata.is_file() || metadata.len() > maximum_bytes { - return Err(ConversionError::new( - "INPUT_SIZE_INVALID", - format!( - "{description} {} must be a file no larger than {maximum_bytes} bytes", - path.display() - ), - )); - } - let capacity = usize::try_from(metadata.len()).map_err(|_| { - ConversionError::new( - "INPUT_SIZE_INVALID", - format!("{description} length does not fit this platform"), - ) - })?; - let mut bytes = Vec::with_capacity(capacity); - fs::File::open(path) - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", path, error))? - .take(maximum_bytes + 1) - .read_to_end(&mut bytes) - .map_err(|error| ConversionError::io("INPUT_READ_FAILED", path, error))?; - if bytes.len() as u64 > maximum_bytes { - return Err(ConversionError::new( - "INPUT_SIZE_INVALID", - format!("{description} changed while being read or exceeds its size limit"), - )); - } - Ok(bytes) -} diff --git a/apps/dicom-viewer/src/main.rs b/apps/dicom-viewer/src/main.rs index 650b0a1..a7e91a5 100644 --- a/apps/dicom-viewer/src/main.rs +++ b/apps/dicom-viewer/src/main.rs @@ -1,10 +1,16 @@ #![forbid(unsafe_code)] +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] mod app; -const APP_TITLE: &str = "WSI Viewer — Research Use Only"; +const APP_TITLE: &str = "Slide Viewer"; const APP_ID: &str = "io.frames.dicom-viewer"; +fn app_icon() -> eframe::egui::IconData { + eframe::icon_data::from_png_bytes(include_bytes!("../assets/app-icon.png")) + .expect("the embedded application icon must be a valid PNG") +} + fn main() -> eframe::Result<()> { let initial_path = std::env::args_os().nth(1).map(std::path::PathBuf::from); let options = native_options(); @@ -30,6 +36,7 @@ fn native_options() -> eframe::NativeOptions { viewport: eframe::egui::ViewportBuilder::default() .with_app_id(APP_ID) .with_title(APP_TITLE) + .with_icon(app_icon()) .with_inner_size([1320.0, 880.0]) .with_min_inner_size([900.0, 640.0]), renderer: eframe::Renderer::Wgpu, @@ -72,10 +79,16 @@ mod tests { } #[test] - fn native_viewer_is_explicitly_labeled_for_research_use() { - assert!(APP_TITLE.contains("Research Use Only")); + fn native_viewer_uses_the_slide_viewer_identity_and_icon() { + assert_eq!(APP_TITLE, "Slide Viewer"); assert_eq!(native_options().viewport.title.as_deref(), Some(APP_TITLE)); assert_eq!(native_options().viewport.app_id.as_deref(), Some(APP_ID)); + let icon = native_options() + .viewport + .icon + .expect("the native window should use the packaged application icon"); + assert_eq!((icon.width, icon.height), (256, 256)); + assert_eq!(icon.rgba.len(), 256 * 256 * 4); } #[test] diff --git a/apps/dicom-viewer/tests/annotation_probe_cli.rs b/apps/dicom-viewer/tests/annotation_probe_cli.rs deleted file mode 100644 index fc5601e..0000000 --- a/apps/dicom-viewer/tests/annotation_probe_cli.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::process::Command; - -#[test] -fn annotation_probe_process_preserves_json_stdout_and_usage_exit_contracts() { - let probe = env!("CARGO_BIN_EXE_annotation_probe"); - - let recognized = Command::new(probe) - .arg("convert-raster") - .output() - .expect("annotation_probe should start"); - assert_eq!(recognized.status.code(), Some(2)); - let report: serde_json::Value = serde_json::from_slice(&recognized.stdout).unwrap(); - assert_eq!(report["schema"], "conversion-report-v1"); - assert_eq!(report["status"], "error"); - assert_eq!(report["error"]["code"], "USAGE_ERROR"); - assert!(String::from_utf8(recognized.stderr) - .unwrap() - .contains("--source is required")); - - let unknown = Command::new(probe) - .arg("unsupported-command") - .output() - .expect("annotation_probe should start"); - assert_eq!(unknown.status.code(), Some(2)); - assert!(unknown.stdout.is_empty()); - let stderr = String::from_utf8(unknown.stderr).unwrap(); - assert!(stderr.contains("first argument must name a supported command")); - assert!(stderr.contains("annotation_probe convert-geojson")); - - let directory = tempfile::tempdir().unwrap(); - let missing_source = directory.path().join("missing-source.dcm"); - let geojson_output = directory.path().join("ann.dcm"); - let raster_output = directory.path().join("pm.dcm"); - let conversion_cases = [ - vec![ - "convert-geojson".into(), - "--source".into(), - missing_source.as_os_str().into(), - "--mapping".into(), - directory - .path() - .join("missing-mapping.json") - .into_os_string(), - "--coordinate-space".into(), - "level0-pixels".into(), - "--target".into(), - "ann".into(), - "--output".into(), - geojson_output.as_os_str().into(), - directory.path().join("missing.geojson").into_os_string(), - ], - vec![ - "convert-raster".into(), - "--source".into(), - missing_source.as_os_str().into(), - "--profile".into(), - directory - .path() - .join("missing-profile.json") - .into_os_string(), - "--output".into(), - raster_output.as_os_str().into(), - directory.path().join("missing.npy").into_os_string(), - ], - ]; - for arguments in conversion_cases { - let failed = Command::new(probe) - .args(arguments) - .output() - .expect("annotation_probe conversion should start"); - assert_eq!(failed.status.code(), Some(1)); - let report: serde_json::Value = serde_json::from_slice(&failed.stdout).unwrap(); - assert_eq!(report["schema"], "conversion-report-v1"); - assert_eq!(report["status"], "error"); - assert_eq!(report["error"]["code"], "SOURCE_READ_FAILED"); - assert!(!failed.stderr.is_empty()); - } - assert!(!geojson_output.exists()); - assert!(!raster_output.exists()); -} diff --git a/crates/dicom-viewer-core/Cargo.toml b/crates/dicom-viewer-core/Cargo.toml index 44948c2..ae2d125 100644 --- a/crates/dicom-viewer-core/Cargo.toml +++ b/crates/dicom-viewer-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dicom-viewer-core" -version = "0.1.0" +version = "0.1.1" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -13,10 +13,7 @@ cuda = ["wsi-rs/cuda"] [dependencies] dicom-core = { workspace = true } dicom-dictionary-std = { workspace = true } -dicom-encoding = { workspace = true } dicom-object = { workspace = true } -dicom-parser = { workspace = true } -dicom-transfer-syntax-registry = { workspace = true } wsi-rs = { workspace = true } thiserror = { workspace = true } lcms2 = { workspace = true } diff --git a/crates/dicom-viewer-core/src/annotation_test_support.rs b/crates/dicom-viewer-core/src/annotation_test_support.rs index dd65842..b0b56b0 100644 --- a/crates/dicom-viewer-core/src/annotation_test_support.rs +++ b/crates/dicom-viewer-core/src/annotation_test_support.rs @@ -11,6 +11,24 @@ pub(crate) fn write_source_wsi( height: u32, tile_width: u16, tile_height: u16, +) { + write_source_wsi_with_optical_paths( + path, + width, + height, + tile_width, + tile_height, + &["OPTICAL-1"], + ); +} + +pub(crate) fn write_source_wsi_with_optical_paths( + path: &Path, + width: u32, + height: u32, + tile_width: u16, + tile_height: u16, + optical_path_identifiers: &[&str], ) { const SOP_UID: &str = "1.2.826.0.1.3680043.10.777.101"; const SERIES_UID: &str = "1.2.826.0.1.3680043.10.777.102"; @@ -27,6 +45,18 @@ pub(crate) fn write_source_wsi( VR::DS, "0", )); + 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::>(); let frame_count = width .div_ceil(u32::from(tile_width)) .saturating_mul(height.div_ceil(u32::from(tile_height))); @@ -41,7 +71,7 @@ pub(crate) fn write_source_wsi( DataElement::new(tags::STUDY_INSTANCE_UID, VR::UI, STUDY_UID), DataElement::new(tags::SERIES_INSTANCE_UID, VR::UI, SERIES_UID), DataElement::new(tags::FRAME_OF_REFERENCE_UID, VR::UI, FOR_UID), - 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, "20260804"), DataElement::new(tags::STUDY_TIME, VR::TM, "120000"), @@ -56,7 +86,7 @@ pub(crate) fn write_source_wsi( DataElement::new( tags::NUMBER_OF_OPTICAL_PATHS, VR::UL, - PrimitiveValue::from(1_u32), + PrimitiveValue::from(u32::try_from(optical_paths.len()).unwrap()), ), DataElement::new( tags::TOTAL_PIXEL_MATRIX_FOCAL_PLANES, @@ -86,6 +116,11 @@ pub(crate) fn write_source_wsi( VR::SQ, Value::from(DataSetSequence::new(vec![origin], Length::UNDEFINED)), )); + 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, diff --git a/crates/dicom-viewer-core/src/annotations/mod.rs b/crates/dicom-viewer-core/src/annotations/mod.rs index f7cb505..3376ead 100644 --- a/crates/dicom-viewer-core/src/annotations/mod.rs +++ b/crates/dicom-viewer-core/src/annotations/mod.rs @@ -1,12 +1,14 @@ mod workspace; pub use workspace::{ - CompositeSegmentGeometry, ControlledFindingSite, ExternalLayerKind, ExternalLayerReference, - ExternalPromotionSource, LayerPresentation, PolygonComponent, SegmentEditOutcome, - SegmentOperation, SegmentationLayer, SegmentationPrimitive, SegmentationPrimitiveGeometry, - SegmentationSegmentFinding, SourceFrameContext, VectorFinding, VectorFindingGeometry, - VectorLayer, VectorSegmentationPolicy, WorkspaceDocument, WorkspaceGeoJsonExport, - WorkspaceLinearMeasurement, WorkspaceObjectProvenance, WorkspacePresentation, + BulkAnnExport, BulkAnnotationLocation, CompositeSegmentGeometry, ControlledFindingSite, + ExternalLayerKind, ExternalLayerReference, ExternalPromotionSource, LayerPresentation, + PolygonComponent, SegmentEditOutcome, SegmentOperation, SegmentationLayer, + SegmentationPrimitive, SegmentationPrimitiveGeometry, SegmentationSegmentFinding, + SourceFrameContext, VectorFinding, VectorFindingGeometry, VectorLayer, + VectorSegmentationPolicy, WorkspaceDocument, WorkspaceGeoJsonExport, + WorkspaceLinearMeasurement, WorkspaceObjectGeometryKind, WorkspaceObjectProvenance, + WorkspaceObjectRef, WorkspacePresentation, }; pub use wsi_dicom_annotations::*; diff --git a/crates/dicom-viewer-core/src/annotations/workspace.rs b/crates/dicom-viewer-core/src/annotations/workspace.rs index bab1837..8e5768a 100644 --- a/crates/dicom-viewer-core/src/annotations/workspace.rs +++ b/crates/dicom-viewer-core/src/annotations/workspace.rs @@ -5,8 +5,10 @@ mod export; mod geojson; mod model; -pub use document::{SegmentEditOutcome, WorkspaceDocument}; -pub use export::VectorSegmentationPolicy; +pub use document::{ + SegmentEditOutcome, WorkspaceDocument, WorkspaceObjectGeometryKind, WorkspaceObjectRef, +}; +pub use export::{BulkAnnExport, BulkAnnotationLocation, VectorSegmentationPolicy}; pub use geojson::WorkspaceGeoJsonExport; pub use model::{ CompositeSegmentGeometry, ControlledFindingSite, ExternalLayerKind, ExternalLayerReference, diff --git a/crates/dicom-viewer-core/src/annotations/workspace/compatibility.rs b/crates/dicom-viewer-core/src/annotations/workspace/compatibility.rs index 3a98657..2b13fbb 100644 --- a/crates/dicom-viewer-core/src/annotations/workspace/compatibility.rs +++ b/crates/dicom-viewer-core/src/annotations/workspace/compatibility.rs @@ -1,5 +1,4 @@ use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; use crate::{ frames_viewer_producer, polygon_signed_area, AnnotationDocument, AnnotationGroup, @@ -7,7 +6,9 @@ use crate::{ }; use super::document::WorkspaceDocument; -use super::export::{apply_vector_context, dicom_label, finding_site_code}; +use super::export::shared::{ + apply_vector_context, dicom_label, finding_site_code, validate_ann_source_context, +}; use super::model::{PolygonComponent, VectorFindingGeometry}; const VIABLE_TUMOR_CIELAB: [u16; 3] = [49_152, 20_000, 48_000]; @@ -35,6 +36,11 @@ impl WorkspaceDocument { let mut groups = Vec::new(); for finding in self.vector_findings() { + validate_ann_source_context( + &format!("compatibility finding #{}", finding.ordinal()), + finding.source_frame(), + context, + )?; let class = self.scheme().class(finding.class_id()).ok_or_else(|| { ViewerError::InvalidInput( "finding references an unknown compatibility class".into(), @@ -71,6 +77,11 @@ impl WorkspaceDocument { } for segment in self.segments() { + validate_ann_source_context( + &format!("compatibility segment #{}", segment.ordinal()), + segment.source_frame(), + context, + )?; if segment.class_id() != "viable-tumor" { return Err(ViewerError::InvalidInput( "compatibility segmentation contains a non-viable-tumor segment".into(), @@ -104,9 +115,13 @@ impl WorkspaceDocument { exclusion.clone(), EXCLUSION_CIELAB, holes, - )? - .with_uid(derived_exclusion_uid(segment.object_id()))?; + )?; exclusion_group = apply_segment_context(self, segment, exclusion_group)?; + exclusion_group = exclusion_group.with_deterministic_uid( + "frames-dicom-viewer:tumor-mask-exclusion:v1", + context.sop_instance_uid(), + &segment.object_id().to_string(), + )?; groups.push(exclusion_group); } } @@ -226,19 +241,6 @@ fn apply_segment_context( Ok(group) } -fn derived_exclusion_uid(object_id: uuid::Uuid) -> String { - let digest = Sha256::digest( - [ - b"frames-tumor-mask-exclusion-v1\0".as_slice(), - object_id.as_bytes(), - ] - .concat(), - ); - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&digest[..16]); - format!("2.25.{}", u128::from_be_bytes(bytes)) -} - fn closed_ring(points: &[Point2], positive_area: bool) -> Vec<[f64; 2]> { let mut ordered = points.to_vec(); if (polygon_signed_area(&ordered) > 0.0) != positive_area { diff --git a/crates/dicom-viewer-core/src/annotations/workspace/document.rs b/crates/dicom-viewer-core/src/annotations/workspace/document.rs index d04ba1b..b3e25ec 100644 --- a/crates/dicom-viewer-core/src/annotations/workspace/document.rs +++ b/crates/dicom-viewer-core/src/annotations/workspace/document.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -14,12 +14,17 @@ use super::composition::{ }; use super::model::{ CompositeSegmentGeometry, ControlledFindingSite, ExternalLayerReference, - ExternalPromotionSource, LayerPresentation, SegmentOperation, SegmentationLayer, - SegmentationPrimitive, SegmentationSegmentFinding, SourceFrameContext, VectorFinding, - VectorFindingGeometry, VectorLayer, WorkspaceLinearMeasurement, WorkspaceObjectIdentity, - WorkspaceObjectProvenance, WorkspacePresentation, + ExternalPromotionSource, SegmentOperation, SegmentationLayer, SegmentationPrimitive, + SegmentationSegmentFinding, SourceFrameContext, VectorFinding, VectorFindingGeometry, + VectorLayer, WorkspaceLinearMeasurement, WorkspaceObjectIdentity, WorkspaceObjectProvenance, + WorkspacePresentation, }; +mod layers; +mod object; +mod validation; +pub use object::{WorkspaceObjectGeometryKind, WorkspaceObjectRef}; + const WORKSPACE_SCHEMA_VERSION: u32 = 1; const MAX_WORKSPACE_BYTES: usize = 128 * 1024 * 1024; const MAX_EDITABLE_OBJECTS: usize = 100_000; @@ -150,25 +155,6 @@ impl WorkspaceDocument { .flat_map(|layer| layer.segments().iter()) } - #[must_use] - pub fn finding(&self, object_id: Uuid) -> Option<&VectorFinding> { - self.vector_findings() - .find(|finding| finding.object_id() == object_id) - } - - #[must_use] - pub fn segment(&self, object_id: Uuid) -> Option<&SegmentationSegmentFinding> { - self.segments() - .find(|segment| segment.object_id() == object_id) - } - - #[must_use] - pub fn measurement(&self, object_id: Uuid) -> Option<&WorkspaceLinearMeasurement> { - self.measurements - .iter() - .find(|measurement| measurement.object_id() == object_id) - } - pub fn add_vector_finding( &mut self, layer_id: Uuid, @@ -534,146 +520,6 @@ impl WorkspaceDocument { Ok(object_id) } - pub fn add_external_layer(&mut self, layer: ExternalLayerReference) -> Result { - if layer.name().trim().is_empty() || layer.name().len() > 256 { - return Err(ViewerError::InvalidInput( - "external layer name must be 1..=256 bytes".into(), - )); - } - if self - .external_layers - .iter() - .any(|item| item.id() == layer.id()) - { - return Err(ViewerError::InvalidInput( - "external layer ID already exists".into(), - )); - } - let id = layer.id(); - self.presentation.insert_layer(id); - self.external_layers.push(layer); - self.bump_revision(); - Ok(id) - } - - pub fn remove_external_layer(&mut self, layer_id: Uuid) -> Result { - let Some(index) = self - .external_layers - .iter() - .position(|layer| layer.id() == layer_id) - else { - return Ok(false); - }; - self.external_layers.remove(index); - self.presentation.remove_layer(layer_id); - self.bump_revision(); - Ok(true) - } - - /// Updates an unloaded external-layer reference after its payload has been - /// validated and loaded. The layer identity and any class mappings remain - /// stable so a discovered sidecar cannot turn into a duplicate layer. - pub fn hydrate_external_layer( - &mut self, - layer_id: Uuid, - source_object_count: u64, - source_digest: Option, - ) -> Result<()> { - if source_digest - .as_deref() - .is_some_and(|digest| digest.is_empty() || digest.len() > 128) - { - return Err(ViewerError::InvalidInput( - "external source digest must be 1..=128 bytes when present".into(), - )); - } - let layer = self - .external_layers - .iter_mut() - .find(|layer| layer.id() == layer_id) - .ok_or_else(|| { - ViewerError::InvalidInput("the external source layer does not exist".into()) - })?; - if let (Some(expected), Some(actual)) = (layer.source_digest(), source_digest.as_deref()) { - if expected != actual { - return Err(ViewerError::InvalidInput( - "the external source content changed; remove the saved source layer and import it explicitly" - .into(), - )); - } - } - let source_digest = source_digest.or_else(|| layer.source_digest().map(ToOwned::to_owned)); - layer.hydrate(source_object_count, source_digest); - self.bump_revision(); - Ok(()) - } - - pub fn set_external_class_mapping( - &mut self, - layer_id: Uuid, - source_class: &str, - target_class_id: &str, - ) -> Result<()> { - if source_class.trim().is_empty() || source_class.len() > 1_024 { - return Err(ViewerError::InvalidInput( - "external source class key must be 1..=1024 bytes".into(), - )); - } - if self.scheme.class(target_class_id).is_none() { - return Err(ViewerError::InvalidInput( - "external class mapping target is not in the pinned annotation scheme".into(), - )); - } - let layer = self - .external_layers - .iter_mut() - .find(|layer| layer.id() == layer_id) - .ok_or_else(|| { - ViewerError::InvalidInput("the external source layer does not exist".into()) - })?; - layer.set_class_mapping(source_class.to_owned(), target_class_id.to_owned()); - self.bump_revision(); - Ok(()) - } - - pub fn delete_object(&mut self, object_id: Uuid) -> Result { - for layer in &mut self.vector_layers { - if let Some(index) = layer - .findings() - .iter() - .position(|finding| finding.object_id() == object_id) - { - layer.findings_mut().remove(index); - self.presentation.remove_object(object_id); - self.bump_revision(); - return Ok(true); - } - } - for layer in &mut self.segmentation_layers { - if let Some(index) = layer - .segments() - .iter() - .position(|segment| segment.object_id() == object_id) - { - layer.segments_mut().remove(index); - self.presentation.remove_object(object_id); - self.bump_revision(); - return Ok(true); - } - } - if let Some(index) = self - .measurements - .iter() - .position(|measurement| measurement.object_id() == object_id) - { - self.measurements.remove(index); - self.presentation.remove_object(object_id); - self.bump_revision(); - return Ok(true); - } - Ok(false) - } - pub fn move_vector_vertex( &mut self, object_id: Uuid, @@ -767,40 +613,23 @@ impl WorkspaceDocument { } pub fn reclassify_object(&mut self, object_id: Uuid, class_id: &str) -> Result<()> { - let geometry = if let Some(finding) = self.finding(object_id) { - match finding.geometry() { - VectorFindingGeometry::Point(_) => AnnotationClassGeometry::Point, - VectorFindingGeometry::Regions(_) => AnnotationClassGeometry::Region, + let geometry = match self.object(object_id).map(|object| object.geometry_kind()) { + Some(object::WorkspaceObjectGeometryKind::Point) => AnnotationClassGeometry::Point, + Some( + object::WorkspaceObjectGeometryKind::Region + | object::WorkspaceObjectGeometryKind::Segmentation + | object::WorkspaceObjectGeometryKind::Measurement, + ) => AnnotationClassGeometry::Region, + None => { + return Err(ViewerError::InvalidInput( + "the selected workspace object does not exist".into(), + )) } - } else if self.segment(object_id).is_some() || self.measurement(object_id).is_some() { - AnnotationClassGeometry::Region - } else { - return Err(ViewerError::InvalidInput( - "the selected workspace object does not exist".into(), - )); }; self.validate_class(class_id, geometry)?; - if let Some(finding) = self - .vector_layers - .iter_mut() - .flat_map(|layer| layer.findings_mut()) - .find(|finding| finding.object_id() == object_id) - { - finding.set_class_id(class_id.to_owned()); - } else if let Some(segment) = self - .segmentation_layers - .iter_mut() - .flat_map(|layer| layer.segments_mut()) - .find(|segment| segment.object_id() == object_id) - { - segment.set_class_id(class_id.to_owned()); - } else if let Some(measurement) = self - .measurements - .iter_mut() - .find(|measurement| measurement.object_id() == object_id) - { - measurement.set_class_id(class_id.to_owned()); - } + self.object_mut(object_id) + .expect("the object was checked before mutation") + .set_class_id(class_id.to_owned()); self.bump_revision(); Ok(()) } @@ -831,129 +660,60 @@ impl WorkspaceDocument { }) }) .transpose()?; - if let Some(finding) = self - .vector_layers - .iter_mut() - .flat_map(|layer| layer.findings_mut()) - .find(|finding| finding.object_id() == object_id) - { - if finding.finding_site() != site.as_ref() { - finding.set_finding_site(site); - self.bump_revision(); - } - return Ok(()); - } - if let Some(segment) = self - .segmentation_layers - .iter_mut() - .flat_map(|layer| layer.segments_mut()) - .find(|segment| segment.object_id() == object_id) - { - if segment.finding_site() != site.as_ref() { - segment.set_finding_site(site); - self.bump_revision(); - } - return Ok(()); - } - if let Some(measurement) = self - .measurements - .iter_mut() - .find(|measurement| measurement.object_id() == object_id) - { - if measurement.finding_site() != site.as_ref() { - measurement.set_finding_site(site); - self.bump_revision(); + let changed = { + let mut object = self.object_mut(object_id).ok_or_else(|| { + ViewerError::InvalidInput("the selected workspace object does not exist".into()) + })?; + if object.finding_site() == site.as_ref() { + false + } else { + object.set_finding_site(site); + true } - return Ok(()); + }; + if changed { + self.bump_revision(); } - Err(ViewerError::InvalidInput( - "the selected workspace object does not exist".into(), - )) + Ok(()) } pub fn set_object_name(&mut self, object_id: Uuid, name: Option<&str>) -> Result<()> { let name = validate_optional_object_text(name, 256, "object name")?; - if let Some(finding) = self - .vector_layers - .iter_mut() - .flat_map(|layer| layer.findings_mut()) - .find(|finding| finding.object_id() == object_id) - { - if finding.name() != name.as_deref() { - finding.set_name(name); - self.bump_revision(); - } - return Ok(()); - } - if let Some(segment) = self - .segmentation_layers - .iter_mut() - .flat_map(|layer| layer.segments_mut()) - .find(|segment| segment.object_id() == object_id) - { - if segment.name() != name.as_deref() { - segment.set_name(name); - self.bump_revision(); - } - return Ok(()); - } - if let Some(measurement) = self - .measurements - .iter_mut() - .find(|measurement| measurement.object_id() == object_id) - { - if measurement.name() != name.as_deref() { - measurement.set_name(name); - self.bump_revision(); + let changed = { + let mut object = self.object_mut(object_id).ok_or_else(|| { + ViewerError::InvalidInput("the selected workspace object does not exist".into()) + })?; + if object.name() == name.as_deref() { + false + } else { + object.set_name(name); + true } - return Ok(()); + }; + if changed { + self.bump_revision(); } - Err(ViewerError::InvalidInput( - "the selected workspace object does not exist".into(), - )) + Ok(()) } /// Sets the optional comment on one tracked object. pub fn set_object_comment(&mut self, object_id: Uuid, comment: Option<&str>) -> Result<()> { let comment = validate_optional_object_text(comment, 4_096, "object comment")?; - if let Some(finding) = self - .vector_layers - .iter_mut() - .flat_map(|layer| layer.findings_mut()) - .find(|finding| finding.object_id() == object_id) - { - if finding.comment() != comment.as_deref() { - finding.set_comment(comment); - self.bump_revision(); - } - return Ok(()); - } - if let Some(segment) = self - .segmentation_layers - .iter_mut() - .flat_map(|layer| layer.segments_mut()) - .find(|segment| segment.object_id() == object_id) - { - if segment.comment() != comment.as_deref() { - segment.set_comment(comment); - self.bump_revision(); - } - return Ok(()); - } - if let Some(measurement) = self - .measurements - .iter_mut() - .find(|measurement| measurement.object_id() == object_id) - { - if measurement.comment() != comment.as_deref() { - measurement.set_comment(comment); - self.bump_revision(); + let changed = { + let mut object = self.object_mut(object_id).ok_or_else(|| { + ViewerError::InvalidInput("the selected workspace object does not exist".into()) + })?; + if object.comment() == comment.as_deref() { + false + } else { + object.set_comment(comment); + true } - return Ok(()); + }; + if changed { + self.bump_revision(); } - Err(ViewerError::InvalidInput( - "the selected workspace object does not exist".into(), - )) + Ok(()) } pub fn suggest_scheme_migration(&self, target: &AnnotationScheme) -> BTreeMap { @@ -1027,37 +787,6 @@ impl WorkspaceDocument { Ok(()) } - pub fn set_layer_presentation( - &mut self, - layer_id: Uuid, - presentation: LayerPresentation, - ) -> Result<()> { - if !presentation.opacity.is_finite() || !(0.0..=1.0).contains(&presentation.opacity) { - return Err(ViewerError::InvalidInput( - "layer opacity must be between zero and one".into(), - )); - } - if !self.layer_exists(layer_id) { - return Err(ViewerError::InvalidInput( - "the selected layer does not exist".into(), - )); - } - self.presentation.set_layer(layer_id, presentation); - self.bump_revision(); - Ok(()) - } - - pub fn set_object_visible(&mut self, object_id: Uuid, visible: bool) -> Result<()> { - if !self.object_exists(object_id) { - return Err(ViewerError::InvalidInput( - "the selected workspace object does not exist".into(), - )); - } - self.presentation.set_object_visible(object_id, visible); - self.bump_revision(); - Ok(()) - } - #[must_use] pub fn object_count(&self) -> usize { self.vector_findings().count() + self.segments().count() + self.measurements.len() @@ -1083,134 +812,6 @@ impl WorkspaceDocument { .saturating_add(self.coordinate_count().saturating_mul(16)) } - pub fn validate(&self) -> Result<()> { - if self.schema_version != WORKSPACE_SCHEMA_VERSION { - return Err(ViewerError::Unsupported(format!( - "workspace schema version {} is not supported", - self.schema_version - ))); - } - validate_source_identity(&self.source_identity)?; - if self.object_count() > MAX_EDITABLE_OBJECTS { - return Err(ViewerError::InvalidInput(format!( - "workspace exceeds the {MAX_EDITABLE_OBJECTS} editable-object limit" - ))); - } - if self.coordinate_count() > MAX_COORDINATE_POINTS { - return Err(ViewerError::InvalidInput(format!( - "workspace exceeds the {MAX_COORDINATE_POINTS} coordinate-point limit" - ))); - } - - let mut layer_ids = HashSet::new(); - for id in self - .vector_layers - .iter() - .map(VectorLayer::id) - .chain(self.segmentation_layers.iter().map(SegmentationLayer::id)) - .chain(self.external_layers.iter().map(ExternalLayerReference::id)) - { - if !layer_ids.insert(id) { - return Err(ViewerError::InvalidInput( - "workspace contains duplicate layer IDs".into(), - )); - } - } - for layer in &self.external_layers { - if layer.name().trim().is_empty() || layer.name().len() > 256 { - return Err(ViewerError::InvalidInput( - "external layer name must be 1..=256 bytes".into(), - )); - } - for (source, target) in layer.class_mappings() { - if source.trim().is_empty() - || source.len() > 1_024 - || self.scheme.class(target).is_none() - { - return Err(ViewerError::InvalidInput( - "external layer contains an invalid class mapping".into(), - )); - } - } - } - - let mut object_ids = HashSet::new(); - let mut ordinals = HashSet::new(); - let mut tracking_ids = HashSet::new(); - let mut tracking_uids = HashSet::new(); - let mut max_ordinal = 0; - for finding in self.vector_findings() { - self.validate_class( - finding.class_id(), - match finding.geometry() { - VectorFindingGeometry::Point(_) => AnnotationClassGeometry::Point, - VectorFindingGeometry::Regions(_) => AnnotationClassGeometry::Region, - }, - )?; - validate_vector_geometry(finding.geometry(), self.source_identity.dimensions())?; - validate_identity( - finding.object_id(), - finding.ordinal(), - finding.tracking(), - &mut object_ids, - &mut ordinals, - &mut tracking_ids, - &mut tracking_uids, - )?; - max_ordinal = max_ordinal.max(finding.ordinal()); - } - for segment in self.segments() { - self.validate_class(segment.class_id(), AnnotationClassGeometry::Region)?; - if segment - .primitives() - .first() - .map(SegmentationPrimitive::operation) - != Some(SegmentOperation::Add) - { - return Err(ViewerError::InvalidInput( - "a segmentation segment must start with an Add primitive".into(), - )); - } - compose_segment(segment.primitives(), self.source_identity.dimensions())?; - validate_identity( - segment.object_id(), - segment.ordinal(), - segment.tracking(), - &mut object_ids, - &mut ordinals, - &mut tracking_ids, - &mut tracking_uids, - )?; - max_ordinal = max_ordinal.max(segment.ordinal()); - } - for measurement in &self.measurements { - self.validate_class(measurement.class_id(), AnnotationClassGeometry::Region)?; - validate_measurement( - measurement.endpoints(), - measurement.physical_length_mm(), - self.source_identity.dimensions(), - )?; - validate_identity( - measurement.object_id(), - measurement.ordinal(), - measurement.tracking(), - &mut object_ids, - &mut ordinals, - &mut tracking_ids, - &mut tracking_uids, - )?; - max_ordinal = max_ordinal.max(measurement.ordinal()); - } - if self.next_ordinal == 0 || self.next_ordinal <= max_ordinal { - return Err(ViewerError::InvalidInput( - "workspace next ordinal does not exceed every assigned ordinal".into(), - )); - } - self.validate_sites_for_scheme(&self.scheme)?; - self.presentation.validate(&layer_ids, &object_ids)?; - Ok(()) - } - fn validate_class(&self, class_id: &str, geometry: AnnotationClassGeometry) -> Result<()> { let class = self.scheme.class(class_id).ok_or_else(|| { ViewerError::InvalidInput(format!( @@ -1327,19 +928,6 @@ impl WorkspaceDocument { .any(|existing| existing.id() == candidate.id() || existing.uid() == candidate.uid()) } - fn layer_exists(&self, id: Uuid) -> bool { - self.vector_layers.iter().any(|layer| layer.id() == id) - || self - .segmentation_layers - .iter() - .any(|layer| layer.id() == id) - || self.external_layers.iter().any(|layer| layer.id() == id) - } - - fn object_exists(&self, id: Uuid) -> bool { - self.finding(id).is_some() || self.segment(id).is_some() || self.measurement(id).is_some() - } - fn bump_revision(&mut self) { self.revision = self.revision.saturating_add(1); } @@ -1442,34 +1030,3 @@ fn validate_point_in_bounds(point: Point2, dimensions: (u64, u64)) -> Result<()> } Ok(()) } - -#[allow(clippy::too_many_arguments)] -fn validate_identity( - object_id: Uuid, - ordinal: u64, - tracking: &TrackingIdentity, - object_ids: &mut HashSet, - ordinals: &mut HashSet, - tracking_ids: &mut HashSet, - tracking_uids: &mut HashSet, -) -> Result<()> { - if object_id.is_nil() || !object_ids.insert(object_id) { - return Err(ViewerError::InvalidInput( - "workspace contains a nil or duplicate object ID".into(), - )); - } - if ordinal == 0 || !ordinals.insert(ordinal) { - return Err(ViewerError::InvalidInput( - "workspace contains a zero or duplicate ordinal".into(), - )); - } - TrackingIdentity::new(tracking.id(), tracking.uid())?; - if !tracking_ids.insert(tracking.id().to_owned()) - || !tracking_uids.insert(tracking.uid().to_owned()) - { - return Err(ViewerError::InvalidInput( - "workspace contains a conflicting tracking identity".into(), - )); - } - Ok(()) -} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/document/layers.rs b/crates/dicom-viewer-core/src/annotations/workspace/document/layers.rs new file mode 100644 index 0000000..839e197 --- /dev/null +++ b/crates/dicom-viewer-core/src/annotations/workspace/document/layers.rs @@ -0,0 +1,148 @@ +use uuid::Uuid; + +use crate::{Result, ViewerError}; + +use super::super::model::{ExternalLayerReference, LayerPresentation}; +use super::WorkspaceDocument; + +impl WorkspaceDocument { + pub fn add_external_layer(&mut self, layer: ExternalLayerReference) -> Result { + if layer.name().trim().is_empty() || layer.name().len() > 256 { + return Err(ViewerError::InvalidInput( + "external layer name must be 1..=256 bytes".into(), + )); + } + if self + .external_layers + .iter() + .any(|item| item.id() == layer.id()) + { + return Err(ViewerError::InvalidInput( + "external layer ID already exists".into(), + )); + } + let id = layer.id(); + self.presentation.insert_layer(id); + self.external_layers.push(layer); + self.bump_revision(); + Ok(id) + } + + pub fn remove_external_layer(&mut self, layer_id: Uuid) -> Result { + let Some(index) = self + .external_layers + .iter() + .position(|layer| layer.id() == layer_id) + else { + return Ok(false); + }; + self.external_layers.remove(index); + self.presentation.remove_layer(layer_id); + self.bump_revision(); + Ok(true) + } + + /// Updates an unloaded external-layer reference after its payload has been validated. + pub fn hydrate_external_layer( + &mut self, + layer_id: Uuid, + source_object_count: u64, + source_digest: Option, + ) -> Result<()> { + if source_digest + .as_deref() + .is_some_and(|digest| digest.is_empty() || digest.len() > 128) + { + return Err(ViewerError::InvalidInput( + "external source digest must be 1..=128 bytes when present".into(), + )); + } + let layer = self + .external_layers + .iter_mut() + .find(|layer| layer.id() == layer_id) + .ok_or_else(|| { + ViewerError::InvalidInput("the external source layer does not exist".into()) + })?; + if let (Some(expected), Some(actual)) = (layer.source_digest(), source_digest.as_deref()) { + if expected != actual { + return Err(ViewerError::InvalidInput( + "the external source content changed; remove the saved source layer and import it explicitly" + .into(), + )); + } + } + let source_digest = source_digest.or_else(|| layer.source_digest().map(ToOwned::to_owned)); + layer.hydrate(source_object_count, source_digest); + self.bump_revision(); + Ok(()) + } + + pub fn set_external_class_mapping( + &mut self, + layer_id: Uuid, + source_class: &str, + target_class_id: &str, + ) -> Result<()> { + if source_class.trim().is_empty() || source_class.len() > 1_024 { + return Err(ViewerError::InvalidInput( + "external source class key must be 1..=1024 bytes".into(), + )); + } + if self.scheme.class(target_class_id).is_none() { + return Err(ViewerError::InvalidInput( + "external class mapping target is not in the pinned annotation scheme".into(), + )); + } + let layer = self + .external_layers + .iter_mut() + .find(|layer| layer.id() == layer_id) + .ok_or_else(|| { + ViewerError::InvalidInput("the external source layer does not exist".into()) + })?; + layer.set_class_mapping(source_class.to_owned(), target_class_id.to_owned()); + self.bump_revision(); + Ok(()) + } + + pub fn set_layer_presentation( + &mut self, + layer_id: Uuid, + presentation: LayerPresentation, + ) -> Result<()> { + if !presentation.opacity.is_finite() || !(0.0..=1.0).contains(&presentation.opacity) { + return Err(ViewerError::InvalidInput( + "layer opacity must be between zero and one".into(), + )); + } + if !self.layer_exists(layer_id) { + return Err(ViewerError::InvalidInput( + "the selected layer does not exist".into(), + )); + } + self.presentation.set_layer(layer_id, presentation); + self.bump_revision(); + Ok(()) + } + + pub fn set_object_visible(&mut self, object_id: Uuid, visible: bool) -> Result<()> { + if !self.object_exists(object_id) { + return Err(ViewerError::InvalidInput( + "the selected workspace object does not exist".into(), + )); + } + self.presentation.set_object_visible(object_id, visible); + self.bump_revision(); + Ok(()) + } + + fn layer_exists(&self, id: Uuid) -> bool { + self.vector_layers.iter().any(|layer| layer.id() == id) + || self + .segmentation_layers + .iter() + .any(|layer| layer.id() == id) + || self.external_layers.iter().any(|layer| layer.id() == id) + } +} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/document/object.rs b/crates/dicom-viewer-core/src/annotations/workspace/document/object.rs new file mode 100644 index 0000000..37925c2 --- /dev/null +++ b/crates/dicom-viewer-core/src/annotations/workspace/document/object.rs @@ -0,0 +1,326 @@ +use uuid::Uuid; + +use crate::{Result, TrackingIdentity, ViewerError}; + +use super::super::model::{ + ControlledFindingSite, SegmentationSegmentFinding, SourceFrameContext, VectorFinding, + VectorFindingGeometry, WorkspaceLinearMeasurement, WorkspaceObjectProvenance, +}; +use super::WorkspaceDocument; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceObjectGeometryKind { + Point, + Region, + Segmentation, + Measurement, +} + +#[derive(Debug, Clone, Copy)] +pub enum WorkspaceObjectRef<'a> { + Vector(&'a VectorFinding), + Segment(&'a SegmentationSegmentFinding), + Measurement(&'a WorkspaceLinearMeasurement), +} + +impl<'a> WorkspaceObjectRef<'a> { + #[must_use] + pub fn object_id(self) -> Uuid { + match self { + Self::Vector(object) => object.object_id(), + Self::Segment(object) => object.object_id(), + Self::Measurement(object) => object.object_id(), + } + } + + #[must_use] + pub fn ordinal(self) -> u64 { + match self { + Self::Vector(object) => object.ordinal(), + Self::Segment(object) => object.ordinal(), + Self::Measurement(object) => object.ordinal(), + } + } + + #[must_use] + pub fn tracking(self) -> &'a TrackingIdentity { + match self { + Self::Vector(object) => object.tracking(), + Self::Segment(object) => object.tracking(), + Self::Measurement(object) => object.tracking(), + } + } + + #[must_use] + pub fn class_id(self) -> &'a str { + match self { + Self::Vector(object) => object.class_id(), + Self::Segment(object) => object.class_id(), + Self::Measurement(object) => object.class_id(), + } + } + + #[must_use] + pub fn finding_site(self) -> Option<&'a ControlledFindingSite> { + match self { + Self::Vector(object) => object.finding_site(), + Self::Segment(object) => object.finding_site(), + Self::Measurement(object) => object.finding_site(), + } + } + + #[must_use] + pub fn name(self) -> Option<&'a str> { + match self { + Self::Vector(object) => object.name(), + Self::Segment(object) => object.name(), + Self::Measurement(object) => object.name(), + } + } + + #[must_use] + pub fn comment(self) -> Option<&'a str> { + match self { + Self::Vector(object) => object.comment(), + Self::Segment(object) => object.comment(), + Self::Measurement(object) => object.comment(), + } + } + + #[must_use] + pub fn provenance(self) -> &'a WorkspaceObjectProvenance { + match self { + Self::Vector(object) => object.provenance(), + Self::Segment(object) => object.provenance(), + Self::Measurement(object) => object.provenance(), + } + } + + #[must_use] + pub fn source_frame(self) -> &'a SourceFrameContext { + match self { + Self::Vector(object) => object.source_frame(), + Self::Segment(object) => object.source_frame(), + Self::Measurement(object) => object.source_frame(), + } + } + + #[must_use] + pub fn geometry_kind(self) -> WorkspaceObjectGeometryKind { + match self { + Self::Vector(object) => match object.geometry() { + VectorFindingGeometry::Point(_) => WorkspaceObjectGeometryKind::Point, + VectorFindingGeometry::Regions(_) => WorkspaceObjectGeometryKind::Region, + }, + Self::Segment(_) => WorkspaceObjectGeometryKind::Segmentation, + Self::Measurement(_) => WorkspaceObjectGeometryKind::Measurement, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum ObjectLocation { + Vector { layer: usize, object: usize }, + Segment { layer: usize, object: usize }, + Measurement { object: usize }, +} + +pub(super) enum WorkspaceObjectMut<'a> { + Vector(&'a mut VectorFinding), + Segment(&'a mut SegmentationSegmentFinding), + Measurement(&'a mut WorkspaceLinearMeasurement), +} + +impl WorkspaceObjectMut<'_> { + pub(super) fn set_class_id(&mut self, class_id: String) { + match self { + Self::Vector(object) => object.set_class_id(class_id), + Self::Segment(object) => object.set_class_id(class_id), + Self::Measurement(object) => object.set_class_id(class_id), + } + } + + pub(super) fn finding_site(&self) -> Option<&ControlledFindingSite> { + match self { + Self::Vector(object) => object.finding_site(), + Self::Segment(object) => object.finding_site(), + Self::Measurement(object) => object.finding_site(), + } + } + + pub(super) fn set_finding_site(&mut self, site: Option) { + match self { + Self::Vector(object) => object.set_finding_site(site), + Self::Segment(object) => object.set_finding_site(site), + Self::Measurement(object) => object.set_finding_site(site), + } + } + + pub(super) fn name(&self) -> Option<&str> { + match self { + Self::Vector(object) => object.name(), + Self::Segment(object) => object.name(), + Self::Measurement(object) => object.name(), + } + } + + pub(super) fn set_name(&mut self, name: Option) { + match self { + Self::Vector(object) => object.set_name(name), + Self::Segment(object) => object.set_name(name), + Self::Measurement(object) => object.set_name(name), + } + } + + pub(super) fn comment(&self) -> Option<&str> { + match self { + Self::Vector(object) => object.comment(), + Self::Segment(object) => object.comment(), + Self::Measurement(object) => object.comment(), + } + } + + pub(super) fn set_comment(&mut self, comment: Option) { + match self { + Self::Vector(object) => object.set_comment(comment), + Self::Segment(object) => object.set_comment(comment), + Self::Measurement(object) => object.set_comment(comment), + } + } +} + +impl WorkspaceDocument { + #[must_use] + pub fn object(&self, object_id: Uuid) -> Option> { + match self.locate_object(object_id)? { + ObjectLocation::Vector { layer, object } => Some(WorkspaceObjectRef::Vector( + &self.vector_layers[layer].findings()[object], + )), + ObjectLocation::Segment { layer, object } => Some(WorkspaceObjectRef::Segment( + &self.segmentation_layers[layer].segments()[object], + )), + ObjectLocation::Measurement { object } => { + Some(WorkspaceObjectRef::Measurement(&self.measurements[object])) + } + } + } + + pub fn objects(&self) -> impl Iterator> { + self.vector_findings() + .map(WorkspaceObjectRef::Vector) + .chain(self.segments().map(WorkspaceObjectRef::Segment)) + .chain( + self.measurements + .iter() + .map(WorkspaceObjectRef::Measurement), + ) + } + + pub fn object_layer_id(&self, object_id: Uuid) -> Result> { + match self.locate_object(object_id) { + Some(ObjectLocation::Vector { layer, .. }) => Ok(Some(self.vector_layers[layer].id())), + Some(ObjectLocation::Segment { layer, .. }) => { + Ok(Some(self.segmentation_layers[layer].id())) + } + Some(ObjectLocation::Measurement { .. }) => Ok(None), + None => Err(ViewerError::InvalidInput( + "the selected workspace object does not exist".into(), + )), + } + } + + #[must_use] + pub fn finding(&self, object_id: Uuid) -> Option<&VectorFinding> { + match self.object(object_id) { + Some(WorkspaceObjectRef::Vector(object)) => Some(object), + _ => None, + } + } + + #[must_use] + pub fn segment(&self, object_id: Uuid) -> Option<&SegmentationSegmentFinding> { + match self.object(object_id) { + Some(WorkspaceObjectRef::Segment(object)) => Some(object), + _ => None, + } + } + + #[must_use] + pub fn measurement(&self, object_id: Uuid) -> Option<&WorkspaceLinearMeasurement> { + match self.object(object_id) { + Some(WorkspaceObjectRef::Measurement(object)) => Some(object), + _ => None, + } + } + + pub fn delete_object(&mut self, object_id: Uuid) -> Result { + let Some(location) = self.locate_object(object_id) else { + return Ok(false); + }; + match location { + ObjectLocation::Vector { layer, object } => { + self.vector_layers[layer].findings_mut().remove(object); + } + ObjectLocation::Segment { layer, object } => { + self.segmentation_layers[layer] + .segments_mut() + .remove(object); + } + ObjectLocation::Measurement { object } => { + self.measurements.remove(object); + } + } + self.presentation.remove_object(object_id); + self.bump_revision(); + Ok(true) + } + + pub(super) fn object_mut(&mut self, object_id: Uuid) -> Option> { + match self.locate_object(object_id)? { + ObjectLocation::Vector { layer, object } => Some(WorkspaceObjectMut::Vector( + &mut self.vector_layers[layer].findings_mut()[object], + )), + ObjectLocation::Segment { layer, object } => Some(WorkspaceObjectMut::Segment( + &mut self.segmentation_layers[layer].segments_mut()[object], + )), + ObjectLocation::Measurement { object } => Some(WorkspaceObjectMut::Measurement( + &mut self.measurements[object], + )), + } + } + + pub(super) fn object_exists(&self, object_id: Uuid) -> bool { + self.locate_object(object_id).is_some() + } + + fn locate_object(&self, object_id: Uuid) -> Option { + for (layer_index, layer) in self.vector_layers.iter().enumerate() { + if let Some(object) = layer + .findings() + .iter() + .position(|object| object.object_id() == object_id) + { + return Some(ObjectLocation::Vector { + layer: layer_index, + object, + }); + } + } + for (layer_index, layer) in self.segmentation_layers.iter().enumerate() { + if let Some(object) = layer + .segments() + .iter() + .position(|object| object.object_id() == object_id) + { + return Some(ObjectLocation::Segment { + layer: layer_index, + object, + }); + } + } + self.measurements + .iter() + .position(|object| object.object_id() == object_id) + .map(|object| ObjectLocation::Measurement { object }) + } +} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/document/validation.rs b/crates/dicom-viewer-core/src/annotations/workspace/document/validation.rs new file mode 100644 index 0000000..53edb88 --- /dev/null +++ b/crates/dicom-viewer-core/src/annotations/workspace/document/validation.rs @@ -0,0 +1,176 @@ +use std::collections::HashSet; + +use uuid::Uuid; + +use crate::{AnnotationClassGeometry, Result, TrackingIdentity, ViewerError}; + +use super::super::composition::compose_segment; +use super::super::model::{ + ExternalLayerReference, SegmentOperation, SegmentationLayer, SegmentationPrimitive, + VectorFindingGeometry, VectorLayer, +}; +use super::{ + validate_measurement, validate_source_identity, validate_vector_geometry, WorkspaceDocument, + MAX_COORDINATE_POINTS, MAX_EDITABLE_OBJECTS, WORKSPACE_SCHEMA_VERSION, +}; + +impl WorkspaceDocument { + pub fn validate(&self) -> Result<()> { + if self.schema_version != WORKSPACE_SCHEMA_VERSION { + return Err(ViewerError::Unsupported(format!( + "workspace schema version {} is not supported", + self.schema_version + ))); + } + validate_source_identity(&self.source_identity)?; + if self.object_count() > MAX_EDITABLE_OBJECTS { + return Err(ViewerError::InvalidInput(format!( + "workspace exceeds the {MAX_EDITABLE_OBJECTS} editable-object limit" + ))); + } + if self.coordinate_count() > MAX_COORDINATE_POINTS { + return Err(ViewerError::InvalidInput(format!( + "workspace exceeds the {MAX_COORDINATE_POINTS} coordinate-point limit" + ))); + } + + let mut layer_ids = HashSet::new(); + for id in self + .vector_layers + .iter() + .map(VectorLayer::id) + .chain(self.segmentation_layers.iter().map(SegmentationLayer::id)) + .chain(self.external_layers.iter().map(ExternalLayerReference::id)) + { + if !layer_ids.insert(id) { + return Err(ViewerError::InvalidInput( + "workspace contains duplicate layer IDs".into(), + )); + } + } + for layer in &self.external_layers { + if layer.name().trim().is_empty() || layer.name().len() > 256 { + return Err(ViewerError::InvalidInput( + "external layer name must be 1..=256 bytes".into(), + )); + } + for (source, target) in layer.class_mappings() { + if source.trim().is_empty() + || source.len() > 1_024 + || self.scheme.class(target).is_none() + { + return Err(ViewerError::InvalidInput( + "external layer contains an invalid class mapping".into(), + )); + } + } + } + + let mut object_ids = HashSet::new(); + let mut ordinals = HashSet::new(); + let mut tracking_ids = HashSet::new(); + let mut tracking_uids = HashSet::new(); + let mut max_ordinal = 0; + for finding in self.vector_findings() { + self.validate_class( + finding.class_id(), + match finding.geometry() { + VectorFindingGeometry::Point(_) => AnnotationClassGeometry::Point, + VectorFindingGeometry::Regions(_) => AnnotationClassGeometry::Region, + }, + )?; + validate_vector_geometry(finding.geometry(), self.source_identity.dimensions())?; + validate_identity( + finding.object_id(), + finding.ordinal(), + finding.tracking(), + &mut object_ids, + &mut ordinals, + &mut tracking_ids, + &mut tracking_uids, + )?; + max_ordinal = max_ordinal.max(finding.ordinal()); + } + for segment in self.segments() { + self.validate_class(segment.class_id(), AnnotationClassGeometry::Region)?; + if segment + .primitives() + .first() + .map(SegmentationPrimitive::operation) + != Some(SegmentOperation::Add) + { + return Err(ViewerError::InvalidInput( + "a segmentation segment must start with an Add primitive".into(), + )); + } + compose_segment(segment.primitives(), self.source_identity.dimensions())?; + validate_identity( + segment.object_id(), + segment.ordinal(), + segment.tracking(), + &mut object_ids, + &mut ordinals, + &mut tracking_ids, + &mut tracking_uids, + )?; + max_ordinal = max_ordinal.max(segment.ordinal()); + } + for measurement in &self.measurements { + self.validate_class(measurement.class_id(), AnnotationClassGeometry::Region)?; + validate_measurement( + measurement.endpoints(), + measurement.physical_length_mm(), + self.source_identity.dimensions(), + )?; + validate_identity( + measurement.object_id(), + measurement.ordinal(), + measurement.tracking(), + &mut object_ids, + &mut ordinals, + &mut tracking_ids, + &mut tracking_uids, + )?; + max_ordinal = max_ordinal.max(measurement.ordinal()); + } + if self.next_ordinal == 0 || self.next_ordinal <= max_ordinal { + return Err(ViewerError::InvalidInput( + "workspace next ordinal does not exceed every assigned ordinal".into(), + )); + } + self.validate_sites_for_scheme(&self.scheme)?; + self.presentation.validate(&layer_ids, &object_ids)?; + Ok(()) + } +} + +#[allow(clippy::too_many_arguments)] +fn validate_identity( + object_id: Uuid, + ordinal: u64, + tracking: &TrackingIdentity, + object_ids: &mut HashSet, + ordinals: &mut HashSet, + tracking_ids: &mut HashSet, + tracking_uids: &mut HashSet, +) -> Result<()> { + if object_id.is_nil() || !object_ids.insert(object_id) { + return Err(ViewerError::InvalidInput( + "workspace contains a nil or duplicate object ID".into(), + )); + } + if ordinal == 0 || !ordinals.insert(ordinal) { + return Err(ViewerError::InvalidInput( + "workspace contains a zero or duplicate ordinal".into(), + )); + } + TrackingIdentity::new(tracking.id(), tracking.uid())?; + if !tracking_ids.insert(tracking.id().to_owned()) + || !tracking_uids.insert(tracking.uid().to_owned()) + { + return Err(ViewerError::InvalidInput( + "workspace contains a conflicting tracking identity".into(), + )); + } + Ok(()) +} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/export.rs b/crates/dicom-viewer-core/src/annotations/workspace/export.rs index 6e1c399..2297a69 100644 --- a/crates/dicom-viewer-core/src/annotations/workspace/export.rs +++ b/crates/dicom-viewer-core/src/annotations/workspace/export.rs @@ -1,10 +1,9 @@ -use crate::{ - frames_viewer_producer, AnnotationDocument, AnnotationGroup, DicomAnnotationContext, DicomCode, - Result, SegmentationDocument, SegmentationSegment, ViewerError, -}; +mod ann; +mod bulk_ann; +mod seg; +pub(super) mod shared; -use super::document::WorkspaceDocument; -use super::model::{ControlledFindingSite, VectorFinding, VectorFindingGeometry}; +pub use bulk_ann::{BulkAnnExport, BulkAnnotationLocation}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum VectorSegmentationPolicy { @@ -12,176 +11,3 @@ pub enum VectorSegmentationPolicy { Exclude, Rasterize, } - -impl WorkspaceDocument { - pub fn export_ann(&self, context: &DicomAnnotationContext) -> Result { - let mut groups = Vec::new(); - for finding in self.vector_findings() { - let class = self.scheme().class(finding.class_id()).ok_or_else(|| { - ViewerError::InvalidInput("finding references an unknown annotation class".into()) - })?; - let label = dicom_label(finding.name().unwrap_or_else(|| class.label()))?; - let group = match finding.geometry() { - VectorFindingGeometry::Point(point) => AnnotationGroup::points( - label, - class.category().clone(), - class.property_type().clone(), - class.recommended_display_cielab(), - vec![*point], - )?, - VectorFindingGeometry::Regions(components) => AnnotationGroup::polygons( - label, - class.category().clone(), - class.property_type().clone(), - class.recommended_display_cielab(), - components - .iter() - .map(|component| component.to_vec()) - .collect(), - )?, - } - .with_uid(finding.tracking().uid())? - .with_property_type_modifiers(class.property_type_modifiers().to_vec()); - groups.push(apply_vector_context(self, finding, group)?); - } - if groups.is_empty() { - return Err(ViewerError::InvalidInput( - "ANN export has no directly representable vector findings".into(), - )); - } - Ok(AnnotationDocument::new(context.clone(), groups)? - .with_producer(frames_viewer_producer(9101, "WSI annotations")?)) - } - - pub fn export_seg( - &self, - context: &DicomAnnotationContext, - vector_policy: VectorSegmentationPolicy, - ) -> Result { - let mut segments = Vec::new(); - for segment in self.segments() { - let class = self.scheme().class(segment.class_id()).ok_or_else(|| { - ViewerError::InvalidInput("segment references an unknown annotation class".into()) - })?; - let geometry = self.composite_segment(segment.object_id())?; - if geometry.components().is_empty() { - return Err(ViewerError::InvalidInput(format!( - "segment #{} is empty and cannot be exported", - segment.ordinal() - ))); - } - let outer = geometry - .components() - .iter() - .map(|component| component.exterior().to_vec()) - .collect::>(); - let holes = geometry - .components() - .iter() - .map(|component| component.holes().to_vec()) - .collect::>(); - let mut exported = SegmentationSegment::new( - dicom_label(segment.name().unwrap_or_else(|| class.label()))?, - class.category().clone(), - class.property_type().clone(), - class.recommended_display_cielab(), - outer, - Vec::new(), - )? - .with_component_holes(holes)? - .with_property_type_modifiers(class.property_type_modifiers().to_vec()) - .with_tracking(segment.tracking().id(), segment.tracking().uid())?; - if let Some(comment) = segment.comment() { - exported = exported.with_description(comment)?; - } - if let Some(site) = segment.finding_site() { - exported = exported.with_anatomic_regions(vec![finding_site_code(self, site)?]); - } - segments.push(exported); - } - - if vector_policy == VectorSegmentationPolicy::Rasterize { - for finding in self.vector_findings() { - let VectorFindingGeometry::Regions(components) = finding.geometry() else { - continue; - }; - let class = self.scheme().class(finding.class_id()).ok_or_else(|| { - ViewerError::InvalidInput( - "finding references an unknown annotation class".into(), - ) - })?; - let mut exported = SegmentationSegment::new( - dicom_label(finding.name().unwrap_or_else(|| class.label()))?, - class.category().clone(), - class.property_type().clone(), - class.recommended_display_cielab(), - components - .iter() - .map(|component| component.to_vec()) - .collect(), - Vec::new(), - )? - .with_property_type_modifiers(class.property_type_modifiers().to_vec()) - .with_tracking(finding.tracking().id(), finding.tracking().uid())?; - if let Some(comment) = finding.comment() { - exported = exported.with_description(comment)?; - } - if let Some(site) = finding.finding_site() { - exported = exported.with_anatomic_regions(vec![finding_site_code(self, site)?]); - } - segments.push(exported); - } - } - - if segments.is_empty() { - return Err(ViewerError::InvalidInput( - "SEG export has no editable segmentation content".into(), - )); - } - Ok(SegmentationDocument::binary(context.clone(), segments)? - .with_producer(frames_viewer_producer(9201, "WSI segmentations")?)) - } -} - -pub(super) fn apply_vector_context( - document: &WorkspaceDocument, - finding: &VectorFinding, - mut group: AnnotationGroup, -) -> Result { - if let Some(comment) = finding.comment() { - group = group.with_description(comment)?; - } - if let Some(site) = finding.finding_site() { - group = group.with_anatomic_regions(vec![finding_site_code(document, site)?]); - } - if let Some(optical_path) = finding.source_frame().optical_path() { - group = group.with_referenced_optical_paths(vec![optical_path.to_owned()])?; - } - Ok(group) -} - -pub(super) fn finding_site_code( - document: &WorkspaceDocument, - site: &ControlledFindingSite, -) -> Result { - document - .scheme() - .finding_sites() - .iter() - .find(|code| site.matches(code)) - .cloned() - .ok_or_else(|| { - ViewerError::InvalidInput( - "finding site is not controlled by the pinned annotation scheme".into(), - ) - }) -} - -pub(super) fn dicom_label(label: &str) -> Result { - if label.trim().is_empty() || label.len() > 64 || label.contains(['\\', '\0']) { - return Err(ViewerError::InvalidInput( - "export label must be 1..=64 bytes and contain no DICOM separator or NUL".into(), - )); - } - Ok(label.to_owned()) -} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/export/ann.rs b/crates/dicom-viewer-core/src/annotations/workspace/export/ann.rs new file mode 100644 index 0000000..aee299c --- /dev/null +++ b/crates/dicom-viewer-core/src/annotations/workspace/export/ann.rs @@ -0,0 +1,54 @@ +use crate::{ + frames_viewer_producer, AnnotationDocument, AnnotationGroup, DicomAnnotationContext, Result, + ViewerError, +}; + +use super::super::document::WorkspaceDocument; +use super::super::model::VectorFindingGeometry; +use super::shared::{apply_vector_context, dicom_label, validate_ann_source_context}; + +impl WorkspaceDocument { + pub fn export_ann(&self, context: &DicomAnnotationContext) -> Result { + let mut groups = Vec::new(); + for finding in self.vector_findings() { + validate_ann_source_context( + &format!("vector finding #{}", finding.ordinal()), + finding.source_frame(), + context, + )?; + let class = self.scheme().class(finding.class_id()).ok_or_else(|| { + ViewerError::InvalidInput("finding references an unknown annotation class".into()) + })?; + let label = dicom_label(finding.name().unwrap_or_else(|| class.label()))?; + let group = match finding.geometry() { + VectorFindingGeometry::Point(point) => AnnotationGroup::points( + label, + class.category().clone(), + class.property_type().clone(), + class.recommended_display_cielab(), + vec![*point], + )?, + VectorFindingGeometry::Regions(components) => AnnotationGroup::polygons( + label, + class.category().clone(), + class.property_type().clone(), + class.recommended_display_cielab(), + components + .iter() + .map(|component| component.to_vec()) + .collect(), + )?, + } + .with_uid(finding.tracking().uid())? + .with_property_type_modifiers(class.property_type_modifiers().to_vec()); + groups.push(apply_vector_context(self, finding, group)?); + } + if groups.is_empty() { + return Err(ViewerError::InvalidInput( + "ANN export has no directly representable vector findings".into(), + )); + } + Ok(AnnotationDocument::new(context.clone(), groups)? + .with_producer(frames_viewer_producer(9101, "WSI annotations")?)) + } +} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/export/bulk_ann.rs b/crates/dicom-viewer-core/src/annotations/workspace/export/bulk_ann.rs new file mode 100644 index 0000000..dc2c9b6 --- /dev/null +++ b/crates/dicom-viewer-core/src/annotations/workspace/export/bulk_ann.rs @@ -0,0 +1,298 @@ +use std::num::NonZeroU32; + +use uuid::Uuid; + +use crate::{ + frames_viewer_producer, AlgorithmIdentification, AnnotationDocument, AnnotationGroup, + DicomAnnotationContext, GenerationType, Point2, Result, ViewerError, +}; + +use super::super::document::WorkspaceDocument; +use super::super::model::{ControlledFindingSite, VectorFinding, VectorFindingGeometry}; +use super::shared::{dicom_label, finding_site_code, validate_ann_source_context}; + +/// One automatic source object's correspondence to primitives in a bulk ANN group. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BulkAnnotationLocation { + object_id: Uuid, + tracking_uid: String, + group_uid: String, + first_annotation_index: NonZeroU32, + annotation_count: NonZeroU32, +} + +impl BulkAnnotationLocation { + #[must_use] + pub const fn object_id(&self) -> Uuid { + self.object_id + } + + #[must_use] + pub fn tracking_uid(&self) -> &str { + &self.tracking_uid + } + + #[must_use] + pub fn group_uid(&self) -> &str { + &self.group_uid + } + + /// Returns the one-based first primitive index in the identified ANN group. + #[must_use] + pub const fn first_annotation_index(&self) -> NonZeroU32 { + self.first_annotation_index + } + + #[must_use] + pub const fn annotation_count(&self) -> NonZeroU32 { + self.annotation_count + } + + #[must_use] + pub fn into_parts(self) -> (Uuid, String, String, NonZeroU32, NonZeroU32) { + ( + self.object_id, + self.tracking_uid, + self.group_uid, + self.first_annotation_index, + self.annotation_count, + ) + } +} + +/// A bulk ANN document and in-memory source-object correspondence evidence. +/// +/// The location evidence is not encoded into the DICOM ANN instance. Callers that persist the +/// document must retain this value separately for the lifetime of the export operation. +#[derive(Debug, Clone, PartialEq)] +pub struct BulkAnnExport { + document: AnnotationDocument, + annotation_locations: Vec, +} + +impl BulkAnnExport { + #[must_use] + pub fn document(&self) -> &AnnotationDocument { + &self.document + } + + #[must_use] + pub fn annotation_locations(&self) -> &[BulkAnnotationLocation] { + &self.annotation_locations + } + + #[must_use] + pub fn into_parts(self) -> (AnnotationDocument, Vec) { + (self.document, self.annotation_locations) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BulkGraphicType { + Point, + Polygon, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BulkGroupKey { + class_id: String, + graphic_type: BulkGraphicType, + finding_site: Option, + optical_path: Option, +} + +impl BulkGroupKey { + fn from_finding(finding: &VectorFinding) -> Self { + Self { + class_id: finding.class_id().to_owned(), + graphic_type: match finding.geometry() { + VectorFindingGeometry::Point(_) => BulkGraphicType::Point, + VectorFindingGeometry::Regions(_) => BulkGraphicType::Polygon, + }, + finding_site: finding.finding_site().cloned(), + optical_path: finding.source_frame().optical_path().map(str::to_owned), + } + } +} + +struct BulkGroupBucket { + key: BulkGroupKey, + points: Vec, + polygons: Vec>, +} + +impl BulkGroupBucket { + fn new(key: BulkGroupKey) -> Self { + Self { + key, + points: Vec::new(), + polygons: Vec::new(), + } + } + + fn annotation_count(&self) -> usize { + match self.key.graphic_type { + BulkGraphicType::Point => self.points.len(), + BulkGraphicType::Polygon => self.polygons.len(), + } + } +} + +struct PendingBulkAnnotationLocation { + object_id: Uuid, + tracking_uid: String, + bucket_index: usize, + first_annotation_index: NonZeroU32, + annotation_count: NonZeroU32, +} + +impl WorkspaceDocument { + /// Exports automatic vector findings in shared ANN groups. + /// + /// Unlike [`Self::export_ann`], this path groups compatible findings and returns a one-based + /// in-memory source-object-to-primitive mapping. Per-object names and comments are rejected + /// because ANN can represent those values only at group scope. + pub fn export_automatic_bulk_ann( + &self, + context: &DicomAnnotationContext, + algorithm: AlgorithmIdentification, + ) -> Result { + let mut findings = self.vector_findings().collect::>(); + findings.sort_by_key(|finding| finding.ordinal()); + + let mut buckets = Vec::::new(); + let mut pending_locations = Vec::with_capacity(findings.len()); + for finding in findings { + validate_ann_source_context( + &format!("vector finding #{}", finding.ordinal()), + finding.source_frame(), + context, + )?; + if finding.name().is_some() || finding.comment().is_some() { + return Err(ViewerError::InvalidInput( + "bulk ANN cannot preserve per-annotation name or comment".into(), + )); + } + if self.scheme().class(finding.class_id()).is_none() { + return Err(ViewerError::InvalidInput( + "finding references an unknown annotation class".into(), + )); + } + + let key = BulkGroupKey::from_finding(finding); + let bucket_index = buckets + .iter() + .position(|bucket| bucket.key == key) + .unwrap_or_else(|| { + buckets.push(BulkGroupBucket::new(key)); + buckets.len() - 1 + }); + let bucket = &mut buckets[bucket_index]; + let first_annotation_index = bucket + .annotation_count() + .checked_add(1) + .and_then(|index| u32::try_from(index).ok()) + .and_then(NonZeroU32::new) + .ok_or_else(|| { + ViewerError::InvalidInput( + "bulk ANN annotation index exceeds DICOM UL range".into(), + ) + })?; + let annotation_count = match finding.geometry() { + VectorFindingGeometry::Point(point) => { + bucket.points.push(*point); + NonZeroU32::MIN + } + VectorFindingGeometry::Regions(components) => { + let count = u32::try_from(components.len()) + .ok() + .and_then(NonZeroU32::new) + .ok_or_else(|| { + ViewerError::InvalidInput( + "bulk ANN annotation count must fit a nonzero DICOM UL".into(), + ) + })?; + bucket + .polygons + .extend(components.iter().map(|component| component.to_vec())); + count + } + }; + pending_locations.push(PendingBulkAnnotationLocation { + object_id: finding.object_id(), + tracking_uid: finding.tracking().uid().to_owned(), + bucket_index, + first_annotation_index, + annotation_count, + }); + } + + if buckets.is_empty() { + return Err(ViewerError::InvalidInput( + "bulk ANN export has no directly representable vector findings".into(), + )); + } + + let mut groups = Vec::with_capacity(buckets.len()); + let mut group_uids = Vec::with_capacity(buckets.len()); + for bucket in buckets { + let class = self.scheme().class(&bucket.key.class_id).ok_or_else(|| { + ViewerError::InvalidInput("finding references an unknown annotation class".into()) + })?; + let site_code = bucket + .key + .finding_site + .as_ref() + .map(|site| finding_site_code(self, site)) + .transpose()?; + let mut group = match bucket.key.graphic_type { + BulkGraphicType::Point => AnnotationGroup::points( + dicom_label(class.label())?, + class.category().clone(), + class.property_type().clone(), + class.recommended_display_cielab(), + bucket.points, + )?, + BulkGraphicType::Polygon => AnnotationGroup::polygons( + dicom_label(class.label())?, + class.category().clone(), + class.property_type().clone(), + class.recommended_display_cielab(), + bucket.polygons, + )?, + } + .with_property_type_modifiers(class.property_type_modifiers().to_vec()) + .with_generation(GenerationType::Automatic, vec![algorithm.clone()])?; + if let Some(site) = site_code { + group = group.with_anatomic_regions(vec![site]); + } + if let Some(optical_path) = bucket.key.optical_path { + group = group.with_referenced_optical_paths(vec![optical_path])?; + } + group = group.with_deterministic_uid( + "frames-dicom-viewer:automatic-bulk-ann:v1", + context.sop_instance_uid(), + &bucket.key.class_id, + )?; + group_uids.push(group.uid().to_owned()); + groups.push(group); + } + + let annotation_locations = pending_locations + .into_iter() + .map(|location| BulkAnnotationLocation { + object_id: location.object_id, + tracking_uid: location.tracking_uid, + group_uid: group_uids[location.bucket_index].clone(), + first_annotation_index: location.first_annotation_index, + annotation_count: location.annotation_count, + }) + .collect(); + let document = AnnotationDocument::new(context.clone(), groups)? + .with_producer(frames_viewer_producer(9101, "WSI annotations")?); + Ok(BulkAnnExport { + document, + annotation_locations, + }) + } +} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/export/seg.rs b/crates/dicom-viewer-core/src/annotations/workspace/export/seg.rs new file mode 100644 index 0000000..7d0791e --- /dev/null +++ b/crates/dicom-viewer-core/src/annotations/workspace/export/seg.rs @@ -0,0 +1,108 @@ +use crate::{ + frames_viewer_producer, DicomAnnotationContext, Result, SegmentationDocument, + SegmentationSegment, ViewerError, +}; + +use super::super::document::WorkspaceDocument; +use super::super::model::VectorFindingGeometry; +use super::shared::{dicom_label, finding_site_code, validate_seg_source_context}; +use super::VectorSegmentationPolicy; + +impl WorkspaceDocument { + pub fn export_seg( + &self, + context: &DicomAnnotationContext, + vector_policy: VectorSegmentationPolicy, + ) -> Result { + let mut segments = Vec::new(); + for segment in self.segments() { + validate_seg_source_context( + &format!("segment #{}", segment.ordinal()), + segment.source_frame(), + )?; + let class = self.scheme().class(segment.class_id()).ok_or_else(|| { + ViewerError::InvalidInput("segment references an unknown annotation class".into()) + })?; + let geometry = self.composite_segment(segment.object_id())?; + if geometry.components().is_empty() { + return Err(ViewerError::InvalidInput(format!( + "segment #{} is empty and cannot be exported", + segment.ordinal() + ))); + } + let outer = geometry + .components() + .iter() + .map(|component| component.exterior().to_vec()) + .collect::>(); + let holes = geometry + .components() + .iter() + .map(|component| component.holes().to_vec()) + .collect::>(); + let mut exported = SegmentationSegment::new( + dicom_label(segment.name().unwrap_or_else(|| class.label()))?, + class.category().clone(), + class.property_type().clone(), + class.recommended_display_cielab(), + outer, + Vec::new(), + )? + .with_component_holes(holes)? + .with_property_type_modifiers(class.property_type_modifiers().to_vec()) + .with_tracking(segment.tracking().id(), segment.tracking().uid())?; + if let Some(comment) = segment.comment() { + exported = exported.with_description(comment)?; + } + if let Some(site) = segment.finding_site() { + exported = exported.with_anatomic_regions(vec![finding_site_code(self, site)?]); + } + segments.push(exported); + } + + if vector_policy == VectorSegmentationPolicy::Rasterize { + for finding in self.vector_findings() { + validate_seg_source_context( + &format!("vector finding #{}", finding.ordinal()), + finding.source_frame(), + )?; + let VectorFindingGeometry::Regions(components) = finding.geometry() else { + continue; + }; + let class = self.scheme().class(finding.class_id()).ok_or_else(|| { + ViewerError::InvalidInput( + "finding references an unknown annotation class".into(), + ) + })?; + let mut exported = SegmentationSegment::new( + dicom_label(finding.name().unwrap_or_else(|| class.label()))?, + class.category().clone(), + class.property_type().clone(), + class.recommended_display_cielab(), + components + .iter() + .map(|component| component.to_vec()) + .collect(), + Vec::new(), + )? + .with_property_type_modifiers(class.property_type_modifiers().to_vec()) + .with_tracking(finding.tracking().id(), finding.tracking().uid())?; + if let Some(comment) = finding.comment() { + exported = exported.with_description(comment)?; + } + if let Some(site) = finding.finding_site() { + exported = exported.with_anatomic_regions(vec![finding_site_code(self, site)?]); + } + segments.push(exported); + } + } + + if segments.is_empty() { + return Err(ViewerError::InvalidInput( + "SEG export has no editable segmentation content".into(), + )); + } + Ok(SegmentationDocument::binary(context.clone(), segments)? + .with_producer(frames_viewer_producer(9201, "WSI segmentations")?)) + } +} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/export/shared.rs b/crates/dicom-viewer-core/src/annotations/workspace/export/shared.rs new file mode 100644 index 0000000..9356aa0 --- /dev/null +++ b/crates/dicom-viewer-core/src/annotations/workspace/export/shared.rs @@ -0,0 +1,101 @@ +use crate::{AnnotationGroup, DicomAnnotationContext, DicomCode, Result, ViewerError}; + +use super::super::document::WorkspaceDocument; +use super::super::model::{ControlledFindingSite, SourceFrameContext, VectorFinding}; + +pub(in crate::annotations::workspace) fn validate_ann_source_context( + object: &str, + source_frame: &SourceFrameContext, + context: &DicomAnnotationContext, +) -> Result<()> { + let (z, c, t) = source_frame.plane(); + let axes = [("z", z), ("c", c), ("t", t)] + .into_iter() + .filter_map(|(name, value)| value.map(|value| format!("{name}={value}"))) + .collect::>(); + if !axes.is_empty() { + return Err(ViewerError::Unsupported(format!( + "2D ANN cannot preserve source axes for {object}: {}", + axes.join(", ") + ))); + } + if let Some(identifier) = source_frame.optical_path() { + if !context + .optical_path_identifiers() + .iter() + .any(|known| known == identifier) + { + return Err(ViewerError::InvalidInput(format!( + "{object} references optical path {identifier:?}, which is not declared by the source WSI" + ))); + } + } + Ok(()) +} + +pub(in crate::annotations::workspace) fn apply_vector_context( + document: &WorkspaceDocument, + finding: &VectorFinding, + mut group: AnnotationGroup, +) -> Result { + if let Some(comment) = finding.comment() { + group = group.with_description(comment)?; + } + if let Some(site) = finding.finding_site() { + group = group.with_anatomic_regions(vec![finding_site_code(document, site)?]); + } + if let Some(optical_path) = finding.source_frame().optical_path() { + group = group.with_referenced_optical_paths(vec![optical_path.to_owned()])?; + } + Ok(group) +} + +pub(in crate::annotations::workspace) fn finding_site_code( + document: &WorkspaceDocument, + site: &ControlledFindingSite, +) -> Result { + document + .scheme() + .finding_sites() + .iter() + .find(|code| site.matches(code)) + .cloned() + .ok_or_else(|| { + ViewerError::InvalidInput( + "finding site is not controlled by the pinned annotation scheme".into(), + ) + }) +} + +pub(in crate::annotations::workspace) fn dicom_label(label: &str) -> Result { + if label.trim().is_empty() || label.len() > 64 || label.contains(['\\', '\0']) { + return Err(ViewerError::InvalidInput( + "export label must be 1..=64 bytes and contain no DICOM separator or NUL".into(), + )); + } + Ok(label.to_owned()) +} + +pub(in crate::annotations::workspace) fn validate_seg_source_context( + object: &str, + source_frame: &SourceFrameContext, +) -> Result<()> { + let (z, c, t) = source_frame.plane(); + let mut values = Vec::new(); + if let Some(optical_path) = source_frame.optical_path() { + values.push(format!("optical_path={optical_path:?}")); + } + for (name, value) in [("z", z), ("c", c), ("t", t)] { + if let Some(value) = value { + values.push(format!("{name}={value}")); + } + } + if values.is_empty() { + Ok(()) + } else { + Err(ViewerError::Unsupported(format!( + "SEG cannot preserve source context for {object}: {}", + values.join(", ") + ))) + } +} diff --git a/crates/dicom-viewer-core/src/annotations/workspace/model.rs b/crates/dicom-viewer-core/src/annotations/workspace/model.rs index 38ac259..9d5219f 100644 --- a/crates/dicom-viewer-core/src/annotations/workspace/model.rs +++ b/crates/dicom-viewer-core/src/annotations/workspace/model.rs @@ -80,6 +80,38 @@ impl SourceFrameContext { pub const fn plane(&self) -> (Option, Option, Option) { (self.z, self.c, self.t) } + + /// Converts ANN group applicability into a promotable workspace source context. + /// + /// Groups applying to multiple optical paths remain readable but cannot be promoted into one + /// editable object because the workspace object model carries at most one optical path. + pub fn from_ann_group(group: &wsi_dicom_annotations::AnnotationGroup) -> Result { + if !group.applies_to_all_z_planes() || !group.common_z_coordinates().is_empty() { + return Err(ViewerError::Unsupported( + "ANN group has Z-plane applicability that one editable workspace object cannot preserve" + .into(), + )); + } + let optical_path = match ( + group.applies_to_all_optical_paths(), + group.referenced_optical_paths(), + ) { + (true, []) => None, + (false, [identifier]) => Some(identifier.clone()), + (false, identifiers) if identifiers.len() > 1 => { + return Err(ViewerError::Unsupported(format!( + "ANN group references {} optical paths and remains read-only because promotion would lose applicability", + identifiers.len() + ))) + } + _ => { + return Err(ViewerError::InvalidInput( + "ANN group has inconsistent optical-path applicability".into(), + )) + } + }; + Ok(Self::new(optical_path, None, None, None)) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/dicom-viewer-core/src/inspection/dicom.rs b/crates/dicom-viewer-core/src/inspection/dicom.rs index 9a4423d..0e54eae 100644 --- a/crates/dicom-viewer-core/src/inspection/dicom.rs +++ b/crates/dicom-viewer-core/src/inspection/dicom.rs @@ -1,24 +1,18 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::io::{Read, Seek, SeekFrom}; +use std::io::Read; use std::path::{Path, PathBuf}; -use dicom_core::{Tag, VR}; +use dicom_core::Tag; use dicom_dictionary_std::{tags, uids}; -use dicom_encoding::transfer_syntax::{TransferSyntax, TransferSyntaxIndex}; -use dicom_object::{file::ReadPreamble, DefaultDicomObject, OpenFileOptions}; -use dicom_parser::dataset::{lazy_read::LazyDataSetReader, LazyDataToken}; -use dicom_transfer_syntax_registry::TransferSyntaxRegistry; +use dicom_object::DefaultDicomObject; use crate::{DicomInstanceSummary, LevelInfo, LevelTileLayout, Result, SourceKind, ViewerError}; const MAX_FOLDER_INSPECTION_FILES: usize = 100_000; -const MAX_FILE_META_BYTES: u32 = 1024 * 1024; -const MAX_FILE_META_ELEMENTS: usize = 128; -pub(crate) const MAX_METADATA_ELEMENT_BYTES: u32 = 16 * 1024 * 1024; -pub(crate) const MAX_METADATA_VALUE_BYTES: u64 = 128 * 1024 * 1024; -const MAX_METADATA_TOKENS: usize = 2_000_000; -pub(crate) const MAX_METADATA_SEQUENCE_DEPTH: usize = 64; -const MAX_TRANSFER_SYNTAX_UID_BYTES: u32 = 128; +#[cfg(test)] +pub(crate) use wsi_dicom_annotations::metadata::{ + MAX_METADATA_ELEMENT_BYTES, MAX_METADATA_SEQUENCE_DEPTH, MAX_METADATA_VALUE_BYTES, +}; #[derive(Debug)] pub(crate) struct InputInspection { @@ -153,313 +147,17 @@ pub(crate) fn candidate_paths_with_limit( } pub(crate) fn open_metadata_object(path: &Path) -> Result { - let mut file = std::fs::File::open(path).map_err(|source| ViewerError::Io { - path: path.to_path_buf(), - source, - })?; - let transfer_syntax_uid = preflight_file_meta(&mut file, path)?; - let transfer_syntax = TransferSyntaxRegistry - .get(&transfer_syntax_uid) - .ok_or_else(|| { - invalid_metadata( - path, - format!("unsupported transfer syntax {transfer_syntax_uid}"), - ) - })?; - preflight_data_set(&mut file, path, transfer_syntax)?; - file.seek(SeekFrom::Start(0)) - .map_err(|source| ViewerError::Io { - path: path.to_path_buf(), - source, - })?; - OpenFileOptions::new() - .read_until(tags::FLOAT_PIXEL_DATA) - .read_preamble(ReadPreamble::Always) - .from_reader(file) - .map_err(|source| ViewerError::DicomRead { - path: path.to_path_buf(), - source: Box::new(source), - }) -} - -fn preflight_file_meta(file: &mut std::fs::File, path: &Path) -> Result { - file.seek(SeekFrom::Start(128)) - .map_err(|source| ViewerError::Io { - path: path.to_path_buf(), - source, - })?; - let mut magic = [0_u8; 4]; - read_metadata_exact(file, path, &mut magic, "DICOM magic code")?; - if &magic != b"DICM" { - return Err(invalid_metadata(path, "missing DICOM file preamble")); - } - - let group_length_header = read_explicit_header(file, path)?; - if group_length_header.tag != tags::FILE_META_INFORMATION_GROUP_LENGTH - || group_length_header.vr != VR::UL - || group_length_header.value_len != 4 - { - return Err(invalid_metadata( - path, - "invalid File Meta Information Group Length element", - )); - } - let mut length_bytes = [0_u8; 4]; - read_metadata_exact(file, path, &mut length_bytes, "file meta group length")?; - let group_length = u32::from_le_bytes(length_bytes); - if group_length > MAX_FILE_META_BYTES { - return Err(invalid_metadata( - path, - format!( - "file meta group is {group_length} bytes, exceeding the {MAX_FILE_META_BYTES}-byte limit" - ), - )); - } - - let mut remaining = u64::from(group_length); - let mut element_count = 0_usize; - let mut transfer_syntax_uid = None; - while remaining > 0 { - element_count = element_count.saturating_add(1); - if element_count > MAX_FILE_META_ELEMENTS { - return Err(invalid_metadata( - path, - format!("file meta group exceeds the {MAX_FILE_META_ELEMENTS}-element limit"), - )); - } - let header = read_explicit_header(file, path)?; - let encoded_len = header - .header_len - .checked_add(u64::from(header.value_len)) - .ok_or_else(|| invalid_metadata(path, "file meta element length overflows"))?; - if header.tag.group() != 0x0002 || encoded_len > remaining { - return Err(invalid_metadata( - path, - format!( - "file meta element {} exceeds the declared group boundary", - header.tag - ), - )); + wsi_dicom_annotations::metadata::open_metadata_object(path).map_err(|error| match error { + // Preserve the viewer's established error variants at this shared boundary. + wsi_dicom_annotations::Error::Io { path, source } => ViewerError::Io { path, source }, + wsi_dicom_annotations::Error::DicomRead { path, source } => { + ViewerError::DicomRead { path, source } } - if header.tag == tags::TRANSFER_SYNTAX_UID { - if header.value_len == 0 || header.value_len > MAX_TRANSFER_SYNTAX_UID_BYTES { - return Err(invalid_metadata( - path, - "transfer syntax UID has an invalid declared length", - )); - } - let mut value = vec![0_u8; header.value_len as usize]; - read_metadata_exact(file, path, &mut value, "transfer syntax UID")?; - let value = String::from_utf8(value).map_err(|_| { - invalid_metadata(path, "transfer syntax UID is not valid ASCII/UTF-8") - })?; - transfer_syntax_uid = Some( - value - .trim_end_matches(|character: char| { - character.is_whitespace() || character == '\0' - }) - .to_string(), - ); - } else { - file.seek(SeekFrom::Current(i64::from(header.value_len))) - .map_err(|source| ViewerError::Io { - path: path.to_path_buf(), - source, - })?; - } - remaining -= encoded_len; - } - - let position = file.stream_position().map_err(|source| ViewerError::Io { - path: path.to_path_buf(), - source, - })?; - let file_len = file - .metadata() - .map_err(|source| ViewerError::Io { - path: path.to_path_buf(), - source, - })? - .len(); - if position > file_len { - return Err(invalid_metadata( - path, - "file meta group extends beyond the end of the file", - )); - } - transfer_syntax_uid - .filter(|uid| !uid.is_empty()) - .ok_or_else(|| invalid_metadata(path, "file meta group has no transfer syntax UID")) -} - -struct ExplicitHeader { - tag: Tag, - vr: VR, - value_len: u32, - header_len: u64, -} - -fn read_explicit_header(file: &mut std::fs::File, path: &Path) -> Result { - let mut base = [0_u8; 8]; - read_metadata_exact(file, path, &mut base, "file meta element header")?; - let tag = Tag( - u16::from_le_bytes([base[0], base[1]]), - u16::from_le_bytes([base[2], base[3]]), - ); - let vr = VR::from_binary([base[4], base[5]]) - .ok_or_else(|| invalid_metadata(path, format!("invalid VR in file meta element {tag}")))?; - let uses_u32_length = matches!( - vr, - VR::OB - | VR::OD - | VR::OF - | VR::OL - | VR::OV - | VR::OW - | VR::SQ - | VR::UC - | VR::UN - | VR::UR - | VR::UT - ); - let (value_len, header_len) = if uses_u32_length { - if base[6..8] != [0, 0] { - return Err(invalid_metadata( - path, - format!("invalid reserved bytes in file meta element {tag}"), - )); - } - let mut length = [0_u8; 4]; - read_metadata_exact(file, path, &mut length, "file meta element length")?; - (u32::from_le_bytes(length), 12) - } else { - (u32::from(u16::from_le_bytes([base[6], base[7]])), 8) - }; - Ok(ExplicitHeader { - tag, - vr, - value_len, - header_len, + wsi_dicom_annotations::Error::InvalidInput(reason) => ViewerError::InvalidInput(reason), + other => ViewerError::Annotation(other), }) } -fn preflight_data_set( - file: &mut std::fs::File, - path: &Path, - transfer_syntax: &TransferSyntax, -) -> Result<()> { - let mut reader = LazyDataSetReader::new_with_ts(file, transfer_syntax).map_err(|error| { - invalid_metadata( - path, - format!("could not initialize metadata parser: {error}"), - ) - })?; - let mut token_count = 0_usize; - let mut sequence_depth = 0_usize; - let mut declared_value_bytes = 0_u64; - - while let Some(token) = reader.advance() { - token_count = token_count.saturating_add(1); - if token_count > MAX_METADATA_TOKENS { - return Err(invalid_metadata( - path, - format!("metadata exceeds the {MAX_METADATA_TOKENS}-token limit"), - )); - } - let token = token.map_err(|error| { - invalid_metadata(path, format!("could not preflight metadata: {error}")) - })?; - match token { - LazyDataToken::ElementHeader(header) => { - if is_pixel_value(header.tag) { - return Ok(()); - } - if header.len.0 > MAX_METADATA_ELEMENT_BYTES { - return Err(invalid_metadata( - path, - format!( - "metadata element value limit is {MAX_METADATA_ELEMENT_BYTES} bytes, but {} declares {} bytes", - header.tag, header.len.0 - ), - )); - } - declared_value_bytes = declared_value_bytes - .checked_add(u64::from(header.len.0)) - .ok_or_else(|| invalid_metadata(path, "metadata byte count overflows"))?; - if declared_value_bytes > MAX_METADATA_VALUE_BYTES { - return Err(invalid_metadata( - path, - format!( - "metadata declares more than the {MAX_METADATA_VALUE_BYTES}-byte cumulative value limit" - ), - )); - } - } - LazyDataToken::SequenceStart { tag, .. } => { - if is_pixel_value(tag) { - return Ok(()); - } - sequence_depth = sequence_depth.saturating_add(1); - if sequence_depth > MAX_METADATA_SEQUENCE_DEPTH { - return Err(invalid_metadata( - path, - format!( - "metadata sequence nesting exceeds the {MAX_METADATA_SEQUENCE_DEPTH}-level limit" - ), - )); - } - } - LazyDataToken::PixelSequenceStart => return Ok(()), - LazyDataToken::SequenceEnd => { - sequence_depth = sequence_depth.saturating_sub(1); - } - lazy @ (LazyDataToken::LazyValue { .. } | LazyDataToken::LazyItemValue { .. }) => { - lazy.skip().map_err(|error| { - invalid_metadata(path, format!("could not skip metadata value: {error}")) - })?; - } - LazyDataToken::ItemStart { .. } | LazyDataToken::ItemEnd => {} - _ => { - return Err(invalid_metadata( - path, - "metadata parser returned an unsupported token", - )); - } - } - } - if sequence_depth != 0 { - return Err(invalid_metadata( - path, - "metadata ended inside an unterminated sequence", - )); - } - Ok(()) -} - -fn is_pixel_value(tag: Tag) -> bool { - matches!( - tag, - tags::FLOAT_PIXEL_DATA | tags::DOUBLE_FLOAT_PIXEL_DATA | tags::PIXEL_DATA - ) -} - -fn read_metadata_exact( - file: &mut std::fs::File, - path: &Path, - bytes: &mut [u8], - context: &str, -) -> Result<()> { - file.read_exact(bytes) - .map_err(|error| invalid_metadata(path, format!("could not read {context}: {error}"))) -} - -fn invalid_metadata(path: &Path, reason: impl std::fmt::Display) -> ViewerError { - ViewerError::InvalidInput(format!( - "DICOM metadata preflight failed for {}: {reason}", - path.display() - )) -} - fn inspect_dicom_instance(path: &Path) -> Result)>> { let obj = open_metadata_object(path)?; let sop_class_uid = obj.meta().media_storage_sop_class_uid().to_string(); diff --git a/crates/dicom-viewer-core/src/inspection/dicom_tests.rs b/crates/dicom-viewer-core/src/inspection/dicom_tests.rs index 98d0fe6..349acd2 100644 --- a/crates/dicom-viewer-core/src/inspection/dicom_tests.rs +++ b/crates/dicom-viewer-core/src/inspection/dicom_tests.rs @@ -1,17 +1,5 @@ use super::*; -fn part10_file_meta(transfer_syntax: &[u8]) -> Vec { - let mut bytes = vec![0; 128]; - bytes.extend_from_slice(b"DICM"); - bytes.extend_from_slice(&[0x02, 0x00, 0x00, 0x00, b'U', b'L', 0x04, 0x00]); - let group_length = u32::try_from(8 + transfer_syntax.len()).unwrap(); - bytes.extend_from_slice(&group_length.to_le_bytes()); - bytes.extend_from_slice(&[0x02, 0x00, 0x10, 0x00, b'U', b'I']); - bytes.extend_from_slice(&u16::try_from(transfer_syntax.len()).unwrap().to_le_bytes()); - bytes.extend_from_slice(transfer_syntax); - bytes -} - #[test] fn path_discovery_is_sorted_bounded_and_distinguishes_dicom_names() { let directory = tempfile::tempdir().unwrap(); @@ -69,82 +57,6 @@ fn preamble_and_top_level_inspection_fail_closed_without_hiding_plain_files() { assert!(inspection.warnings[0].contains("ignored unreadable DICOM candidate")); } -#[test] -fn explicit_headers_accept_both_length_forms_and_reject_malformed_headers() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("header.bin"); - - std::fs::write(&path, [0x02, 0x00, 0x00, 0x00, b'U', b'L', 0x04, 0x00]).unwrap(); - let mut file = std::fs::File::open(&path).unwrap(); - let header = read_explicit_header(&mut file, &path).unwrap(); - assert_eq!(header.tag, tags::FILE_META_INFORMATION_GROUP_LENGTH); - assert_eq!(header.vr, VR::UL); - assert_eq!((header.value_len, header.header_len), (4, 8)); - - std::fs::write( - &path, - [ - 0x02, 0x00, 0x01, 0x00, b'O', b'B', 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, - ], - ) - .unwrap(); - let mut file = std::fs::File::open(&path).unwrap(); - let header = read_explicit_header(&mut file, &path).unwrap(); - assert_eq!( - (header.vr, header.value_len, header.header_len), - (VR::OB, 4, 12) - ); - - for malformed in [ - vec![0x02, 0x00, 0x01, 0x00, b'Z', b'Z', 0x00, 0x00], - vec![0x02, 0x00, 0x01, 0x00, b'O', b'B', 0x01, 0x00], - vec![0x02, 0x00], - ] { - std::fs::write(&path, malformed).unwrap(); - let mut file = std::fs::File::open(&path).unwrap(); - assert!(read_explicit_header(&mut file, &path).is_err()); - } -} - -#[test] -fn file_meta_preflight_rejects_missing_invalid_and_unsupported_transfer_syntaxes() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("meta.dcm"); - - let mut missing = vec![0; 128]; - missing.extend_from_slice(b"DICM"); - missing.extend_from_slice(&[0x02, 0x00, 0x00, 0x00, b'U', b'L', 0x04, 0x00]); - missing.extend_from_slice(&0_u32.to_le_bytes()); - std::fs::write(&path, missing).unwrap(); - let mut file = std::fs::File::open(&path).unwrap(); - assert!(preflight_file_meta(&mut file, &path).is_err()); - - std::fs::write(&path, part10_file_meta(&[0xff, 0x00])).unwrap(); - let mut file = std::fs::File::open(&path).unwrap(); - assert!(preflight_file_meta(&mut file, &path).is_err()); - - std::fs::write(&path, part10_file_meta(b"9.9\0")).unwrap(); - assert!(open_metadata_object(&path).is_err()); - - let mut truncated_value = part10_file_meta(b"1.2.840.10008.1.2.1\0"); - truncated_value.extend_from_slice(&[0x10, 0x00, 0x10, 0x00, b'P', b'N', 0x04, 0x00]); - std::fs::write(&path, truncated_value).unwrap(); - assert!(open_metadata_object(&path).is_err()); - - let mut invalid_data_set_vr = part10_file_meta(b"1.2.840.10008.1.2.1\0"); - invalid_data_set_vr.extend_from_slice(&[0x10, 0x00, 0x10, 0x00, b'Z', b'Z', 0x00, 0x00]); - std::fs::write(&path, invalid_data_set_vr).unwrap(); - assert!(open_metadata_object(&path).is_err()); - - let mut oversized = vec![0; 128]; - oversized.extend_from_slice(b"DICM"); - oversized.extend_from_slice(&[0x02, 0x00, 0x00, 0x00, b'U', b'L', 0x04, 0x00]); - oversized.extend_from_slice(&(MAX_FILE_META_BYTES + 1).to_le_bytes()); - std::fs::write(&path, oversized).unwrap(); - let mut file = std::fs::File::open(&path).unwrap(); - assert!(preflight_file_meta(&mut file, &path).is_err()); -} - #[test] fn real_wsi_metadata_exposes_numeric_spacing_and_pixel_boundaries() { let directory = tempfile::tempdir().unwrap(); @@ -154,18 +66,11 @@ fn real_wsi_metadata_exposes_numeric_spacing_and_pixel_boundaries() { let object = open_metadata_object(&source).unwrap(); assert_eq!(optional_u32(&object, tags::ROWS), Some(4)); assert_eq!(optional_spacing(&object), Some((0.00025, 0.00025))); - assert!(is_pixel_value(tags::PIXEL_DATA)); - assert!(is_pixel_value(tags::FLOAT_PIXEL_DATA)); - assert!(is_pixel_value(tags::DOUBLE_FLOAT_PIXEL_DATA)); - assert!(!is_pixel_value(tags::ROWS)); let inspection = inspect_input(&source).unwrap(); assert_eq!(inspection.instances.len(), 1); assert_eq!(inspection.instances[0].rows, Some(4)); assert_eq!(file_name_display(&source), "source.dcm"); - assert!(invalid_metadata(&source, "reason") - .to_string() - .contains("reason")); } #[cfg(unix)] diff --git a/crates/dicom-viewer-core/src/lib.rs b/crates/dicom-viewer-core/src/lib.rs index 9fd5a8a..082b52b 100644 --- a/crates/dicom-viewer-core/src/lib.rs +++ b/crates/dicom-viewer-core/src/lib.rs @@ -32,11 +32,11 @@ pub use annotations::{ polygon_self_intersects, polygon_signed_area, srgb_to_dicom_cielab, AlgorithmIdentification, AnnotationClass, AnnotationClassConceptKey, AnnotationClassGeometry, AnnotationDocument, AnnotationGeometry, AnnotationGraphicType, AnnotationGroup, AnnotationMeasurement, - AnnotationObjectKind, AnnotationScheme, BinaryMaskRun, BinarySegmentationFrame, - CompositeSegmentGeometry, ControlledFindingSite, CoordinateGraphic, DiagnosticDisposition, - DiagnosticSeverity, DicomAnnotationContext, DicomBundlePublication, DicomCode, - DicomCodeValueKind, DicomPublicationError, DicomSinglePublication, ExternalLayerKind, - ExternalLayerReference, ExternalPromotionSource, FractionalMaskRun, + AnnotationObjectKind, AnnotationScheme, BinaryMaskRun, BinarySegmentationFrame, BulkAnnExport, + BulkAnnotationLocation, CompositeSegmentGeometry, ControlledFindingSite, CoordinateGraphic, + DiagnosticDisposition, DiagnosticSeverity, DicomAnnotationContext, DicomBundlePublication, + DicomCode, DicomCodeValueKind, DicomPublicationError, DicomSinglePublication, + ExternalLayerKind, ExternalLayerReference, ExternalPromotionSource, FractionalMaskRun, FractionalSegmentationFrame, GenerationType, InteroperabilityDiagnostic, LayerPresentation, LinearMeasurementSpec, MeasurementReportSemantics, ParametricMapDocument, ParametricMapInstance, ParametricMapPartPlan, ParametricMapPlan, ParametricMapPreview, @@ -51,7 +51,8 @@ pub use annotations::{ StructuredReportMeasurementGroup, StructuredReportQualitativeEvaluation, StructuredReportReferenceKind, TrackingIdentity, VectorFinding, VectorFindingGeometry, VectorLayer, VectorSegmentationPolicy, WorkspaceDocument, WorkspaceGeoJsonExport, - WorkspaceLinearMeasurement, WorkspaceObjectProvenance, WorkspacePresentation, + WorkspaceLinearMeasurement, WorkspaceObjectGeometryKind, WorkspaceObjectProvenance, + WorkspaceObjectRef, WorkspacePresentation, }; use inspection::{inspect_input, summarize_slide}; #[cfg(target_os = "macos")] diff --git a/crates/dicom-viewer-core/src/workspace_export_tests.rs b/crates/dicom-viewer-core/src/workspace_export_tests.rs index 12cffa4..45ea845 100644 --- a/crates/dicom-viewer-core/src/workspace_export_tests.rs +++ b/crates/dicom-viewer-core/src/workspace_export_tests.rs @@ -1,9 +1,20 @@ -use crate::annotation_test_support::write_source_wsi; +use crate::annotation_test_support::{write_source_wsi, write_source_wsi_with_optical_paths}; use crate::{ - AnnotationScheme, DicomAnnotationContext, Point2, SegmentOperation, SegmentationPrimitive, - VectorFindingGeometry, VectorSegmentationPolicy, ViewerSourceIdentity, WorkspaceDocument, + AlgorithmIdentification, AnnotationDocument, AnnotationScheme, DicomAnnotationContext, + DicomCode, ExternalLayerKind, ExternalLayerReference, ExternalPromotionSource, GenerationType, + Point2, SegmentOperation, SegmentationPrimitive, SourceFrameContext, VectorFindingGeometry, + VectorSegmentationPolicy, ViewerSourceIdentity, WorkspaceDocument, }; +fn automatic_algorithm() -> AlgorithmIdentification { + AlgorithmIdentification::new( + DicomCode::new("AI", "99FRAMES", "Artificial intelligence").unwrap(), + "bulk exporter", + "1.0", + ) + .unwrap() +} + fn square(x: f64, y: f64, size: f64) -> Vec { vec![ Point2::new(x, y), @@ -140,6 +151,469 @@ fn ann_exports_independent_vector_findings_only_and_reuses_tracking_uid() { ); } +#[test] +fn automatic_bulk_ann_groups_polygons_and_returns_exact_locations() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + let ann_path = temp.path().join("bulk.dcm"); + write_source_wsi(&source, 256, 256, 64, 64); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + let mut document = workspace(); + let layer = document.vector_layers()[0].id(); + let first = document + .add_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![ + square(10.0, 10.0, 10.0), + square(30.0, 10.0, 10.0), + ]), + ) + .unwrap(); + let second = document + .add_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(50.0, 10.0, 10.0)]), + ) + .unwrap(); + + let first_export = document + .export_automatic_bulk_ann(&context, automatic_algorithm()) + .unwrap(); + let second_export = document + .export_automatic_bulk_ann(&context, automatic_algorithm()) + .unwrap(); + + assert_eq!(first_export.document().groups().len(), 1); + let group = &first_export.document().groups()[0]; + assert_eq!(group.annotation_count(), 3); + assert_eq!(group.generation_type(), GenerationType::Automatic); + assert_eq!(group.algorithms(), &[automatic_algorithm()]); + assert_eq!(group.uid(), second_export.document().groups()[0].uid()); + let locations = first_export.annotation_locations(); + assert_eq!(locations.len(), 2); + assert_eq!(locations[0].object_id(), first); + assert_eq!( + locations[0].tracking_uid(), + document.finding(first).unwrap().tracking().uid() + ); + assert_eq!(locations[0].group_uid(), group.uid()); + assert_eq!(locations[0].first_annotation_index().get(), 1); + assert_eq!(locations[0].annotation_count().get(), 2); + assert_eq!(locations[1].object_id(), second); + assert_eq!(locations[1].group_uid(), group.uid()); + assert_eq!(locations[1].first_annotation_index().get(), 3); + assert_eq!(locations[1].annotation_count().get(), 1); + first_export.document().write_ann(&ann_path).unwrap(); + let restored = AnnotationDocument::read_ann(&ann_path, &context).unwrap(); + for location in locations { + let restored_group = restored + .groups() + .iter() + .find(|candidate| candidate.uid() == location.group_uid()) + .unwrap(); + let start = usize::try_from(location.first_annotation_index().get() - 1).unwrap(); + let end = start + usize::try_from(location.annotation_count().get()).unwrap(); + let expected = match document.finding(location.object_id()).unwrap().geometry() { + VectorFindingGeometry::Regions(components) => components + .iter() + .map(|component| component.to_vec()) + .collect::>(), + VectorFindingGeometry::Point(_) => unreachable!(), + }; + assert_eq!( + &restored_group.polygon_annotations().unwrap()[start..end], + expected + ); + } + + let singleton = document.export_ann(&context).unwrap(); + assert_eq!(singleton.groups().len(), 2); + assert_eq!( + singleton.groups()[0].uid(), + document.finding(first).unwrap().tracking().uid() + ); + assert_eq!( + singleton.groups()[1].uid(), + document.finding(second).unwrap().tracking().uid() + ); +} + +#[test] +fn automatic_bulk_ann_separates_classes_and_graphic_types() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + write_source_wsi(&source, 256, 256, 64, 64); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + let mut document = workspace(); + let layer = document.vector_layers()[0].id(); + document + .add_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(10.0, 10.0, 10.0)]), + ) + .unwrap(); + document + .add_vector_finding( + layer, + "necrosis", + VectorFindingGeometry::regions(vec![square(30.0, 10.0, 10.0)]), + ) + .unwrap(); + document + .add_vector_finding( + layer, + "cell", + VectorFindingGeometry::Point(Point2::new(50.0, 10.0)), + ) + .unwrap(); + + let export = document + .export_automatic_bulk_ann(&context, automatic_algorithm()) + .unwrap(); + + assert_eq!(export.document().groups().len(), 3); + assert_eq!(export.document().groups()[0].label(), "Neoplasm"); + assert!(export.document().groups()[0] + .polygon_annotations() + .is_some()); + assert_eq!(export.document().groups()[1].label(), "Necrosis"); + assert!(export.document().groups()[1] + .polygon_annotations() + .is_some()); + assert_eq!(export.document().groups()[2].label(), "Cell"); + assert!(export.document().groups()[2].point_annotations().is_some()); +} + +#[test] +fn automatic_bulk_ann_separates_site_and_known_optical_path_context() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + write_source_wsi_with_optical_paths(&source, 256, 256, 64, 64, &["A", "B"]); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + let mut scheme_json: serde_json::Value = + serde_json::from_slice(&AnnotationScheme::general_pathology_v1().to_json().unwrap()) + .unwrap(); + scheme_json["finding_sites"] = serde_json::json!([{ + "code_value": "91723000", + "coding_scheme_designator": "SCT", + "code_meaning": "Anatomical structure" + }]); + let scheme = AnnotationScheme::from_json(&serde_json::to_vec(&scheme_json).unwrap()).unwrap(); + let site = scheme.finding_sites()[0].clone(); + let mut document = WorkspaceDocument::new( + ViewerSourceIdentity::new(74, 0, 0, 0, 0, 0, (256, 256)), + scheme, + ) + .unwrap(); + let layer = document.vector_layers()[0].id(); + let plain = document + .add_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(10.0, 10.0, 5.0)]), + ) + .unwrap(); + let with_site = document + .add_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(20.0, 10.0, 5.0)]), + ) + .unwrap(); + document + .set_object_finding_site(with_site, Some(&site)) + .unwrap(); + let external = + ExternalLayerReference::new("CellViT", ExternalLayerKind::DicomAnn, None, None, 3); + let external_id = external.id(); + document.add_external_layer(external).unwrap(); + for (index, source_frame) in [ + SourceFrameContext::new(Some("A".into()), None, None, None), + SourceFrameContext::new(Some("B".into()), None, None, None), + ] + .into_iter() + .enumerate() + { + document + .promote_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(30.0 + index as f64 * 10.0, 10.0, 5.0)]), + ExternalPromotionSource::new( + external_id, + format!("cell-{index}"), + None, + source_frame, + ), + ) + .unwrap(); + } + + let export = document + .export_automatic_bulk_ann(&context, automatic_algorithm()) + .unwrap(); + + assert_eq!(export.document().groups().len(), 4); + assert!(export.document().groups()[0].anatomic_regions().is_empty()); + assert_eq!(export.document().groups()[1].anatomic_regions(), &[site]); + assert_eq!( + export.document().groups()[2].referenced_optical_paths(), + &["A"] + ); + assert_eq!( + export.document().groups()[3].referenced_optical_paths(), + &["B"] + ); + assert_eq!(export.annotation_locations()[0].object_id(), plain); +} + +#[test] +fn ann_exports_reject_every_explicit_plane_axis_including_zero() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + write_source_wsi_with_optical_paths(&source, 256, 256, 64, 64, &["A"]); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + + for source_frame in [ + SourceFrameContext::new(Some("A".into()), Some(0), None, None), + SourceFrameContext::new(Some("A".into()), None, Some(0), None), + SourceFrameContext::new(Some("A".into()), None, None, Some(0)), + ] { + let mut document = workspace(); + let layer = document.vector_layers()[0].id(); + let external = + ExternalLayerReference::new("source", ExternalLayerKind::DicomAnn, None, None, 1); + let external_id = external.id(); + document.add_external_layer(external).unwrap(); + document + .promote_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(10.0, 10.0, 5.0)]), + ExternalPromotionSource::new(external_id, "finding", None, source_frame), + ) + .unwrap(); + + for error in [ + document.export_ann(&context).unwrap_err(), + document + .export_automatic_bulk_ann(&context, automatic_algorithm()) + .unwrap_err(), + ] { + assert!(error.to_string().contains("2D ANN")); + assert!(error.to_string().contains("source axes")); + } + } +} + +#[test] +fn ann_exports_reject_unknown_optical_paths_before_annotation_construction() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + write_source_wsi_with_optical_paths(&source, 256, 256, 64, 64, &["A"]); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + let mut document = workspace(); + let layer = document.vector_layers()[0].id(); + let external = + ExternalLayerReference::new("source", ExternalLayerKind::DicomAnn, None, None, 1); + let external_id = external.id(); + document.add_external_layer(external).unwrap(); + document + .promote_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(10.0, 10.0, 5.0)]), + ExternalPromotionSource::new( + external_id, + "finding", + None, + SourceFrameContext::new(Some("UNKNOWN".into()), None, None, None), + ), + ) + .unwrap(); + + let error = document.export_ann(&context).unwrap_err(); + assert!(error.to_string().contains("UNKNOWN")); + assert!(error.to_string().contains("source WSI")); +} + +#[test] +fn multi_optical_ann_group_is_readable_but_has_a_precise_promotion_block_reason() { + let group = crate::AnnotationGroup::points( + "cells", + DicomCode::new("49755003", "SCT", "Morphologically abnormal structure").unwrap(), + DicomCode::new("4421005", "SCT", "Cell structure").unwrap(), + [1, 2, 3], + vec![Point2::new(1.0, 1.0)], + ) + .unwrap() + .with_referenced_optical_paths(vec!["A".into(), "B".into()]) + .unwrap(); + + let error = SourceFrameContext::from_ann_group(&group).unwrap_err(); + assert!(error.to_string().contains("2 optical paths")); + assert!(error.to_string().contains("remains read-only")); + assert!(error.to_string().contains("lose applicability")); +} + +#[test] +fn compatibility_ann_uses_the_same_source_context_preflight() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + write_source_wsi_with_optical_paths(&source, 256, 256, 64, 64, &["A"]); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + let mut document = WorkspaceDocument::new( + ViewerSourceIdentity::new(75, 0, 0, 0, 0, 0, (256, 256)), + AnnotationScheme::tumor_mask_compatibility_v1(), + ) + .unwrap(); + let external = + ExternalLayerReference::new("source", ExternalLayerKind::DicomAnn, None, None, 1); + let external_id = external.id(); + document.add_external_layer(external).unwrap(); + document + .promote_vector_finding( + document.vector_layers()[0].id(), + "cell", + VectorFindingGeometry::Point(Point2::new(10.0, 10.0)), + ExternalPromotionSource::new( + external_id, + "cell", + None, + SourceFrameContext::new(Some("A".into()), Some(0), None, None), + ), + ) + .unwrap(); + + let error = document + .export_tumor_mask_compatibility_ann(&context) + .unwrap_err(); + assert!(error.to_string().contains("2D ANN")); + assert!(error.to_string().contains("source axes")); +} + +#[test] +fn seg_export_rejects_any_nondefault_source_context() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + write_source_wsi_with_optical_paths(&source, 256, 256, 64, 64, &["A"]); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + + for source_frame in [ + SourceFrameContext::new(Some("A".into()), None, None, None), + SourceFrameContext::new(None, Some(0), None, None), + SourceFrameContext::new(None, None, Some(0), None), + SourceFrameContext::new(None, None, None, Some(0)), + ] { + let mut document = workspace(); + let layer = document.vector_layers()[0].id(); + let external = + ExternalLayerReference::new("source", ExternalLayerKind::DicomAnn, None, None, 1); + let external_id = external.id(); + document.add_external_layer(external).unwrap(); + document + .promote_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(10.0, 10.0, 5.0)]), + ExternalPromotionSource::new(external_id, "finding", None, source_frame), + ) + .unwrap(); + + let error = document + .export_seg(&context, VectorSegmentationPolicy::Rasterize) + .unwrap_err(); + assert!(error.to_string().contains("SEG")); + assert!(error.to_string().contains("source context")); + } +} + +#[test] +fn automatic_bulk_ann_rejects_per_object_names_and_comments() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + write_source_wsi(&source, 256, 256, 64, 64); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + for comment in [false, true] { + let mut document = workspace(); + let finding = document + .add_vector_finding( + document.vector_layers()[0].id(), + "neoplasm", + VectorFindingGeometry::regions(vec![square(10.0, 10.0, 10.0)]), + ) + .unwrap(); + if comment { + document + .set_object_comment(finding, Some("reviewed")) + .unwrap(); + } else { + document.set_object_name(finding, Some("cell 1")).unwrap(); + } + + let error = document + .export_automatic_bulk_ann(&context, automatic_algorithm()) + .unwrap_err(); + assert!(error + .to_string() + .contains("bulk ANN cannot preserve per-annotation name or comment")); + } +} + +#[test] +fn automatic_bulk_ann_round_trips_7266_polygons_in_one_group() { + const POLYGON_COUNT: usize = 7_266; + + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.dcm"); + let ann_path = temp.path().join("bulk-ann.dcm"); + write_source_wsi(&source, 256, 256, 64, 64); + let context = DicomAnnotationContext::from_source(&source).unwrap(); + let mut document = workspace(); + let layer = document.vector_layers()[0].id(); + for index in 0..POLYGON_COUNT { + let x = (index % 200) as f64; + let y = ((index / 200) % 200) as f64; + document + .add_vector_finding( + layer, + "neoplasm", + VectorFindingGeometry::regions(vec![square(x, y, 1.0)]), + ) + .unwrap(); + } + + let export = document + .export_automatic_bulk_ann(&context, automatic_algorithm()) + .unwrap(); + assert_eq!(export.document().groups().len(), 1); + assert_eq!( + export.document().groups()[0].annotation_count(), + POLYGON_COUNT + ); + assert_eq!(export.annotation_locations().len(), POLYGON_COUNT); + export.document().write_ann(&ann_path).unwrap(); + + let restored = AnnotationDocument::read_ann(&ann_path, &context).unwrap(); + assert_eq!(restored.groups().len(), 1); + assert_eq!(restored.groups()[0].annotation_count(), POLYGON_COUNT); + assert_eq!( + restored.groups()[0].polygon_annotations(), + export.document().groups()[0].polygon_annotations() + ); + let restored_group = &restored.groups()[0]; + for location in export.annotation_locations() { + assert_eq!(location.group_uid(), restored_group.uid()); + let start = usize::try_from(location.first_annotation_index().get() - 1).unwrap(); + let end = start + usize::try_from(location.annotation_count().get()).unwrap(); + assert!(end <= restored_group.annotation_count()); + assert_eq!(end - start, 1); + } +} + #[test] fn seg_keeps_same_class_segments_separate_and_vector_rasterization_is_explicit() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/dicom-viewer-core/src/workspace_tests.rs b/crates/dicom-viewer-core/src/workspace_tests.rs index 5cc0531..77786b1 100644 --- a/crates/dicom-viewer-core/src/workspace_tests.rs +++ b/crates/dicom-viewer-core/src/workspace_tests.rs @@ -6,9 +6,90 @@ use crate::{ AnnotationScheme, ExternalLayerKind, ExternalLayerReference, ExternalPromotionSource, Point2, SegmentEditOutcome, SegmentOperation, SegmentationPrimitive, SegmentationPrimitiveGeometry, SourceFrameContext, TrackingIdentity, VectorFindingGeometry, ViewerSourceIdentity, - WorkspaceDocument, + WorkspaceDocument, WorkspaceObjectGeometryKind, WorkspaceObjectRef, }; +#[test] +fn common_object_view_preserves_identity_layer_ownership_and_json_v1() { + let mut document = + WorkspaceDocument::new(source_identity(), AnnotationScheme::general_pathology_v1()) + .unwrap(); + let vector_layer = document.vector_layers()[0].id(); + let finding = document + .add_vector_finding( + vector_layer, + "cell", + VectorFindingGeometry::Point(Point2::new(10.0, 10.0)), + ) + .unwrap(); + let segment_layer = document.ensure_manual_segmentation_layer(); + let segment = document + .add_segment( + segment_layer, + "neoplasm", + SegmentationPrimitive::polygon(SegmentOperation::Add, square(20.0, 20.0, 5.0)), + ) + .unwrap(); + let measurement = document + .add_linear_measurement( + "neoplasm", + [Point2::new(30.0, 30.0), Point2::new(40.0, 30.0)], + Some(0.01), + ) + .unwrap(); + let json_before = document.to_json().unwrap(); + + let objects = document.objects().collect::>(); + assert_eq!( + objects + .iter() + .map(|object| object.object_id()) + .collect::>(), + [finding, segment, measurement] + ); + assert!(matches!( + document.object(finding), + Some(WorkspaceObjectRef::Vector(_)) + )); + assert!(matches!( + document.object(segment), + Some(WorkspaceObjectRef::Segment(_)) + )); + assert!(matches!( + document.object(measurement), + Some(WorkspaceObjectRef::Measurement(_)) + )); + assert_eq!( + document.object(finding).unwrap().geometry_kind(), + WorkspaceObjectGeometryKind::Point + ); + assert_eq!( + document.object(segment).unwrap().geometry_kind(), + WorkspaceObjectGeometryKind::Segmentation + ); + assert_eq!( + document.object(measurement).unwrap().geometry_kind(), + WorkspaceObjectGeometryKind::Measurement + ); + assert_eq!( + document.object_layer_id(finding).unwrap(), + Some(vector_layer) + ); + assert_eq!( + document.object_layer_id(segment).unwrap(), + Some(segment_layer) + ); + assert_eq!(document.object_layer_id(measurement).unwrap(), None); + assert!(document.object_layer_id(uuid::Uuid::nil()).is_err()); + assert_eq!(document.to_json().unwrap(), json_before); + assert_eq!( + WorkspaceDocument::from_json(&json_before) + .unwrap() + .schema_version(), + 1 + ); +} + fn source_identity() -> ViewerSourceIdentity { ViewerSourceIdentity::new(42, 1, 2, 3, 4, 5, (20_000, 10_000)) } diff --git a/docs/DICOM_NATIVE_CONVERSION.md b/docs/DICOM_NATIVE_CONVERSION.md index 4956be1..9b797b0 100644 --- a/docs/DICOM_NATIVE_CONVERSION.md +++ b/docs/DICOM_NATIVE_CONVERSION.md @@ -1,6 +1,6 @@ # DICOM-native pathology conversion -The separately versioned `wsi-dicom-annotations` library owns two research +The separately versioned `wsi-dicom-annotations` library owns two conversion boundaries in Rust, while `annotation_probe` exposes them through a deterministic CLI/report contract: @@ -18,8 +18,9 @@ an explicit source derivation reference. A common Frame of Reference means only that the derived object uses the source slide coordinate system; it does not claim spatial registration between pathology and radiology. -New viewer and `annotation_probe` outputs explicitly identify Manufacturer -`Frames` and Manufacturer Model Name `DICOM Viewer`. ANN, SEG, SR, and PM use +New outputs identify Manufacturer `Frames`. Desktop exports use Manufacturer +Model Name `DICOM Viewer`; the standalone `wsi-annotation-probe` package in +`wsi-dicom-annotations` uses `Annotation Probe`. ANN, SEG, SR, and PM use Series Numbers 9101, 9201, 9301, and 9401 respectively, with format-specific Series Descriptions. Round-tripping an imported object instead retains its imported equipment identity. @@ -205,6 +206,6 @@ losses, semantic digest, timing, and tracked peak heap. Profile and GeoJSON checksums are computed from the exact bounded byte buffers consumed by the parser. Timing and memory never enter the semantic digest. -This is research-use-only infrastructure. It does not add model execution, +This infrastructure does not add model execution, geographic CRS handling, ontology inference, fractional SEG generation, radiology/pathology spatial registration, or PACS transport. diff --git a/docs/PATHOLOGY_PERFORMANCE.md b/docs/PATHOLOGY_PERFORMANCE.md index 28022a8..135940b 100644 --- a/docs/PATHOLOGY_PERFORMANCE.md +++ b/docs/PATHOLOGY_PERFORMANCE.md @@ -50,9 +50,8 @@ performance claim. Before using this result to retain or change a production limit, rerun the documented command and record the exact commit/worktree state, raw 15-sample series, output checksum or equivalent parity evidence, and peak RSS. New tile -pipeline experiments belong in the [refactor performance -program](refactor/PERFORMANCE.md) and must follow that document's complete -before/after protocol. +pipeline experiments must record equivalent before/after workloads, repeated samples, +output parity, and peak memory before making performance claims. | Detailed objects | Detailed vertices | p50 | p95 / max | | ---: | ---: | ---: | ---: | diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 1442191..9482665 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,8 +1,4 @@ -# Research release checklist - -This project may be released only as a research-use-only viewer for inputs -that contain no patient data. It has no clinical, diagnostic, or -de-identification claim. +# Release checklist ## Automated gates @@ -25,10 +21,16 @@ runtime validation must pass when those backends are included in a release. ## Reproducible source gate The locked graph must resolve `wsi-rs` 0.6.0 at revision `b940ea94` and J2K -0.10.0 at revision `57b6af89` from their upstream Git repositories. CI and -packaging must build without sibling codec checkouts or local-only source -overrides. Run `cargo metadata --locked --format-version 1` from a clean -checkout and confirm that it leaves `Cargo.lock` unchanged. +0.10.0 at revision `57b6af89` from their upstream Git repositories, plus +`wsi-dicom-annotations` 0.1.2 from crates.io. The annotations release must include +the shared `metadata::open_metadata_object` API. Publication of that version and +a registry lockfile refresh remain prerequisites recorded by the 4 September +2026 validation. + +CI and packaging must build without sibling checkouts or local source overrides. +Run `cargo metadata --locked --format-version 1` from a clean checkout and confirm +that it leaves `Cargo.lock` unchanged. A successful build with a local annotations +overlay does not satisfy this gate. ## Interactive performance gate @@ -45,7 +47,7 @@ Before describing a build as real-time or interactively responsive: stderr, and externally capture presentation frame times. Exercise sustained pan and zoom plus repeated fit, level transitions, facts pagination, measurement, annotation, and GeoJSON replacement. -4. Test representative SVS, DICOM VL WSI, and raw JPEG 2000 research inputs for +4. Test representative SVS, DICOM VL WSI, and raw JPEG 2000 inputs for at least 30 minutes in total. Include cold starts, warm revisits, malformed input, rapid direction reversals, and memory pressure. 5. Report p50, p95, p99, and maximum observations alongside every predeclared @@ -54,7 +56,7 @@ Before describing a build as real-time or interactively responsive: ## Acceptance and packaging -- Open representative SVS, DICOM VL WSI, and raw JPEG 2000 research fixtures; +- Open representative SVS, DICOM VL WSI, and raw JPEG 2000 fixtures; exercise fit, pan, zoom, facts pagination, measurement, annotation, and atomic GeoJSON replacement. - Exercise malformed metadata, oversized geometry, and decoder failures and diff --git a/docs/WORKSPACE_STORAGE.md b/docs/WORKSPACE_STORAGE.md index f294e6c..1bdb5ac 100644 --- a/docs/WORKSPACE_STORAGE.md +++ b/docs/WORKSPACE_STORAGE.md @@ -58,13 +58,12 @@ does not survive restart. ## Privacy boundary Source directory keys do not include patient names. This is still local -research annotation storage, not de-identification: +annotation storage, not de-identification: - annotations, comments, controlled finding sites, and provenance are content; - portable workspaces embed that content and the pinned terminology; - linked external-layer metadata may contain local filesystem paths; - paths and content may appear in the UI, exports, screenshots, or backups. -Use only inputs permitted by the viewer's research-use policy. The viewer does -not upload workspaces, implement collaborative review, or provide a +The viewer does not upload workspaces, implement collaborative review, or provide a PACS/DICOMweb storage workflow. diff --git a/docs/refactor/BASELINE.md b/docs/refactor/BASELINE.md deleted file mode 100644 index a9224e2..0000000 --- a/docs/refactor/BASELINE.md +++ /dev/null @@ -1,179 +0,0 @@ -# Baseline - -## Repository and working tree - -- Repository: `/Users/user/Bench/frames/dicom-viewer` -- Audit anchor and starting HEAD: `87aa73d8cf5223ef6ba674e2ef7747453df06442` -- Branch: `main`, tracking `origin/main` -- There are no repository- or directory-level `AGENTS.md` files below the target repository. -- The target is a three-member Rust workspace. It also has a path dependency on the sibling `/Users/user/Bench/frames/wsi-dicom-annotations`, whose dirty API currently participates in the baseline compile failure. -- The worktree was already substantially dirty when the refactor plan was created. No reset, clean, checkout, commit, or remote mutation is permitted. - -Starting tracked diff statistic: - -```text -29 files changed, 2764 insertions(+), 3193 deletions(-) -``` - -The pre-existing changes add a pathology workspace, background/export/raster/report -jobs, annotation probe, core annotation model, supply-chain patches, and a mechanical -split of tile loader queue/decode/worker code. They also delete the former monolithic -annotation and measurement modules. These changes are not part of the refactor -checkpoint and must be preserved. - -## Toolchain and machine - -- Rust: `rustc 1.96.0 (ac68faa20 2026-05-25)`, host `aarch64-apple-darwin`, LLVM 22.1.2. -- Cargo: `1.96.0 (30a34c682 2026-05-25)`. -- OS: macOS 26.5.2 (25F84), Darwin 25.5.0. -- CPU: Apple M4 Pro, 12 logical/physical CPUs. -- Memory: 51,539,607,552 bytes (48 GiB). -- GPU: Apple M4 Pro, 16 GPU cores, Metal 4. -- Display: built-in 3024×1964 Retina. - -## Workspace members and dependency revisions - -| Member | Kind | Responsibility | -| --- | --- | --- | -| `apps/dicom-viewer` | binaries `dicom-viewer`, `tile_probe`, `annotation_probe` | native eframe UI, tile pipeline, probes | -| `crates/dicom-viewer-core` | library | wsi-rs façade, inspection, color, viewer models, annotation adapters | -| `crates/metal-wgpu-interop` | macOS library | narrow unsafe Metal/wgpu import boundary | - -Important locked declarations: - -- workspace Rust version 1.96, edition 2021; -- `wsi-rs = 0.5.2` from crates.io; Metal feature on macOS, CUDA feature when selected; -- `j2k-core`, `j2k-native`, `j2k-metal-support = 0.8.0`; -- `wgpu = 29.0.3`, `egui-wgpu = 0.34.3`, `eframe = 0.34.2`; -- direct path dependency `wsi-dicom-annotations = 0.1.0` at `../wsi-dicom-annotations`; -- local `[patch.crates-io]` entries for audited `vendor/lru` and `vendor/wayland-scanner` security backports. - -## Supported backend and feature matrix - -| Platform | Default | Optional | Runtime evidence available here | -| --- | --- | --- | --- | -| macOS | wgpu presentation; renderer-local Metal decode/import when supported; CPU fallback | forced CPU through config | compile/test host available; real WSI fixture absent | -| Linux | wgpu presentation; CPU decode | `--features cuda`, checked host download, no CUDA-to-wgpu interop | unavailable locally | -| Windows | wgpu presentation; CPU decode | none documented | unavailable locally | - -`--all-features` is not a portable release command. CI runs default checks on macOS, -Windows, and Linux; CUDA compilation is Linux-only, and runtime CUDA validation uses a -self-hosted CUDA runner. - -## Repository-supported validation commands - -```sh -cargo fmt --all -- --check -cargo metadata --locked --format-version 1 -cargo clippy --workspace --all-targets --locked -- -D warnings -cargo test --workspace --all-targets --locked -cargo check --workspace --all-targets --locked --features cuda # Linux CI -cargo test -p dicom-viewer-core --locked macos_metal_options_return_resident_tiles_for_synthetic_dicom_htj2k # macOS -cargo build --workspace --release --locked -cargo machete -cargo audit --deny unsound -cargo deny check advisories bans licenses sources -``` - -CUDA runtime workflow additionally runs in the sibling repositories: - -```sh -cargo test --locked --features cuda --lib cuda_download_cpu -cargo test -p dicom-viewer-core --locked --features cuda cuda_viewer_download_matches_strict_cpu_for_synthetic_dicom_htj2k -``` - -Probe command (requires a user-supplied research fixture): - -```sh -cargo run --release -p dicom-viewer --bin tile_probe -- \ - --trials 5 --json --api controlled-render --backend auto --batch-size 8 sample.svs -``` - -Manual workflows are in `MANUAL_ACCEPTANCE.md`. The repository has no separate -documentation build command; library doctests are included in workspace tests. - -## Baseline command results - -| Command | Result | -| --- | --- | -| `git diff --check` | PASS | -| `cargo fmt --all -- --check` | PASS | -| `cargo test --workspace --all-targets --locked` | FAIL during app compilation after 19.1 s: `workspace.rs:1266` calls missing `vectorized_annotation_groups`; dirty path dependency exposes `vectorized_annotations(policy)` | - -The baseline blocker was then repaired in the viewer with the current typed API and an -explicit `AllowLoss` policy, preserving the old raster-overlay projection semantics. -Post-repair validation: - -| Command | Result | -| --- | --- | -| `cargo fmt --all -- --check` | PASS | -| `cargo metadata --locked --format-version 1` | PASS | -| `cargo clippy --workspace --all-targets --locked -- -D warnings` | PASS; upstream `block 0.1.6` future-incompatibility notice remains | -| `cargo test --workspace --all-targets --locked` | PASS: 352 passed, 2 ignored, 0 failed | -| `cargo test -p dicom-viewer-core --locked macos_metal_options_return_resident_tiles_for_synthetic_dicom_htj2k` | PASS: 1 passed | -| `cargo build --workspace --release --locked` | PASS in 2m32s | -| `cargo machete` | PASS; no unused dependencies | -| `cargo audit --deny unsound` | PASS with two policy-allowed unmaintained warnings (`encoding`, `paste`) | -| `cargo deny check advisories bans licenses sources` | PASS; duplicate-version warnings are allowed by policy | - -CUDA compilation/runtime, Linux/Windows checks, real-fixture probes, and manual UI -acceptance were not run on this macOS checkpoint. - -## Fixture inventory - -- No committed real WSI/DICOM/JPEG/JPEG-2000 performance files were found. -- Tile-loader tests generate a tiny HTJ2K RGB8 codestream and temporary source. -- Core tests synthesize Part 10 DICOM WSI metadata, malformed headers, oversized values, and truncated metadata. -- Tile/canvas/store/upload tests use synthetic keys, levels, decoded buffers, fake textures/uploaders, cancellation tokens, huge grids, edge tiles, and malformed cardinalities. -- Optional local parity tests use `DICOM_VIEWER_WSI_FIXTURE`; it is not configured in the baseline. -- Missing fixture classes: multiple optical paths, multiple focal planes, concatenated instances, sparse/non-full tiling, high-coordinate end-to-end geometry, and representative real cold/warm performance sources. - -## Telemetry schema and benchmark surface - -- Current public diagnostics are schema v4 JSONL `pipeline_window` and `interaction_summary` records emitted once per second to stderr when `DICOM_VIEWER_DEBUG_STATS=1`. -- JSON is manually formatted in `app/tile/stats/json.rs`; state update paths call `eprintln!` in `stats.rs`. -- `app_ui_cpu_ms` excludes egui tessellation, GPU execution, and presentation. -- `tile_probe` measures source APIs, not end-to-end UI or a cold OS page cache. -- No baseline performance number is recorded because no representative fixture was supplied. - -## Production hotspot inventory - -This is the initial responsibility inventory; `DV-G0-005` remains open until every -production module is classified. - -| Path (current LOC) | Current responsibilities / owned state | Synchronization, boundary, extraction direction | -| --- | --- | --- | -| `app/workspace.rs` (2,089) | pathology editor/runtime, external payloads, promotion, selection/drafts | UI-thread state; split only along real editor/promotion/runtime ownership | -| `app/tile/store.rs` (1,674) | all six tile states, bytes, retry/failure, upload transaction, eviction, coverage, drawing | UI-thread owner; wrongly imports egui/presentation; target decoded/ready/failure cache + ledger only | -| `app/tile/stats.rs` (1,542) | samples, counters, interactions, overlays, output | direct stderr; target typed collector/snapshot separate from sink/overlay | -| `app/tile/upload.rs` (1,389) | CPU/Metal preparation, WGSL, LUTs, wgpu submission, egui registration, config | UI-thread GPU boundary; target CPU/Metal/registration/color setup responsibilities | -| `app/canvas.rs` (1,289) | level policy, frame plan, warming, demand/poll construction, cache protection, draw orchestration | UI thread; target pure plan + orchestration + painter | -| `app/tile.rs` (960) | domain types, renderer/coordinator-like orchestration, demand canonicalization, acceptance sets, upload planning | UI thread; target domain/demand/policy/coordinator split | -| `app/level_warmer.rs` (918) | queue, worker lifecycle, preparation, diagnostics | `Arc>`, `Condvar`, bounded result channel; share only lifecycle/diagnostic primitives | -| `app.rs` (807) | app orchestration, open flow/status, pathology actions, UI commands | UI thread plus open queue; retain high-level orchestration only | -| `app/tile/loader.rs` (425) | scheduler facade, shared queue/in-flight state, worker ownership, configuration | `Arc>`, `Condvar`, bounded result channel; dirty tree extracted queue/decode/worker files | -| `app/tile/loader/queue.rs` (342) | admission, fairness, batch formation, diagnostics buffer setup | shared loader lock; target scheduler owner | -| `app/tile/loader/decode.rs` (295) | source reads, cardinality, panic containment, CUDA/CPU recovery | no cache mutation; target typed decoder outcomes | -| `app/tile/loader/worker.rs` (104) | named spawn and worker loop | operates loader shared state; target worker-pool lifecycle owner | -| `app/open_job.rs` (188) | max-two open queue, latest pending, generation filtering | unbounded `mpsc::channel`, detached thread handles; target cooperative managed tasks | -| `core/model.rs` (691) | errors, summaries, config/budgets, tile/color/identity | core public surface; split by responsibility while preserving re-exports | -| `core/inspection/dicom.rs` (630) | enumeration, bounded preflight, fact extraction, aggregation/warnings | synchronous I/O; multidimensional DICOM model incomplete | -| `core/color.rs` (889) | ICC selection, CPU transform, LUT proof/cache/policy | cohesive, security/performance-sensitive; do not split by size alone | -| `metal-wgpu-interop/macos.rs` (448) | raw Metal/wgpu validation/import | only allowed unsafe boundary; preserve narrow contract | - -## Current module dependency/ownership map - -```text -DicomViewerApp - -> SlideCanvas - -> TileRenderer - -> TileLoader -> queue/decode/worker -> ViewerStudy/wsi-rs - -> TileStore -> egui painting + cache + lifecycle + accounting - -> WgpuTileUploader -> ViewerOpenOptions/env + Metal/wgpu + egui registration - -> PipelineStats -> JSON formatting + stderr + overlay - -> LevelWarmer -> ViewerStudy/wsi-rs - -> TileFramePlan + FrameTileDemand + TilePollRequest - -> OpenQueue/OpenJob -> ViewerStudy::open -``` - -The target ownership and transition map is in `TILE_STATE_MODEL.md`. diff --git a/docs/refactor/DECISIONS.md b/docs/refactor/DECISIONS.md deleted file mode 100644 index ee01233..0000000 --- a/docs/refactor/DECISIONS.md +++ /dev/null @@ -1,64 +0,0 @@ -# Refactor Decisions - -This file is append-only. Supersede a decision with a later record; do not rewrite -history. - -## DV-ADR-001 — Preserve the dirty worktree - -- **Date:** 2026-08-20 -- **Context:** `HEAD` equals the audit anchor, but the worktree already contains a large pathology/workspace feature set, supply-chain changes, and a loader extraction. -- **Alternatives:** reset to the anchor; copy the repository; overwrite overlapping files; preserve and work incrementally in place. -- **Decision:** preserve all pre-existing modifications and untracked files, edit in place, and distinguish pre-existing evidence from changes made under this plan. -- **Rationale:** resetting or overwriting would destroy user work and violate repository instructions. Duplicate `_new` or `_v2` trees would worsen architecture. -- **Consequences:** baseline failures can come from coordinated dirty sibling changes; each refactor slice must inspect the active diff and minimize overlap. No local commit may be created without explicit authorization. -- **Tests/evidence:** session-start `git status --short --branch`, `git diff --stat`, `BASELINE.md`. - -## DV-ADR-002 — Concrete owners, not a generic pipeline framework - -- **Date:** 2026-08-20 -- **Context:** queue, decode, cache, upload, presentation, and telemetry currently overlap. -- **Alternatives:** generic actors/state-machine framework; broad rewrite; concrete scheduler/decoder/cache/coordinator/uploader/presenter boundaries. -- **Decision:** use concrete domain types and one named owner for each transition. Do not introduce a generic actor, pipeline, or state-machine framework. -- **Rationale:** the defect is duplicated ownership. A framework would add indirection without removing state. -- **Consequences:** some modest local duplication is acceptable when semantics differ; extraction follows characterization and ownership, not file-size targets. -- **Tests/evidence:** transition tests required by `INVARIANTS.md`; target map in `TILE_STATE_MODEL.md`. - -## DV-ADR-003 — Keep unsafe Metal interoperability isolated - -- **Date:** 2026-08-20 -- **Context:** `metal-wgpu-interop` validates device identity, pitch, format, offsets, allocation length, and lifetime using narrow audited unsafe code. -- **Alternatives:** move raw handles into uploader/tile modules; replace the boundary; preserve it. -- **Decision:** preserve the crate and keep application/core `forbid(unsafe_code)`. -- **Rationale:** widening raw-platform access would increase correctness and portability risk without solving lifecycle ownership. -- **Consequences:** upload refactors consume safe interop results only. Metal-specific runtime validation remains a separate gate. -- **Tests/evidence:** unsafe-policy lints and interop tests; `rg -n 'unsafe' apps crates`. - -## DV-ADR-004 — Repair the baseline before Phase 1 semantics - -- **Date:** 2026-08-20 -- **Context:** workspace tests do not compile because the dirty viewer calls an older SEG vectorization API than the dirty path dependency provides. -- **Alternatives:** ignore the failure and modify tile code; modify the sibling dependency; minimally adapt the viewer to the typed current API. -- **Decision:** make the smallest compatible viewer-side update, preserve typed loss policy, rerun the workspace baseline, then start checked tile-footprint work. -- **Rationale:** test-first phase work is not trustworthy on a non-compiling baseline, and the sibling repository is not a write target. -- **Consequences:** the compatibility change is recorded separately from Phase 1 architecture work. -- **Tests/evidence:** failing `cargo test --workspace --all-targets --locked`; subsequent evidence to be appended. - -## DV-ADR-005 — First architectural slice is checked tile footprint - -- **Date:** 2026-08-20 -- **Context:** memory arithmetic is repeated across planned texture preflight, planned upload peak, decoded tile cost, loader reservation, and cache accounting. -- **Alternatives:** start with central config, demand snapshot, telemetry, or lifecycle migration. -- **Decision:** after restoring the baseline, introduce a single checked `TileFootprint` value with edge/overflow tests before changing lifecycle ownership. -- **Rationale:** it is a narrow Phase 1 value boundary, reduces primitive arithmetic duplication, and establishes a ledger input without mixing module movement or state semantics. -- **Consequences:** this first slice must preserve existing byte-budget behavior; lifecycle migration remains separate. -- **Tests/evidence:** red/green tests required for zero dimensions, edge tiles, overflow, decoded/texture/temp/peak/reservation values. - -## DV-ADR-006 — Planned and actual footprints share one checked representation - -- **Date:** 2026-08-20 -- **Context:** pre-decode planning knows edge dimensions but must conservatively reserve an RGBA source plus final texture; decoded Metal work knows its actual retained allocation length and may include pitch. CPU decoded data is already final RGBA. -- **Alternatives:** separate estimate and actual structs; store primitive byte fields; one representation with route-specific constructors. -- **Decision:** use one `TileFootprint` with checked constructors for planned RGBA, actual CPU RGBA, and actual device allocations. `cpu_rgba_bytes` describes an overlapping decoded allocation rather than an additional peak allocation; temporary conversion bytes are explicit and currently zero for both routes. -- **Rationale:** one representation removes repeated arithmetic while retaining the semantic difference between planned reservation and actual retained device allocation. -- **Consequences:** in-flight admission uses `in_flight_reservation_bytes`; cache/upload transactions use actual `decoded_source_bytes` and `peak_upload_bytes`. A future route with a real temporary allocation must add it through the constructor rather than hand-adjusting a caller's total. -- **Tests/evidence:** `tile/memory.rs`; new edge/zero/overflow/component tests; existing decoded/store/reservation/upload/overview/fallback tests; workspace clippy/tests/release build. diff --git a/docs/refactor/DUPLICATION_INVENTORY.md b/docs/refactor/DUPLICATION_INVENTORY.md deleted file mode 100644 index a493921..0000000 --- a/docs/refactor/DUPLICATION_INVENTORY.md +++ /dev/null @@ -1,33 +0,0 @@ -# Duplication Inventory - -Status values describe migration, not whether every occurrence is byte-identical. - -| ID | Duplicated concept | Current implementations / semantic differences | Intended authoritative owner | Migration status / deletion gate | -| --- | --- | --- | --- | --- | -| DV-DUP-001 | base-to-screen transform | `CameraView`/viewport plus workspace and external overlays convert `Point2` to `f32` before calling transform | `ViewportTransform` over f64 slide geometry | OPEN; delete local conversion formulas after round-trip/large-coordinate tests | -| DV-DUP-002 | screen-to-base transform | camera/input/workspace interaction paths | `ViewportTransform` | OPEN; replace only after input-boundary tests | -| DV-DUP-003 | camera pan variants | camera/view/viewport helpers | camera module using canonical transform | OPEN; preserve animation semantics | -| DV-DUP-004 | zoom-around variants | camera/view/viewport helpers | camera module | OPEN; preserve anchor and clamp semantics | -| DV-DUP-005 | diagnostic capture setup | loader queue and level warmer independently create optional `Arc>>` | bounded `DiagnosticCapture` | OPEN; install/extract disabled-path tests first | -| DV-DUP-006 | diagnostic extraction/poison recovery | loader queue and level warmer lock/extract separately | `DiagnosticCapture` | OPEN; do not treat poisoned semantic contents as valid | -| DV-DUP-007 | worker lifecycle | loader, warmer, open job, background worker each own variants of spawn/channel/cancel/drop/join/panic containment | concrete small managed-worker primitives only for shared mechanics | PARTIAL; dirty tree has `BackgroundWorker`, but loader/warmer/open semantics remain distinct | -| DV-DUP-008 | RGBA byte calculation | formerly tile decoded cost, planned texture preflight, store reconciliation, upload validation, canvas overview/fallback | `TileFootprint` | DONE; relevant production `width × height × 4` arithmetic now exists only in `tile/memory.rs` | -| DV-DUP-009 | texture byte calculation | formerly `planned_texture_bytes`, `DecodedTile::memory_cost`, upload validation/store accounting | `TileFootprint` | DONE; old `TileMemoryCost` and `planned_texture_bytes` deleted | -| DV-DUP-010 | peak upload bytes | formerly `planned_upload_peak_bytes`, decoded memory cost, store reservation | `TileFootprint` | DONE; wrapper delegates to the checked representation and store consumes its value | -| DV-DUP-011 | edge-tile dimensions | formerly planning/preflight and canvas overview arithmetic | `TileFootprint::for_level_tile` plus validated core layout | DONE for tile-memory consumers; geometry planning remains separately owned | -| DV-DUP-012 | loader reservation bytes | formerly planned peak plus a duplicated full-tile `× 8` fallback | `TileFootprint` | DONE; both actual edge and conservative full-tile fallback use `in_flight_reservation_bytes` | -| DV-DUP-013 | cache byte accounting | `resident_byte_len`, insert/remove accounting, upload reservations | `MemoryLedger` named transitions | OPEN | -| DV-DUP-014 | lane fields | demand structs, poll request, loader stats, gauges, JSON, overlays | fixed `LaneMap` keyed by `QueueLane` | OPEN | -| DV-DUP-015 | DICOM index outcome matching | rolling samples, interactions, lifetime counters, overlay/JSON | typed DICOM outcome counters with `record/merge/delta/snapshot` | OPEN | -| DV-DUP-016 | queue classification/priority | `QueueLane` ordering, retention rank, queue comparison, upload ordering | `TilePipelinePolicy` with named execution/retention/upload order | OPEN | -| DV-DUP-017 | upload wrappers | sink trait, budgeted batch, internal preparation, CPU/Metal branches | `TileUploader` jobs/outcomes with CPU/Metal preparation and registration sub-owners | OPEN | -| DV-DUP-018 | file extension knowledge | picker/core/readme/tests | core input capability table exposed to UI/docs tests | OPEN; verify wsi-rs capabilities before centralizing | -| DV-DUP-019 | configuration parsing | core model/tile output/lib, canvas, loader, stats, uploader, main | startup `ViewerConfig`, explicit sub-config values | OPEN | -| DV-DUP-020 | retry/fallback classification | loader read mode, decode strings, store booleans, upload errors/status | typed failure/retry policy at coordinator/decoder/uploader boundary | OPEN | -| DV-DUP-021 | lifetime counter deltas | repeated telemetry fields/match arms | typed counter `delta/snapshot` | OPEN | -| DV-DUP-022 | JSON serialization | multiple `format!` records and manual optional-number formatting | serde records written by `TelemetrySink` | OPEN; schema-v4 goldens first | -| DV-DUP-023 | level lookup/tile plans | canvas scans and separate overview/fallback/prefetch calculations | pure `TileFramePlan` plus validated level index | OPEN; measure/index without corrupting skipped-level semantics | - -Deletion is complete only when all former implementations are removed or intentionally -retained with documented distinct semantics. Wrapping duplicates without deleting their -policy ownership does not close an item. diff --git a/docs/refactor/FAILURE_MATRIX.md b/docs/refactor/FAILURE_MATRIX.md deleted file mode 100644 index 191f87a..0000000 --- a/docs/refactor/FAILURE_MATRIX.md +++ /dev/null @@ -1,36 +0,0 @@ -# Failure Matrix - -Every injection point must have an explicit owner, observable outcome, and regression -test. “Telemetry” means a typed event once P2/P5 are complete. - -| ID | Injection point | Required behavior / owner | Current evidence | Status | -| --- | --- | --- | --- | --- | -| DV-FAIL-001 | open worker creation | open queue rejects/retains safe pending state; UI receives infrastructure error; no phantom active task | spawn errors surfaced, queue bounded at two | PARTIAL | -| DV-FAIL-002 | source open | typed contextual error, sanitized ordinary path label, no state replacement | generation rejection exists; full path status exists | OPEN | -| DV-FAIL-003 | level preparation | bounded error event, no stale generation publication, visible decode continues | warmer tests cover failures/stale events | PARTIAL | -| DV-FAIL-004 | queue admission | cap/worker-unavailable explicit result; store/cache not marked queued on rejection | cap tests exist; zero-worker construction defect remains | OPEN | -| DV-FAIL-005 | source read | typed transient/permanent/cancelled result with tile identity | string failure + cancellation tests | PARTIAL | -| DV-FAIL-006 | batch cardinality | validate before cache mutation; recover individually where supported | decoder tests cover wrong cardinality/individual recovery | PARTIAL | -| DV-FAIL-007 | cancellation | no failure count, retry, stale cache commit, or subsequent batch element | loader/store cancellation tests exist | PARTIAL | -| DV-FAIL-008 | decoder panic | contain at worker boundary; produce typed infrastructure/tile result; worker pool remains coherent | panic containment tests exist | PARTIAL | -| DV-FAIL-009 | CUDA download | explicit `CudaDownload`; one ordered CPU retry; no hidden readback | decode/core parity tests exist, runtime unavailable locally | PARTIAL | -| DV-FAIL-010 | CPU retry | attempt identity and limit; visible priority retained | read-mode/boolean state and pruning test | OPEN | -| DV-FAIL-011 | Metal import | rollback decoded ownership or typed CPU retry; coherent diagnostic | store/uploader tests for failures | PARTIAL | -| DV-FAIL-012 | wgpu validation | scoped synchronous/async validation maps to upload outcome | incomplete error model | OPEN | -| DV-FAIL-013 | wgpu device loss | stop uploads, invalidate ready device textures, recreate or explicit fallback/unavailable | documented known compromise | OPEN | -| DV-FAIL-014 | wgpu out of memory | explicit non-string device OOM outcome; no retry storm | no explicit variant | OPEN | -| DV-FAIL-015 | texture creation | checked dimensions/limits/bytes; input retained or terminal typed failure | preflight/accounting tests | PARTIAL | -| DV-FAIL-016 | texture registration | unregister partial resources; reconcile one input/outcome | RAII texture wrapper and partial outcome tests | PARTIAL | -| DV-FAIL-017 | upload reconciliation | missing/surplus/partial outcomes cannot strand bytes/input | focused store tests | PARTIAL; replace remove/reinsert with transaction | -| DV-FAIL-018 | cache eviction | protected ordering, hard bound, oversized/pinned policy, deterministic result | broad store tests | PARTIAL; indexed policy/perf open | -| DV-FAIL-019 | DICOM inspection | bounded preflight, contextual sanitized failure, multidimensional facts | strong preflight tests; dimension model incomplete | PARTIAL | -| DV-FAIL-020 | annotation persistence | atomic publication; old data survives failure/cancel; contextual sanitized status | pre-existing workspace tests/docs | PARTIAL; outside tile first slice | -| DV-FAIL-021 | open-job supersession | cooperative cancellation, no stale app update, bounded handles, eventual reap | generation filter/latest pending; no cooperative cancellation/join | OPEN | - -## Failure classes - -The target typed classes are `Cancelled`, `TransientSource`, `CudaDownload`, -`DeviceLost`, `DeviceOutOfMemory`, `UploadValidation`, `PermanentInvalidInput`, -`PermanentUnsupported`, and `InfrastructureUnavailable`. Exact naming may adapt to -existing repository conventions, but string inspection and ambiguous retry booleans are -not acceptable ownership mechanisms. diff --git a/docs/refactor/INVARIANTS.md b/docs/refactor/INVARIANTS.md deleted file mode 100644 index 9ed0785..0000000 --- a/docs/refactor/INVARIANTS.md +++ /dev/null @@ -1,51 +0,0 @@ -# Tile Pipeline Invariants - -These are testable contracts, not aspirations. A transition or extraction is incomplete -until the relevant invariant has a focused test. - -- **DV-INV-001:** a `TileKey` is owned by at most one lifecycle state at a time. -- **DV-INV-002:** `Queued` and `InFlight` exist only in the scheduler. -- **DV-INV-003:** `Decoded`, `Ready`, and `Failed` exist only in the cache/coordinator domain. -- **DV-INV-004:** every state transition has one named owner. -- **DV-INV-005:** every state transition commits completely or leaves the previous state recoverable. -- **DV-INV-006:** resident byte accounting equals authoritative cache entries plus active upload reservations. -- **DV-INV-007:** pinned bytes are a subset of resident bytes unless a separately named in-flight reservation is included. -- **DV-INV-008:** an upload transaction owns its decoded input and peak-memory reservation until commit or rollback. -- **DV-INV-009:** a cancelled or obsolete result cannot populate a current cache generation. -- **DV-INV-010:** demand publication and result acceptance use the same immutable demand-snapshot identity. -- **DV-INV-011:** every failure is one of: retryable source, retryable device, explicit CPU fallback, cancellation, terminal unsupported input, terminal corrupt input, or application infrastructure failure. -- **DV-INV-012:** a failed tile is never counted as visually covered. -- **DV-INV-013:** terminal failure may count as interaction-resolution complete only through a separately named metric. -- **DV-INV-014:** CPU fallback for a visible tile preserves or raises visible execution priority. -- **DV-INV-015:** batch result order and cardinality are validated before cache mutation. -- **DV-INV-016:** one tile failure does not fail unrelated tiles when per-tile recovery is supported. -- **DV-INV-017:** zero available workers prevents queue admission or returns an explicit unavailable error. -- **DV-INV-018:** no hidden device-to-host download occurs. -- **DV-INV-019:** no command encoder is created or submitted for an empty upload batch. -- **DV-INV-020:** CPU-only uploads perform no unnecessary GPU compute submission. -- **DV-INV-021:** slide-space geometry remains `f64` or checked integer until the egui/wgpu boundary. -- **DV-INV-022:** process environment is parsed once into `ViewerConfig` at application startup. -- **DV-INV-023:** one process execution budget bounds outer tile workers and inner codec threads. -- **DV-INV-024:** disabled telemetry retains no sample vectors and emits no output. -- **DV-INV-025:** ordinary UI status and telemetry do not emit full local source paths. - -## Additional boundedness and compatibility invariants - -- **DV-INV-026:** every queue, result channel, cache, task collection, diagnostic buffer, and temporary decode/upload allocation has a checked bound. -- **DV-INV-027:** checked tile-footprint arithmetic covers actual edge dimensions, decoded bytes, texture bytes, temporary bytes, upload peak, and in-flight reservation; overflow is an explicit error. -- **DV-INV-028:** a result is accepted only when source identity, study generation, demand identity, and tile identity all match the coordinator's current state. -- **DV-INV-029:** queue reprioritization cannot discard a forced CPU read route or retry attempt identity. -- **DV-INV-030:** stopping or replacing a study cancels queued/in-flight work and prevents stale publication before new work consumes its result. -- **DV-INV-031:** poisoned-lock recovery never silently blesses semantically inconsistent scheduler or warmer state. -- **DV-INV-032:** cache access/touch is explicit; presentation never changes lifecycle or byte accounting. -- **DV-INV-033:** DICOM metadata preflight limits remain enforced before eager object parsing. -- **DV-INV-034:** dense WSI frame expectations follow the actual DICOM dimension organization, optical path, focal plane, concatenation, and instance partitioning rather than a two-dimensional tile-grid assumption. -- **DV-INV-035:** the application and core crates remain `forbid(unsafe_code)`; raw Metal/wgpu handles remain contained in `metal-wgpu-interop`. - -## Existing characterization evidence to preserve - -The current tree contains tests for queue priority/fairness/caps, reprioritization, -demand epoch changes, cancellation, cardinality recovery, CUDA fallback, stale result -rejection, byte accounting, upload reservation and partial outcomes, eviction protection, -failed coverage, frame-level fallback, and first-sharp/full-coverage metrics. Their exact -green status must be re-established after the baseline compile blocker is fixed. diff --git a/docs/refactor/MANUAL_ACCEPTANCE.md b/docs/refactor/MANUAL_ACCEPTANCE.md deleted file mode 100644 index 793f266..0000000 --- a/docs/refactor/MANUAL_ACCEPTANCE.md +++ /dev/null @@ -1,102 +0,0 @@ -# Manual Acceptance - -Use research-only fixtures with no patient data. Record fixture SHA-256, build command, -backend, OS/hardware, start/end time, and observed failures. Do not put full local paths -in public telemetry artifacts. - -## Common setup - -1. Build the exact dirty checkpoint with `cargo build --workspace --release --locked`. -2. Start the viewer with debug telemetry disabled, then repeat the performance workflow - with `DICOM_VIEWER_DEBUG_STATS=1` when telemetry validation is intended. -3. Record the source label, format, dimensions, tile dimensions, codec, ICC status, and - backend. Store the full local path only in a private test log if explicitly required. -4. Confirm no panic, unbounded status growth, path leak in JSONL, or silent backend fallback. - -## DV-MAN-001 — local file open - -- Open a supported single WSI file from the picker and command line. -- Verify metadata arrives, initial fit is correct, overview/fallback appears, target tiles - sharpen, loading settles, and the ordinary status uses a basename/redacted label. -- Supersede the open with another source and confirm stale results do not replace it. - -## DV-MAN-002 — DICOM folder open - -- Open a folder containing one coherent VL WSI series with multiple instances. -- Verify bounded enumeration, correct instance/frame facts, and rejection of mixed series. -- Exercise a malformed/oversized candidate and confirm a contextual non-crashing error. - -## DV-MAN-003 — zoom - -- Perform single-step, continuous, and rapid reversal zoom around visible anchors. -- Verify anchor stability, held-level behavior, visible priority, first-sharp transition, - no blanking when a coarser ready fallback exists, and stale-work cancellation. - -## DV-MAN-004 — pan - -- Pan slowly and rapidly at fit, intermediate, and highest useful zoom. -- Verify no coordinate drift, deterministic edge-tile geometry, bounded queue growth, and - no obsolete results appearing in the current viewport. - -## DV-MAN-005 — level transition - -- Cross adjacent pyramid thresholds in both directions, stop near hysteresis boundaries, - and rapidly reverse. -- Verify target/held/fallback ordering, no permanent low-resolution hold, and correct - full-target coverage semantics when a target tile fails. - -## DV-MAN-006 — overview fallback - -- Zoom deeply, move away, then fit the slide and revisit a recent region. -- Verify overview reservation is bounded, reusable, subordinate to the hard memory ceiling, - and never painted over a ready sharper target. - -## DV-MAN-007 — measurement - -- Create, edit/select, and remove a ruler at normal and extreme zoom. -- Verify base-coordinate stability, physical-unit calculation when spacing exists, clear - unavailability when it does not, undo/redo, and deterministic export identity. - -## DV-MAN-008 — annotation - -- Create/select/edit independent polygon and point findings; create separate same-class - segments with Add/Erase brush/polygon primitives; exercise undo/redo and autosave. -- Test large coordinates, near-collinear edges, touching/repeated points, and an invalid - self-intersection. Verify no geometry silently changes precision or topology. - -## DV-MAN-009 — annotation export - -- Export portable workspace, scheme-aware GeoJSON, eligible ANN/SEG/SR, and one supported - raster/PM flow. Exercise existing-destination decision, cancellation, and injected failure. -- Verify atomic publication, deterministic content where specified, preserved prior output, - and no unnecessary source path in exported metadata/JSON. - -## DV-MAN-010 — CPU mode - -- Run with `DICOM_VIEWER_TILE_BACKEND=cpu` using JPEG and JPEG-2000/HTJ2K fixtures. -- Verify pixel/ICC parity, visible priority, bounded memory, no Metal compute conversion, - and no GPU command submission for empty work. - -## DV-MAN-011 — Metal mode (macOS) - -- Run `auto` on the exact renderer Metal device with a supported resident J2K source and - an ICC-profiled source. -- Verify device identity, pitch/edge tiles, LUT proof behavior, one batch submission, - explicit CPU fallback diagnostics, and pixel parity with forced CPU. - -## DV-MAN-012 — CUDA mode (supported Linux only) - -- Build/run with `--features cuda` and the runtime-required environment from the CUDA CI. -- Verify actual CUDA selection, checked pitch-aware host download, pixel parity, exactly one - CPU retry on download failure, and explicit diagnostics. Do not claim zero-copy. - -## DV-MAN-013 — device failure/fallback - -- Where reproducible, inject Metal import mismatch, wgpu validation, device loss, and OOM. -- Verify uploads stop coherently, ready device resources are invalidated, decoded ownership - is reconciled, recovery/fallback is explicit, and diagnostics do not storm per tile. - -## Acceptance log - -No workflow has been executed under this plan yet. Hardware/fixture limitations must be -recorded here rather than converted into a pass. diff --git a/docs/refactor/MASTER_PLAN.md b/docs/refactor/MASTER_PLAN.md deleted file mode 100644 index 396d883..0000000 --- a/docs/refactor/MASTER_PLAN.md +++ /dev/null @@ -1,322 +0,0 @@ -# DICOM Viewer Refactor Master Plan - -Audit anchor: `87aa73d8cf5223ef6ba674e2ef7747453df06442` - -This is the durable execution plan for the tile-pipeline refactor. Status values are -`OPEN`, `IN PROGRESS`, `BLOCKED`, `DONE`, `SUPERSEDED`, and `NOT APPLICABLE`. -`DONE` requires named implementation, tests, and commands. Pre-existing uncommitted -work is evidence about the current tree, not work credited to this plan. - -## G0 — baseline, re-audit, characterization - -- **DV-G0-001 — DONE.** Starting `HEAD` equals the audit anchor. The branch is `main` and the large dirty worktree predates this plan. Evidence: `BASELINE.md`, `STATUS.md`; commands `git rev-parse HEAD`, `git status --short --branch`, `git diff --stat`. -- **DV-G0-002 — DONE.** Read the repository root manifest, crate manifests, toolchain file, README, architecture/release/supply-chain documents, CI and CUDA workflows, Reasonix configuration, dependency policy, and searched for all `AGENTS.md` files (none exist below the target repository). Evidence: `BASELINE.md`. -- **DV-G0-003 — DONE.** Repository-supported validation and platform matrix recorded in `BASELINE.md`. -- **DV-G0-004 — DONE.** Initial workspace compilation failed at pre-existing sibling SEG API drift. `WorkspaceRuntime::add_external_segmentation` now uses typed `vectorized_annotations(AllowLoss)`, returns its diagnostics to the caller, and UI status surfaces every blocking projection-loss code. One `frames_viewer_producer` factory supplies explicit Frames/DICOM Viewer identity and per-format series metadata to new ANN/SEG/SR/PM objects, including GeoJSON companion documents. Evidence: workspace import/export, raster, report, and annotation-probe modules; `cargo test --workspace --all-targets --locked` (346 passed, 2 ignored), Clippy with warnings denied, and the optimized workspace build. -- **DV-G0-005 — IN PROGRESS.** Hotspot inventory is recorded; extend it to every production module before closing G0. -- **DV-G0-006 — DONE.** Current end-to-end lifecycle is recorded in `TILE_STATE_MODEL.md`. -- **DV-G0-007 — DONE.** Current and target ownership diagrams are recorded in `TILE_STATE_MODEL.md`. -- **DV-G0-008 — IN PROGRESS.** Existing tests cover most queue, demand, retry, cancellation, upload, accounting, eviction, fallback, and metric behaviors. Restore compilation, run them, then fill the named gaps in `BASELINE.md`. -- **DV-G0-009 — IN PROGRESS.** Synthetic HTJ2K, DICOM metadata, edge-tile, malformed/truncated, cancellation, upload, and large-grid fixtures exist. Multi-optical-path, multi-focal-plane, concatenation, and high-coordinate geometry fixtures remain open. -- **DV-G0-010 — BLOCKED.** No real WSI benchmark fixture is committed or configured. Synthetic test evidence may proceed, but cold/warm viewer measurements require an explicitly supplied research fixture and manual UI run. -- **DV-G0-011 — DONE.** Current finding classification is recorded below and in the supporting inventories. - -G0 gate: **OPEN** until the current tree compiles, characterization tests run, the full file inventory is complete, and fixture limitations are recorded with final scope. - -## Current audit classification - -### Ownership and boundaries - -- **DV-AUD-A-001 — OPEN:** distributed lifecycle ownership. `TileStore::TileState` owns `Queued`, `Decoding`, `Decoded`, `Uploading`, `Ready`, and `Failed`; `TileLoader` independently owns queue/in-flight state; `TileRenderer` owns overlapping demand/acceptance sets; canvas builds two demand values. -- **DV-AUD-B-001 — PARTIALLY FIXED:** the dirty tree split loader decode, queue, worker mechanics, and focused tests into submodules, but `loader.rs` still coordinates all three and the authoritative state split is not established. -- **DV-AUD-B-002 — OPEN:** `store.rs` (1,674 LOC), `stats.rs` (1,542), `upload.rs` (1,389), `canvas.rs` (1,289), and `tile.rs` (960) remain tile-pipeline hotspots with mixed responsibilities. -- **DV-AUD-B-003 — OPEN:** `level_warmer.rs` (918) duplicates worker/diagnostic mechanics. -- **DV-AUD-B-004 — PARTIALLY FIXED:** root `app.rs` is 807 LOC after pre-existing pathology extraction but still owns detailed open/status/tool behavior. -- **DV-AUD-D-001 — OPEN:** cache/store paints egui directly through `TileStore::draw_ready_tile`. -- **DV-AUD-D-002 — OPEN:** uploader constructs `ViewerOpenOptions` and reads environment-backed configuration. -- **DV-AUD-D-003 — PARTIALLY FIXED:** annotation/workspace responsibilities have pre-existing splits, but `workspace.rs` is 2,089 LOC and still mixes editor state, promotion, payload ownership, and coordination. -- **DV-AUD-D-004 — PARTIALLY FIXED:** root app delegates more pathology behavior but still owns detailed interaction/status logic. -- **DV-AUD-D-005 — OPEN:** core model still combines process configuration, cache budgets, identifiers, summaries, tile/color data, and errors. -- **DV-AUD-D-006 — OPEN:** telemetry update methods call `eprintln!` directly. -- **DV-AUD-D-007 — OPEN:** canvas owns planning, warming, scheduler publication, cache protection, upload polling, and drawing. - -### Correctness and reliability - -- **DV-BUG-001 — OPEN:** loader construction records spawn failures but remains constructible and admits work even if all workers fail. -- **DV-BUG-002 — OPEN:** failed-tile retry is represented by state/booleans rather than a typed retry policy. -- **DV-BUG-003 — ALREADY FIXED:** `TileCoverage` separates ready/pending/failed/missing; failed tiles remain uncovered. Existing tests: `failed_tile_stops_loading_but_remains_uncovered`, `failed_target_draws_no_placeholder_over_the_coarser_fallback`. -- **DV-BUG-004 — PARTIALLY FIXED:** CPU retry state is cleared when a queued retry is pruned, but it is not typed by generation/failure class/attempt. -- **DV-BUG-005 — OPEN:** CPU fallback read mode is retained, but visible-priority restoration is not represented or proven as a retry invariant. -- **DV-BUG-006 — PARTIALLY FIXED:** failed batches receive individual recovery, but failure types are string-based and need model tests after ownership migration. -- **DV-BUG-007 — OPEN:** asynchronous wgpu validation is not mapped to a tile-level typed outcome. -- **DV-BUG-008 — OPEN:** device-loss recovery is explicitly listed as a known compromise. -- **DV-BUG-009 — OPEN:** GPU out-of-memory is not an explicit upload error variant. -- **DV-BUG-010 — PARTIALLY FIXED:** partial/missing/surplus upload outcomes have accounting tests, but ownership uses remove/reinsert state transitions rather than an RAII transaction. -- **DV-BUG-011 — OPEN:** open workers are not cooperatively cancellable. -- **DV-BUG-012 — PARTIALLY FIXED:** open jobs are capped at two with latest-pending replacement, but running work is detached from cancellation and not joined. -- **DV-BUG-013 — OPEN:** slide/UI conversion still narrows coordinates to `f32` before the final presentation boundary. -- **DV-BUG-014 — OPEN:** geometry tolerance policy has not been independently re-audited and centralized. -- **DV-BUG-015 — OPEN:** skipped-leading-level canonical geometry needs a regression test. -- **DV-BUG-016 — PARTIALLY FIXED:** dense frame expectation multiplies tile grid by declared optical paths and focal planes, but does not parse the actual dimension organization or per-frame groups. -- **DV-BUG-017 — OPEN:** concatenation aggregation uses per-instance counts and does not establish multi-instance dimension membership. -- **DV-BUG-018 — REGRESSED:** root app status strings include full `path.display()` values; ordinary warnings/errors may expose local paths. -- **DV-BUG-019 — PARTIALLY FIXED:** CUDA has a checked host-download route and parity workflow, but there is no local CUDA runtime validation on this macOS host. -- **DV-BUG-020 — PARTIALLY FIXED:** CPU and Metal ICC paths and LUT proof/cache tests exist; current dirty tree must compile before they can be rerun. -- **DV-BUG-021 — PARTIALLY FIXED:** CPU fallback diagnostics exist, but failure classes are strings and malformed device-path coverage is incomplete. -- **DV-BUG-022 — PARTIALLY FIXED:** queue/in-flight cancellation exists; running kernels and open/warmer work can still consume obsolete capacity. -- **DV-BUG-023 — OPEN:** result acceptance depends on `active_demand_keys`, `accepted_result_keys`, poll `relevant_tiles`, loader batch currency, and generation checks. -- **DV-BUG-024 — OPEN:** poisoned loader/warmer mutexes recover access without proving semantic state validity. -- **DV-BUG-025 — OPEN:** cache transitions and accounting depend on remove/reinsert mutation. -- **DV-BUG-026 — OPEN:** configuration is read independently in main/core/canvas/loader/uploader/stats. -- **DV-BUG-027 — OPEN:** counters are not yet proven to correspond to unique typed tile events. - -### Performance findings - -All performance items are hypotheses until `PERFORMANCE.md` contains measured evidence. - -- **DV-PERF-001 — OPEN:** frame planning rebuilds vectors. -- **DV-PERF-002 — OPEN:** frame planning sorts tile vectors. -- **DV-PERF-003 — OPEN:** frame planning clones/constructs hash sets. -- **DV-PERF-004 — OPEN:** one frame creates `FrameTileDemand`, `TilePollRequest`, multiple lane vectors, and key sets. -- **DV-PERF-005 — OPEN:** eviction repeatedly searches map candidates; benchmark required. -- **DV-PERF-006 — OPEN:** cache profiles are fixed. -- **DV-PERF-007 — ALREADY FIXED:** uploader documents/tests that empty and CPU-only batches do not create/submit a compute encoder; rerun after baseline repair. -- **DV-PERF-008 — OPEN:** Metal per-tile resource creation remains. -- **DV-PERF-009 — OPEN:** uniform/bind-group cost unmeasured. -- **DV-PERF-010 — PARTIALLY FIXED:** bounded LUT texture LRU exists; binding reuse cost remains unmeasured. -- **DV-PERF-011 — OPEN:** stale decode/level preparation can consume capacity. -- **DV-PERF-012 — OPEN:** process-wide CPU budget does not exist. -- **DV-PERF-013 — OPEN:** JP2K inner/outer concurrency can multiply. -- **DV-PERF-014 — OPEN:** compressed payload copy chain unmeasured. -- **DV-PERF-015 — OPEN:** device-side RGB8 expansion opportunity unmeasured. -- **DV-PERF-016 — OPEN:** memory calculations are duplicated and peak scope is incomplete. -- **DV-PERF-017 — OPEN:** loader heap compaction cost unmeasured. -- **DV-PERF-018 — OPEN:** repeated level searches need measurement/classification. -- **DV-PERF-019 — PARTIALLY FIXED:** disabled telemetry avoids some timing/sample work; it is not sink-separated or benchmarked. -- **DV-PERF-020 — OPEN:** handwritten JSON allocates intermediate strings. -- **DV-PERF-021 — OPEN:** DICOM index rebuild/reuse requires fixture telemetry. -- **DV-PERF-022 — OPEN:** warmer and visible scheduler do not share a process budget. -- **DV-PERF-023 — OPEN:** lane policy requires first-sharp/full-coverage A/B data. -- **DV-PERF-024 — OPEN:** decode/upload batch sizes are fixed or environment-selected without retained local evidence. - -## P1 — shared checked types and centralized configuration - -- **DV-P1-001 — OPEN:** canonical f64 slide geometry. -- **DV-P1-002 — OPEN:** one `ViewportTransform`. -- **DV-P1-003 — DONE:** `tile/memory.rs` is the checked owner for actual edge dimensions, decoded source bytes, CPU RGBA bytes, final texture bytes, temporary conversion bytes, upload peak, and in-flight reservation. Loader admission, store accounting/reconciliation, uploader validation, overview planning, and fallback estimates use it. Tests: `tile_footprint_tracks_edge_dimensions_and_every_memory_component`, `tile_footprint_rejects_zero_dimensions_out_of_range_tiles_and_overflow`, existing tile/store/upload/canvas suites. Commands: narrow red/green test, 125 tile tests, 15 canvas tests, workspace tests, clippy, release build. -- **DV-P1-004 — OPEN:** lane-indexed representation. -- **DV-P1-005 — OPEN:** immutable `DemandSnapshot`. -- **DV-P1-006 — OPEN:** shared bounded `DiagnosticCapture`. -- **DV-P1-007 — OPEN:** startup-parsed `ViewerConfig`. -- **DV-P1-008 — OPEN:** remove environment parsing from ordinary internal open flow. -- **DV-P1-009 — IN PROGRESS:** footprint edge/zero/out-of-range/overflow boundaries are covered; geometry, lane-map, demand, and config boundaries remain. - -P1 gate: **OPEN**. - -## P2 — typed telemetry and sinks - -- **DV-P2-001 — OPEN:** serde record types. -- **DV-P2-002 — OPEN:** preserve/version schema. -- **DV-P2-003 — OPEN:** one counter/sample representation. -- **DV-P2-004 — OPEN:** separate collection, aggregation, record, serialization, sink, overlay. -- **DV-P2-005 — OPEN:** `TelemetrySink`. -- **DV-P2-006 — PARTIALLY FIXED:** deleted the CPU upload `.map(register)` wrapper and its implementation-only test; the broader dead-contract audit remains open. -- **DV-P2-007 — OPEN:** disabled fast path. -- **DV-P2-008 — OPEN:** golden schema and semantic counter tests. - -P2 gate: **OPEN**. - -## P3 — presentation/cache separation - -- **DV-P3-001 — OPEN:** expose read-only ready-tile view; remove drawing from store. -- **DV-P3-002 — OPEN:** presenter/canvas painter. -- **DV-P3-003 — OPEN:** remove egui/viewport dependencies from cache. -- **DV-P3-004 — OPEN:** explicit cache touch separate from painting. -- **DV-P3-005 — OPEN:** retrieval/rect/layer-order tests. - -P3 gate: **OPEN**. - -## P4 — startup, tasks, and failure lifecycle - -- **DV-P4-001 — OPEN:** loader startup result/unavailable state. -- **DV-P4-002 — OPEN:** injectable all-workers-fail regression. -- **DV-P4-003 — OPEN:** cooperative open cancellation. -- **DV-P4-004 — OPEN:** reap open handles without UI blocking. -- **DV-P4-005 — OPEN:** typed failure/retry classes. -- **DV-P4-006 — OPEN:** exact retry transitions/limits. -- **DV-P4-007 — OPEN:** visible CPU fallback priority. -- **DV-P4-008 — ALREADY FIXED:** visual coverage and failed/pending/missing are separate; retain tests through migration. -- **DV-P4-009 — IN PROGRESS:** several named tests exist; startup/open/typed-retry gaps remain. - -P4 gate: **OPEN**. - -## P5 — authoritative tile state ownership - -- **DV-P5-001 — OPEN:** explicit pipeline event/value types. -- **DV-P5-002 — OPEN:** remove queued/decoding from cache. -- **DV-P5-003 — OPEN:** upload transaction ownership. -- **DV-P5-004 — OPEN:** coordinator-exclusive transitions. -- **DV-P5-005 — OPEN:** RAII upload reservation and decoded ownership. -- **DV-P5-006 — OPEN:** replace overlapping key sets. -- **DV-P5-007 — OPEN:** stale rejection before mutation. -- **DV-P5-008 — OPEN:** named-transition byte ledger. -- **DV-P5-009 — OPEN:** transition/model tests. -- **DV-P5-010 — OPEN:** debug assertions for ownership/ledger invariants. - -P5 gate: **OPEN**. - -## P6 — scheduler, worker pool, decode split - -- **DV-P6-001 — PARTIALLY FIXED:** queue policy was mechanically extracted in the dirty tree; ownership still overlaps cache/coordinator. -- **DV-P6-002 — PARTIALLY FIXED:** worker spawn/run functions were extracted; lifecycle owner and startup errors remain in loader. -- **DV-P6-003 — PARTIALLY FIXED:** decode/recovery functions were extracted; failure classes remain strings. -- **DV-P6-004 — OPEN:** scheduler still transports CUDA-related read modes/results indirectly. -- **DV-P6-005 — PARTIALLY FIXED:** decode module does not select lanes, but shared batch types retain policy fields. -- **DV-P6-006 — PARTIALLY FIXED:** worker module is small but operates shared loader state. -- **DV-P6-007 — OPEN:** measure heap compaction before optimization. -- **DV-P6-008 — ALREADY FIXED:** deterministic batch order has existing tests; rerun after baseline repair. -- **DV-P6-009 — IN PROGRESS:** focused queue/decode/worker tests exist; spawn-failure injection/join coverage remains. - -P6 gate: **OPEN**. - -## P7 — demand, lane policy, frame planning - -- **DV-P7-001 — OPEN:** replace both demand structs with `DemandSnapshot`. -- **DV-P7-002 — OPEN:** one `TilePipelinePolicy`. -- **DV-P7-003 — OPEN:** name execution/retention/upload orderings. -- **DV-P7-004 — PARTIALLY FIXED:** `TileFramePlan::build` is mostly pure but tied to egui/f32 types and canvas-private policy. -- **DV-P7-005 — PARTIALLY FIXED:** frame plan has most target fields but not one authoritative demand/output contract. -- **DV-P7-006 — OPEN:** split canvas responsibilities. -- **DV-P7-007 — OPEN:** cache stationary plans. -- **DV-P7-008 — OPEN:** reuse safe scratch buffers after measurement. -- **DV-P7-009 — OPEN:** indexed level lookup where justified. -- **DV-P7-010 — IN PROGRESS:** broad planning tests exist; pan/reversal/resize/determinism gaps require audit. - -P7 gate: **OPEN**. - -## P8 — cache, ledger, eviction - -- **DV-P8-001 — OPEN:** separate decoded/ready/failure/ledger/eviction responsibilities. -- **DV-P8-002 — OPEN:** indexed bounded eviction if benchmark confirms. -- **DV-P8-003 — OPEN:** prevent repeated full scans. -- **DV-P8-004 — OPEN:** one memory ledger. -- **DV-P8-005 — ALREADY FIXED:** oversized entries become terminal failures under the hard ceiling; preserve semantics in the new ledger. -- **DV-P8-006 — OPEN:** explicit safe adaptive budget policy. -- **DV-P8-007 — IN PROGRESS:** many accounting/eviction tests exist; device-loss invalidation remains. -- **DV-P8-008 — OPEN:** 100/1,000/10,000-entry cache benchmark. - -P8 gate: **OPEN**. - -## P9 — upload and wgpu/Metal errors - -- **DV-P9-001 — OPEN:** move WGSL to owned shader source. -- **DV-P9-002 — OPEN:** split CPU and Metal upload. -- **DV-P9-003 — OPEN:** split egui registration from preparation. -- **DV-P9-004 — ALREADY FIXED:** retain `RegisteredTileTexture` RAII. -- **DV-P9-005 — OPEN:** remove uploader open-options/environment ownership. -- **DV-P9-006 — OPEN:** complete typed upload error variants. -- **DV-P9-007 — OPEN:** async wgpu error scope handling. -- **DV-P9-008 — OPEN:** device-loss recovery. -- **DV-P9-009 — ALREADY FIXED:** no encoder submission for empty work; rerun tests. -- **DV-P9-010 — ALREADY FIXED:** CPU-only path avoids Metal compute submission; rerun tests. -- **DV-P9-011 — OPEN:** profile and evaluate resource reuse. -- **DV-P9-012 — PARTIALLY FIXED:** missing/surplus outcomes are reconciled, but result types do not structurally enforce one-to-one cardinality. -- **DV-P9-013 — ALREADY FIXED:** partial/missing/surplus upload tests exist; preserve through transaction refactor. - -P9 gate: **OPEN**. - -## P10 — level warmer/background workers - -- **DV-P10-001 — OPEN:** extract only shared lifecycle/diagnostic mechanics. -- **DV-P10-002 — OPEN:** shared process source/CPU budget. -- **DV-P10-003 — OPEN:** prove visible admission priority over warming. -- **DV-P10-004 — OPEN:** cancellation prevents stale warmer publication. -- **DV-P10-005 — DONE:** the reviewed test-only prefetch-lane and encoder-prediction wrappers were deleted; production frame-plan and submission-count tests preserve the behavior, and the locked workspace test suite passes. -- **DV-P10-006 — BLOCKED:** real-fixture warming benchmark unavailable. - -P10 gate: **OPEN**. - -## P11 — geometry, camera, measurement, annotation - -- **DV-P11-001 — OPEN:** f64 canonical camera/slide geometry. -- **DV-P11-002 — OPEN:** f32 only at egui/wgpu edge. -- **DV-P11-003 — OPEN:** one pan/zoom implementation. -- **DV-P11-004 — PARTIALLY FIXED:** pre-existing workspace modules split some responsibilities; the 2,089-line runtime remains overloaded. -- **DV-P11-005 — OPEN:** robust scale-aware predicates. -- **DV-P11-006 — OPEN:** coordinate convention contract. -- **DV-P11-007 — OPEN:** deterministic path-minimized export audit. -- **DV-P11-008 — PARTIALLY FIXED:** measurement moved into pathology workspace but controller/output boundary requires audit. -- **DV-P11-009 — PARTIALLY FIXED:** annotation actions/controllers exist; root orchestration boundary requires audit. -- **DV-P11-010 — PARTIALLY FIXED:** app translates some action outcomes; not yet one typed `ToolOutcome`. -- **DV-P11-011 — IN PROGRESS:** geometry tests exist in core/workspace; extreme-offset/tolerance matrix remains. - -P11 gate: **OPEN**. - -## P12 — core model, DICOM inspection, PHI - -- **DV-P12-001 — PARTIALLY FIXED:** annotations/statistics have modules; core `model.rs` remains mixed. Preserve re-exports. -- **DV-P12-002 — OPEN:** process environment remains in core. -- **DV-P12-003 — PARTIALLY FIXED:** inspection already has `dataset` and `dicom` submodules; aggregation remains monolithic. -- **DV-P12-004 — OPEN:** derive TILED_FULL counts from actual multidimensional organization. -- **DV-P12-005 — OPEN:** multidimensional/concatenation/sparse/inconsistent tests. -- **DV-P12-006 — ALREADY FIXED:** bounded metadata preflight exists; retain it. -- **DV-P12-007 — OPEN:** skipped leading level geometry audit. -- **DV-P12-008 — OPEN:** skipped-leading-level regression. -- **DV-P12-009 — REGRESSED:** full local paths appear in ordinary app status and some errors/warnings. - -P12 gate: **OPEN**. - -## P13 — central CPU execution budget - -- **DV-P13-001 — OPEN:** concurrency inventory. -- **DV-P13-002 — OPEN:** determine JP2K thread scope from current upstream API. -- **DV-P13-003 — OPEN:** one execution-budget type. -- **DV-P13-004 — OPEN:** bound multiplication. -- **DV-P13-005 — OPEN:** explicit benchmark overrides. -- **DV-P13-006 — BLOCKED:** CUDA/runtime and real-fixture thread matrix unavailable locally; CPU/Metal subset can proceed later. -- **DV-P13-007 — OPEN:** choose defaults only from retained evidence. - -P13 gate: **OPEN**. - -## PERF — measured experiments - -- **DV-PERF-EXP-001 — OPEN:** frame-plan caching. -- **DV-PERF-EXP-002 — OPEN:** allocation reduction. -- **DV-PERF-EXP-003 — OPEN:** indexed eviction. -- **DV-PERF-EXP-004 — OPEN:** dynamic memory policy. -- **DV-PERF-EXP-005 — BLOCKED:** decoder batch-size matrix needs fixture. -- **DV-PERF-EXP-006 — BLOCKED:** queue policy A/B needs fixture and UI telemetry. -- **DV-PERF-EXP-007 — BLOCKED:** stale-work cancellation measurement needs fixture. -- **DV-PERF-EXP-008 — OPEN:** CPU upload instrumentation/parity. -- **DV-PERF-EXP-009 — BLOCKED:** Metal upload profiling needs suitable fixture/run. -- **DV-PERF-EXP-010 — OPEN:** GPU submission batching instrumentation. -- **DV-PERF-EXP-011 — BLOCKED:** warming A/B needs fixture. -- **DV-PERF-EXP-012 — BLOCKED:** DICOM index reuse needs DICOM WSI fixture. -- **DV-PERF-EXP-013 — BLOCKED:** compressed-copy chain requires upstream/runtime profiling. -- **DV-PERF-EXP-014 — OPEN:** level lookup/indexing. -- **DV-PERF-EXP-015 — OPEN:** telemetry overhead. -- **DV-PERF-EXP-016 — BLOCKED:** CUDA runtime experiment unavailable on macOS. -- **DV-PERF-EXP-017 — BLOCKED:** external frame-time capture not yet configured. - -PERF gate: **OPEN**. - -## P14 — final cleanup - -- **DV-P14-001 — OPEN:** root app becomes orchestration only. -- **DV-P14-002 — PARTIALLY FIXED:** the CPU upload pass-through wrapper was deleted and duplicate same-file target detection was consolidated; the final whole-tree audit remains open. -- **DV-P14-003 — OPEN:** classify ownership copies/allocations. -- **DV-P14-004 — OPEN:** comment audit. -- **DV-P14-005 — OPEN:** final dependency-direction audit. -- **DV-P14-006 — OPEN:** public API/environment audit. -- **DV-P14-007 — PARTIALLY FIXED:** three tests coupled only to deleted micro-wrappers were removed while production behavior tests were retained; the final test audit remains open. -- **DV-P14-008 — OPEN:** remove transitional adapters with deletion gates. - -P14 gate: **OPEN**. - -## Final independent re-audit and completion - -- **DV-FINAL-001 — OPEN:** independently re-audit all 25 categories from the assignment without relying on this plan's status marks. -- **DV-FINAL-002 — OPEN:** run the exact valid local quality matrix and record unavailable hardware/platform gates. -- **DV-FINAL-003 — OPEN:** execute all available manual workflows in `MANUAL_ACCEPTANCE.md`. -- **DV-FINAL-004 — OPEN:** verify every definition-of-done item with code, tests, commands, and measurements. -- **DV-FINAL-005 — OPEN:** produce the required precise final implementation report and state that no remote mutation occurred. diff --git a/docs/refactor/PERFORMANCE.md b/docs/refactor/PERFORMANCE.md deleted file mode 100644 index 0db8a26..0000000 --- a/docs/refactor/PERFORMANCE.md +++ /dev/null @@ -1,85 +0,0 @@ -# Performance Evidence - -No performance gain is claimed in this document without a reproducible before/after -record and parity evidence. - -## Environment - -- Date: 2026-08-20 -- Host: Apple M4 Pro, 12 CPU cores, 16 GPU cores, 48 GiB RAM -- OS: macOS 26.5.2 (25F84) -- Rust: 1.96.0; release profile uses fat LTO, one codegen unit, symbols stripped, opt-level 3 -- Viewer HEAD: `87aa73d8cf5223ef6ba674e2ef7747453df06442` plus a large pre-existing dirty worktree -- Available runtime backends: CPU and Metal-capable host; CUDA unavailable -- Real benchmark fixture: none configured - -## Required experiment record - -Every experiment must record: - -1. ID and hypothesis. -2. Affected code and exact diff/checkpoint. -3. Fixture path label and SHA-256 without exposing PHI-bearing paths in public telemetry. -4. Source format/workload and backend/feature. -5. Exact command, hardware/OS/toolchain, release profile. -6. Warmup and sample count. -7. Baseline p50/p95/p99/max or other declared statistic. -8. After result and delta. -9. Pixel/state/telemetry parity evidence. -10. Peak RSS/resident/pinned/GPU submission effect. -11. Retain or reject decision and known cliffs. - -## Benchmark definitions - -- **DV-BENCH-001 cold open:** fresh process and independent study; does not claim OS page-cache eviction unless externally controlled. -- **DV-BENCH-002 warm open:** repeated independent study open with warm filesystem caches. -- **DV-BENCH-003 stationary frame plan:** identical study/viewport/camera/policy/cache generation, repeated plan construction. -- **DV-BENCH-004 cache eviction:** deterministic ready/decoded mixes at 100, 1,000, and 10,000 entries with equal protected sets and checksums. -- **DV-BENCH-005 interaction:** initial fit, one zoom, continuous zoom, rapid reversal, pan while incomplete, resize, revisit, memory pressure. -- **DV-BENCH-006 upload:** equal CPU or Metal inputs, identical texture bytes and registration results, recording encoder/submission counts. -- **DV-BENCH-007 telemetry:** disabled, window summaries, interaction summaries, full debug using the same interaction trace. -- **DV-BENCH-008 thread matrix:** outer workers 1/2/4 × inner JP2K 1/2/4/bounded-auto where the upstream API truly supports it. - -## Baseline data - -No end-to-end performance baseline exists yet. The initial -`cargo test --workspace --all-targets --locked` attempt failed during compilation after -19.1 seconds; the compatibility issue was subsequently repaired and the workspace tests -passed. Neither build/test duration is a viewer performance result. - -Repository `tile_probe` is available for source API timing only. Its “study-cold” batch -does not flush the operating-system cache and must not be reported as cold application -open or end-to-end frame latency. - -`docs/PATHOLOGY_PERFORMANCE.md` contains a 2026-08-15 CPU-side overlay -characterization. Its exact commit/worktree identity, raw samples, parity artifact, and -peak-memory result were not retained, so it is classified as historical sizing evidence -rather than a reproducible baseline under this program. It must be rerun before it is -used to justify a new optimization or production-limit change. - -## Candidate experiments - -| ID | Candidate | Status | Current evidence / next gate | -| --- | --- | --- | --- | -| DV-PERF-EXP-001 | frame-plan caching | OPEN | add deterministic stationary benchmark after `DemandSnapshot`/pure plan | -| DV-PERF-EXP-002 | scratch-buffer/allocation reuse | OPEN | profile allocation sites; no unsafe pools | -| DV-PERF-EXP-003 | indexed eviction | OPEN | compare against current exact behavior at 100/1k/10k | -| DV-PERF-EXP-004 | adaptive memory policy | OPEN | retain hard ceiling and explicit profiles | -| DV-PERF-EXP-005 | decoder batch matrix | BLOCKED | requires representative fixture | -| DV-PERF-EXP-006 | queue lane A/B | BLOCKED | requires interaction telemetry fixture | -| DV-PERF-EXP-007 | earlier stale cancellation | BLOCKED | requires interaction trace/fixture | -| DV-PERF-EXP-008 | CPU upload path | OPEN | instrument equal outputs and encoder/submission count | -| DV-PERF-EXP-009 | Metal resource reuse | BLOCKED | profile before adding rings/pools/caches | -| DV-PERF-EXP-010 | upload submission batching | OPEN | current code claims one Metal encoder/batch; retain counter evidence | -| DV-PERF-EXP-011 | level warming | BLOCKED | compare disabled/current/lower concurrency with fixture | -| DV-PERF-EXP-012 | DICOM index reuse | BLOCKED | needs DICOM WSI fixture; avoid duplicate viewer-local cache | -| DV-PERF-EXP-013 | compressed payload copies | BLOCKED | trace source through wsi-rs/J2K/device; no unsafe viewer shortcut | -| DV-PERF-EXP-014 | level lookup index | OPEN | first count/measure repeated scans | -| DV-PERF-EXP-015 | typed telemetry overhead | OPEN | benchmark disabled/window/interaction/debug modes | -| DV-PERF-EXP-016 | CUDA host-output path | BLOCKED | macOS host has no CUDA runtime | -| DV-PERF-EXP-017 | external frame-time validation | BLOCKED | capture tooling and acceptance limits not configured | - -## Retained and rejected experiments - -None yet. Pre-existing implementation choices are not reclassified as measured gains -until reproduced under this document's protocol. diff --git a/docs/refactor/STATUS.md b/docs/refactor/STATUS.md deleted file mode 100644 index 7828855..0000000 --- a/docs/refactor/STATUS.md +++ /dev/null @@ -1,27 +0,0 @@ -# Refactor Status - -- **Audit anchor:** `87aa73d8cf5223ef6ba674e2ef7747453df06442` -- **Current HEAD:** `87aa73d8cf5223ef6ba674e2ef7747453df06442` -- **Current branch:** `main` (`main...origin/main`) -- **Working-tree state:** dirty before this plan and still dirty; all pre-existing pathology/workspace/supply-chain/loader-split changes are preserved. The latest checkpoint closes the sibling annotation API migration without changing the active tile-refactor scope. -- **Active phase:** P1 shared checked types and centralized configuration -- **Active task ID:** `DV-P1-004` -- **Exact viewer files modified by this checkpoint:** `crates/dicom-viewer-core/src/{annotations/mod.rs,annotations/workspace/export.rs,annotations/workspace/compatibility.rs,lib.rs,workspace_export_tests.rs}`; `apps/dicom-viewer/src/app/{annotation_actions.rs,workspace.rs,workspace/tests.rs,workspace_actions.rs,raster/io.rs,raster/tests.rs}`; `apps/dicom-viewer/src/bin/annotation_probe/{convert_geojson.rs,convert_raster.rs}`; `docs/DICOM_NATIVE_CONVERSION.md`; `docs/refactor/{MASTER_PLAN.md,STATUS.md}`. The sibling annotations crate additionally changed its pathology document bundle, tests, and durable plan. -- **Current invariant being established:** `DV-INV-027`: every tile-memory consumer uses one checked footprint for edge dimensions, decoded/texture/temporary/peak/reservation bytes; no caller hand-computes RGBA bytes. -- **Completed task IDs since last checkpoint:** `DV-G0-001`, `DV-G0-002`, `DV-G0-003`, `DV-G0-004`, `DV-G0-006`, `DV-G0-007`, `DV-G0-011`, `DV-P1-003`, `DV-P10-005`, `DV-DUP-008` through `DV-DUP-012` -- **Tests currently green:** `git diff --check`; formatting; locked workspace clippy with warnings denied; locked workspace tests with 346 passed and 2 documented ignores; focused viewer tests with 234 passed and 1 documented ignore. Earlier checkpoint evidence for the release build, cargo machete, cargo audit, and cargo deny remains valid. -- **Tests currently failing and why:** none in the executed local matrix. The two ignored tests require a local WSI fixture or an optimized manual pathology characterization. -- **Current backend under test:** CPU and macOS/Metal compile surface; no runtime WSI fixture. CUDA runtime unavailable on this host. -- **Current benchmark fixture:** none configured. Repository tests generate synthetic DICOM/HTJ2K fixtures; `DICOM_VIEWER_WSI_FIXTURE` is unset. -- **Last measured result:** no viewer performance claim. The post-migration release build completed in 2m29s; this is build timing only. No real fixture is configured. -- **Next three function-level actions:** (1) add red tests for exhaustive `QueueLane` iteration/indexing and a fixed `LaneMap`; (2) replace repeated visible/transition/fallback/overview/prefetch counter fields in the narrowest scheduler-stat boundary without changing JSON schema; (3) rerun lane/telemetry goldens and workspace clippy/tests before expanding the migration. -- **Known blockers:** real WSI performance fixture absent; CUDA runtime unavailable; Windows/Linux runtime unavailable; manual UI acceptance not yet run. -- **Decisions that must survive compaction:** preserve all pre-existing dirty changes; do not reset/clean/commit/publish; use `/Users/user/Bench/frames/dicom-viewer` as the single LSP root; keep unsafe Metal code inside `crates/metal-wgpu-interop`; `AllowLoss` preserves the former read-only SEG overlay projection and every blocking diagnostic code is surfaced in UI status; all newly created ANN/SEG/SR/PM objects use `frames_viewer_producer` while imported rewrites retain imported identity; `TileFootprint` is the only tile-memory arithmetic owner; `eframe/persistence` remains required because `RevisionStore::for_application` calls its gated `eframe::storage_dir`; do not credit pre-existing loader extraction as newly implemented work. -- **Last `git status --short`:** 36 tracked paths appear modified/deleted relative to the anchor and 92 individual files are untracked; all pre-existing entries remain preserved. -- **Last `git diff --stat`:** 36 tracked files changed, 3,002 insertions, 3,591 deletions. Untracked files, including the annotation/workspace modules and durable documents, are not included. -- **Date/time of update:** 2026-08-21 23:48:39 EDT - -## Active phase reread checklist - -Before the next production edit, reread `MASTER_PLAN.md`, this file, -`INVARIANTS.md`, and `TILE_STATE_MODEL.md`, then rerun the narrow failing command. diff --git a/docs/refactor/TILE_STATE_MODEL.md b/docs/refactor/TILE_STATE_MODEL.md deleted file mode 100644 index e75b5ea..0000000 --- a/docs/refactor/TILE_STATE_MODEL.md +++ /dev/null @@ -1,151 +0,0 @@ -# Tile State Model - -## Current ownership model - -```text -SlideCanvas.paint - builds TileFramePlan - builds cache_relevant/pinned/overview key sets - builds FrameTileDemand ------------------------------+ - builds TilePollRequest --------------------------+ | - | v -TileRenderer | canonicalize/cap demand - owns demand_epoch | active_demand_keys - owns active_demand_keys | accepted_result_keys - owns accepted_result_keys | loader batch currency - delegates cache state and upload | - | | - v | -TileStore | - missing -> Queued -> Decoding -> Decoded -> Uploading -> Ready - | | | - +----------> Failed <---+ - owns resident/pinned bytes, eviction, retry booleans, - coverage, texture registration lifetime, and egui drawing - -TileLoader - independently owns queued heap/canonical entries, in-flight batches, - cancellation tokens, worker availability, decoded reservations, results - | - v -decode module -> ViewerStudy/wsi-rs source reads -> per-tile recovery/results -``` - -The same tile therefore has scheduler states in both loader and store. Acceptance uses -several overlapping key sets and identities. Upload ownership is temporarily represented -as a long-lived store state and reconciled through remove/reinsert mutation. - -## Target ownership model - -```text -Viewport + study summary + policy + read-only pipeline snapshot - | - v - TileFramePlan (pure) - | - v - DemandSnapshot (immutable) - | - v -TileCoordinator --------------------------------------------------+ - sole transition owner | - validates generation/source/demand identity | - exposes read-only FrameTileStatus | - | admission/results | cache commit | upload job/outcome - v v v -TileScheduler TileCache TileUploader - Queued Decoded CPU prepare/write - InFlight Ready Metal import/convert - cancellation Failed wgpu validation - priority/fairness MemoryLedger texture registration - batch/worker capacity bounded eviction typed device errors - | ^ | - v | | -DecodeWorker ---------------- DecodeBatchResult ------------------+ - source read, order/cardinality, recovery, cancellation, diagnostics - -TilePresenter - reads ReadyTileView + ViewportTransform - owns rects, clipping, paint calls, target/fallback composition - never mutates lifecycle or accounting (explicit cache touch is separate) - -Telemetry - observes typed events/snapshots -> collector -> record -> sink/overlay - cannot change scheduling behavior -``` - -## Authoritative owners - -| State/resource | Sole owner | Allowed operations | -| --- | --- | --- | -| Missing | coordinator-derived absence | admit or report missing | -| Queued | scheduler | deduplicate, reprioritize, cancel, dispatch | -| InFlight | scheduler | hold batch identity, cancellation, decoded-byte permit, worker slot | -| Decode execution | decode worker | source call, cardinality/order validation, per-tile recovery, diagnostics | -| Decoded | cache, committed only by coordinator | retain/evict, lend to upload transaction | -| Uploading | short-lived `UploadTransaction`, coordinated by coordinator | own decoded tile and peak reservation; commit/rollback exactly once | -| Ready | ready cache | texture lifetime, byte ledger, read-only view, explicit touch | -| Failed | failure cache/coordinator domain | typed class/attempt/terminal metadata; never visual coverage | -| Presentation | presenter | read ready views and paint; no lifecycle mutation | -| Metrics | telemetry collector | observe immutable events/snapshots only | - -## Events and transitions - -```text -DemandPublished(snapshot) - Missing -> Queued coordinator asks scheduler to admit -WorkerDispatched(batch) - Queued -> InFlight scheduler only -DecodeSucceeded(result) - InFlight -> Decoded coordinator validates then commits -DecodeCancelled/Obsolete(result) - InFlight -> Missing/Removed no cache mutation -DecodeFailed(result) - InFlight -> Queued typed retry admitted by coordinator - InFlight -> Failed terminal/exhausted outcome -UploadStarted(job) - Decoded -> UploadTransaction transaction owns input + reservation -UploadSucceeded(outcome) - UploadTransaction -> Ready atomic commit -UploadRetryableFailure(outcome) - UploadTransaction -> Decoded rollback, preserving bytes/input -UploadCpuFallback(outcome) - UploadTransaction -> Queued explicit CPU route, visible priority preserved -UploadTerminalFailure(outcome) - UploadTransaction -> Failed reservation/input reconciled once -DemandSuperseded(snapshot) - obsolete Queued/InFlight -> cancelled scheduler - stale results -> rejected coordinator before cache mutation -DeviceLost - stop GPU admission; invalidate Ready GPU textures; preserve eligible Decoded data; - recreate resources or enter explicit CPU/unavailable state; suppress per-tile storms -``` - -## Cancellation and acceptance - -- Demand identity comprises source/study generation, demand epoch/fingerprint, and the - immutable lane-indexed keys/protection set. -- Scheduler cancellation stops queued work immediately and signals in-flight batches. -- Decode checks cancellation before source work, between recoverable elements, after - source return, and before publication. Running codec kernels may remain non-preemptive. -- Coordinator rejects obsolete source, generation, demand, batch, or key identities before - any cache transition or byte-ledger mutation. -- Cancelled work is not a failure and does not consume retry count. - -## Retry and fallback - -- Retry identity includes generation, original route, typed failure class, and attempt. -- Batch failure is isolated into ordered individual attempts only when the decoder supports it. -- CUDA download is explicit; at most one checked CPU retry is permitted. -- Metal import/validation/device failure either rolls back decoded ownership or admits a - typed CPU retry. A visible tile retains visible priority. -- Permanent corrupt/unsupported input becomes `Failed`; it does not count as visual coverage. - -## Memory ownership - -- `TileFootprint` is the checked value source for edge dimensions, decoded bytes, texture - bytes, conversion temporary bytes, upload peak, and in-flight reservation. -- Scheduler owns in-flight decoded reservations. -- Cache ledger owns decoded/ready resident bytes and pinned subset. -- Upload transaction owns its peak reservation and decoded input until commit/rollback. -- Eviction never sees half-transitioned accounting and cannot evict an active transaction. diff --git a/reasonix.toml b/reasonix.toml deleted file mode 100644 index 739fa9f..0000000 --- a/reasonix.toml +++ /dev/null @@ -1,82 +0,0 @@ -# Reasonix configuration. -# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults. -# Secrets come from the environment via api_key_env; never put keys here. - -config_version = 2 # schema marker for diagnostics; old versions may ignore it -default_model = "deepseek-flash" -# language = "zh" # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG - -[agent] -# system_prompt = """...""" # omit to use the built-in prompt for this version -# system_prompt_file = "prompts/system.md" # overrides system_prompt when set -max_steps = 0 # executor tool-call rounds; 0 = no limit -planner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit -temperature = 0.0 -auto_plan = "off" # off|on; off keeps plan mode manual -# auto_plan_classifier = "deepseek-flash" # optional; only used for borderline tasks -soft_compact_ratio = 0.5 # notice only; keeps cache-first prefix intact -compact_ratio = 0.8 # try compacting when prompt reaches this fraction -compact_force_ratio = 0.9 # force compacting at this high-water mark -# planner_model = "mimo" # optional: enable two-model collaboration -# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills -# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } # per-skill overrides -# subagent_effort = "high" # optional default effort for subagents -# subagent_efforts = { review = "max", task = "high" } # per-tool/skill effort overrides -# output_style = "explanatory" # explanatory | learning | concise | custom; empty = default - -[tools] -enabled = [] # empty = all built-in tools -bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap - -[codegraph] -enabled = true # built-in MCP server; off by default for first-run sessions -auto_install = true # fetch the runtime when CodeGraph is enabled but missing -# path = "" # empty = cache, then PATH, then a bundle beside reasonix - -[lsp] -enabled = true # language server tools; servers launch lazily when used -# [lsp.servers.go] -# command = "gopls" -# args = [] -# extensions = [".go"] - -[skills] -# paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots -# excluded_paths = ["~/.agents/skills"] # hide convention roots without deleting folders -# max_depth = 3 # nested scan depth; set 1 for legacy root-only discovery -# disabled_skills = ["review"] # hide noisy or unwanted skills - -[permissions] -# Per-call gating. mode = writer fallback when no rule matches: ask|allow|deny. -# Readers always default to allow. Precedence: deny > ask > allow > fallback. -# Rules are "Tool" or "Tool(specifier)"; e.g. Bash(go test:*), Edit(src/**). -mode = "ask" -# deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode -allow = ["explore", "Bash(ls -la ../wsi-rs/ 2>/dev/null && echo \"--- wsi-rs exists ---\" || echo \"--- wsi-rs NOT found ---\"; ls -la ../j2k/ 2>/dev/null && echo \"--- j2k exists ---\" || echo \"--- j2k NOT found ---\")", "Edit(apps/dicom-viewer/src/app.rs)", "Bash(cd /Users/user/Bench/frames/dicom-viewer && cargo clippy 2>&1)"] -# ask = ["Edit(src/**)"] # force a prompt even if otherwise allowed - -[sandbox] -# Confine tool blast radius. File-writers (write_file/edit_file/multi_edit) -# may only write under workspace_root (empty = current dir) + allow_write. -# bash = "enforce" (default) jails each command in an OS sandbox (macOS now; -# graceful fallback elsewhere); "off" disables it. network allows egress. -# workspace_root = "" # default: current working directory -# allow_write = ["/tmp"] # extra dirs writers may also modify -bash = "enforce" -network = true - -[statusline] -# A custom status line: a command whose first stdout line replaces the built-in -# data row. It receives {"model","contextUsed","contextWindow","cwd"} as JSON on stdin. -# command = "my-statusline.sh" - -# External MCP servers. type: "stdio" (default, a subprocess) | "http" | "sse". -# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers. -# [[plugins]] -# name = "example" -# command = "reasonix-plugin-example" -# [[plugins]] # a remote server over Streamable HTTP -# name = "stripe" -# type = "http" -# url = "https://mcp.stripe.com" -# headers = { Authorization = "Bearer ${STRIPE_KEY}" } diff --git a/vendor/epaint/Cargo.toml b/vendor/epaint/Cargo.toml new file mode 100644 index 0000000..befdbca --- /dev/null +++ b/vendor/epaint/Cargo.toml @@ -0,0 +1,407 @@ +# Vendored from the crates.io `epaint` 0.34.3 manifest and maintained locally. +# See PATCHES.md for the Windows-only changes. + +[package] +edition = "2024" +rust-version = "1.92" +name = "epaint" +version = "0.34.3" +authors = ["Emil Ernerfeldt "] +build = false +include = [ + "../../LICENSE-APACHE", + "../../LICENSE-MIT", + "**/*.rs", + "Cargo.toml", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Minimal 2D graphics library for GUI work" +homepage = "https://github.com/emilk/egui/tree/main/crates/epaint" +readme = "README.md" +keywords = [ + "graphics", + "gui", + "egui", +] +categories = [ + "graphics", + "gui", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/emilk/egui/tree/main/crates/epaint" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--generate-link-to-definition"] + +[features] +_override_unity = [] +bytemuck = [ + "dep:bytemuck", + "emath/bytemuck", + "ecolor/bytemuck", +] +cint = ["ecolor/cint"] +color-hex = ["ecolor/color-hex"] +default = ["default_fonts"] +default_fonts = ["epaint_default_fonts"] +mint = ["emath/mint"] +rayon = ["dep:rayon"] +serde = [ + "dep:serde", + "ahash/serde", + "emath/serde", + "ecolor/serde", + "font-types/serde", + "smallvec/serde", +] +unity = [] + +[lib] +name = "epaint" +path = "src/lib.rs" + +[[bench]] +name = "benchmark" +path = "benches/benchmark.rs" +harness = false + +[dependencies.ahash] +version = "0.8.12" +features = [ + "no-rng", + "std", +] +default-features = false + +[dependencies.bytemuck] +version = "1.24.0" +features = ["derive"] +optional = true + +[dependencies.document-features] +version = "0.2.11" +optional = true + +[dependencies.ecolor] +version = "0.34.3" +default-features = false + +[dependencies.emath] +version = "0.34.3" +default-features = false + +[dependencies.epaint_default_fonts] +version = "0.34.3" +optional = true + +[dependencies.font-types] +version = "0.11.0" +features = ["std"] +default-features = false + +[dependencies.log] +version = "0.4.28" +features = ["std"] + +[dependencies.nohash-hasher] +version = "0.2.0" + +[dependencies.parking_lot] +version = "0.12.5" + +[dependencies.profiling] +version = "1.0.17" +default-features = false + +[dependencies.rayon] +version = "1.11.0" +optional = true + +[dependencies.self_cell] +version = "1.2.1" + +[dependencies.serde] +version = "1.0.228" +features = [ + "derive", + "derive", + "rc", +] +optional = true + +[dependencies.skrifa] +version = "0.40.0" +features = [ + "std", + "autohint_shaping", +] +default-features = false + +[dependencies.smallvec] +version = "1.15.1" + +[dependencies.vello_cpu] +version = "0.0.6" +features = [ + "std", + "u8_pipeline", + "f32_pipeline", +] +default-features = false + +[target.'cfg(target_os = "windows")'.dependencies.dwrote] +version = "0.11.5" +default-features = false + +[target.'cfg(target_os = "windows")'.dependencies.winapi] +version = "0.3.9" +features = [ + "dwrite", + "dwrite_1", + "dwrite_2", + "unknwnbase", + "winerror", +] + +[target.'cfg(target_os = "windows")'.dependencies.wio] +version = "0.2.2" + +[dev-dependencies.criterion] +version = "0.7.0" +default-features = false + +[dev-dependencies.mimalloc] +version = "0.1.48" + +[dev-dependencies.similar-asserts] +version = "1.7.0" + +[lints.clippy] +allow_attributes = "warn" +as_ptr_cast_mut = "warn" +assigning_clones = "allow" +await_holding_lock = "warn" +bool_to_int_with_if = "warn" +branches_sharing_code = "warn" +cast_possible_wrap = "allow" +char_lit_as_u8 = "warn" +checked_conversions = "warn" +clear_with_drain = "warn" +clone_on_ref_ptr = "warn" +cloned_instead_of_copied = "warn" +coerce_container_to_any = "warn" +comparison_chain = "allow" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_union_representation = "warn" +derive_partial_eq_without_eq = "warn" +disallowed_macros = "warn" +disallowed_methods = "warn" +disallowed_names = "warn" +disallowed_script_idents = "warn" +disallowed_types = "warn" +doc_broken_link = "warn" +doc_comment_double_space_linebreaks = "warn" +doc_include_without_cfg = "warn" +doc_link_with_quotes = "warn" +doc_markdown = "warn" +elidable_lifetime_names = "warn" +empty_enums = "warn" +empty_enum_variants_with_brackets = "warn" +empty_line_after_outer_attr = "warn" +enum_glob_use = "warn" +equatable_if_let = "warn" +exit = "warn" +expl_impl_clone_on_copy = "warn" +explicit_deref_methods = "warn" +explicit_into_iter_loop = "warn" +explicit_iter_loop = "warn" +fallible_impl_from = "warn" +filter_map_next = "warn" +flat_map_option = "warn" +float_cmp_const = "warn" +fn_params_excessive_bools = "warn" +fn_to_numeric_cast_any = "warn" +from_iter_instead_of_collect = "warn" +get_unwrap = "warn" +if_let_mutex = "warn" +ignore_without_reason = "warn" +implicit_clone = "warn" +implied_bounds_in_impls = "warn" +imprecise_flops = "warn" +inconsistent_struct_constructor = "warn" +index_refutable_slice = "warn" +inefficient_to_string = "warn" +infinite_loop = "warn" +into_iter_without_iter = "warn" +invalid_upcast_comparisons = "warn" +ip_constant = "warn" +iter_filter_is_ok = "warn" +iter_filter_is_some = "warn" +iter_not_returning_iterator = "warn" +iter_on_empty_collections = "warn" +iter_on_single_items = "warn" +iter_over_hash_type = "warn" +iter_without_into_iter = "warn" +large_digit_groups = "warn" +large_futures = "warn" +large_include_file = "warn" +large_stack_arrays = "warn" +large_stack_frames = "warn" +large_types_passed_by_value = "warn" +let_underscore_must_use = "allow" +let_underscore_untyped = "allow" +let_unit_value = "warn" +linkedlist = "warn" +literal_string_with_formatting_args = "warn" +lossy_float_literal = "warn" +macro_use_imports = "warn" +manual_assert = "warn" +manual_clamp = "warn" +manual_instant_elapsed = "warn" +manual_is_power_of_two = "warn" +manual_is_variant_and = "warn" +manual_let_else = "warn" +manual_midpoint = "warn" +manual_ok_or = "warn" +manual_range_contains = "allow" +manual_string_new = "warn" +map_err_ignore = "warn" +map_flatten = "warn" +map_unwrap_or = "allow" +match_bool = "warn" +match_same_arms = "warn" +match_wild_err_arm = "warn" +match_wildcard_for_single_variants = "warn" +mem_forget = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_enforced_import_renames = "warn" +missing_errors_doc = "warn" +missing_safety_doc = "warn" +mixed_attributes_style = "warn" +mut_mut = "warn" +mutex_integer = "warn" +needless_borrow = "warn" +needless_continue = "warn" +needless_for_each = "warn" +needless_pass_by_ref_mut = "warn" +needless_pass_by_value = "warn" +negative_feature_names = "warn" +non_std_lazy_statics = "warn" +non_zero_suggestions = "warn" +nonstandard_macro_braces = "warn" +option_as_ref_cloned = "warn" +option_option = "warn" +or_fun_call = "warn" +path_buf_push_overwrite = "warn" +pathbuf_init_then_push = "warn" +precedence_bits = "warn" +print_stderr = "warn" +print_stdout = "warn" +ptr_as_ptr = "warn" +ptr_cast_constness = "warn" +pub_underscore_fields = "warn" +pub_without_shorthand = "warn" +rc_mutex = "warn" +readonly_write_lock = "warn" +redundant_type_annotations = "warn" +ref_as_ptr = "warn" +ref_option_ref = "warn" +ref_patterns = "warn" +rest_pat_in_fully_bound_structs = "warn" +return_and_then = "warn" +same_functions_in_if_condition = "warn" +self_named_module_files = "allow" +self_only_used_in_recursion = "warn" +semicolon_if_nothing_returned = "warn" +set_contains_or_insert = "warn" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" +single_char_pattern = "warn" +single_match_else = "warn" +single_option_map = "warn" +str_split_at_newline = "warn" +str_to_string = "warn" +string_add = "warn" +string_add_assign = "warn" +string_lit_as_bytes = "warn" +string_lit_chars_any = "warn" +suspicious_command_arg_space = "warn" +suspicious_xor_used_as_pow = "warn" +todo = "warn" +too_long_first_doc_paragraph = "warn" +too_many_lines = "allow" +trailing_empty_array = "warn" +trait_duplication_in_bounds = "warn" +transmute_ptr_to_ptr = "warn" +tuple_array_conversions = "warn" +unchecked_time_subtraction = "warn" +undocumented_unsafe_blocks = "warn" +unimplemented = "warn" +uninhabited_references = "warn" +uninlined_format_args = "warn" +unnecessary_box_returns = "warn" +unnecessary_debug_formatting = "warn" +unnecessary_literal_bound = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unnecessary_self_imports = "warn" +unnecessary_semicolon = "warn" +unnecessary_struct_initialization = "warn" +unnecessary_wraps = "warn" +unnested_or_patterns = "warn" +unused_async = "warn" +unused_peekable = "warn" +unused_rounding = "warn" +unused_self = "warn" +unused_trait_names = "warn" +unwrap_used = "warn" +use_self = "warn" +useless_let_if_seq = "warn" +useless_transmute = "warn" +verbose_file_reads = "warn" +wildcard_dependencies = "warn" +wildcard_imports = "allow" +zero_sized_map_values = "warn" + +[lints.clippy.all] +level = "warn" +priority = -1 + +[lints.rust] +elided_lifetimes_in_paths = "warn" +rust_2021_prelude_collisions = "warn" +semicolon_in_expressions_from_macros = "warn" +trivial_casts = "allow" +trivial_numeric_casts = "warn" +unexpected_cfgs = "warn" +unsafe_code = "deny" +unsafe_op_in_unsafe_fn = "warn" +unused_extern_crates = "warn" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_qualifications = "allow" + +[lints.rust.future_incompatible] +level = "warn" +priority = -1 + +[lints.rust.nonstandard_style] +level = "warn" +priority = -1 + +[lints.rust.rust_2018_idioms] +level = "warn" +priority = -1 + +[lints.rustdoc] +all = "warn" +broken_intra_doc_links = "warn" +missing_crate_level_docs = "warn" diff --git a/vendor/epaint/LICENSE b/vendor/epaint/LICENSE new file mode 100644 index 0000000..9576e47 --- /dev/null +++ b/vendor/epaint/LICENSE @@ -0,0 +1,5 @@ +This vendored copy of epaint 0.34.3 is used under the Apache License, +Version 2.0. The full license text is available at ../../LICENSE-APACHE. + +Upstream project: https://github.com/emilk/egui +Upstream package license: MIT OR Apache-2.0 diff --git a/vendor/epaint/PATCHES.md b/vendor/epaint/PATCHES.md new file mode 100644 index 0000000..8614f3c --- /dev/null +++ b/vendor/epaint/PATCHES.md @@ -0,0 +1,23 @@ +# Local epaint patch + +This directory is the crates.io source for `epaint` 0.34.3, pinned because the +public API does not provide a glyph-rasterizer hook. + +The local change is intentionally limited to text rasterization on Windows: + +- `src/text/windows_directwrite.rs` rasterizes individual glyphs with + DirectWrite's grayscale alpha texture API and keeps a bounded thread-local + font-face cache. +- `src/text/font.rs` places those alpha masks in the existing epaint atlas, + preserving epaint's layout, clipping, caching, and wgpu rendering. A failed + DirectWrite call is reported once per font and falls back to the unchanged + portable rasterizer. +- `src/text/mod.rs` exposes the active rasterizer name for the viewer's + packaging regression test. + +Non-Windows builds compile the original skrifa/vello path. When updating egui, +compare these three files with the matching upstream `epaint` release before +moving or dropping the patch. + +Upstream license: MIT OR Apache-2.0. The Windows-only `dwrote` dependency is +MPL-2.0 and is used unmodified. diff --git a/vendor/epaint/README.md b/vendor/epaint/README.md new file mode 100644 index 0000000..fb5d9c1 --- /dev/null +++ b/vendor/epaint/README.md @@ -0,0 +1,11 @@ +# epaint - egui paint library + +[![Latest version](https://img.shields.io/crates/v/epaint.svg)](https://crates.io/crates/epaint) +[![Documentation](https://docs.rs/epaint/badge.svg)](https://docs.rs/epaint) +[![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/) +![MIT](https://img.shields.io/badge/license-MIT-blue.svg) +![Apache](https://img.shields.io/badge/license-Apache-blue.svg) + +A bare-bones 2D graphics library for turning simple 2D shapes and text into textured triangles. + +Made for [`egui`](https://github.com/emilk/egui/). diff --git a/vendor/epaint/benches/benchmark.rs b/vendor/epaint/benches/benchmark.rs new file mode 100644 index 0000000..8fbfc65 --- /dev/null +++ b/vendor/epaint/benches/benchmark.rs @@ -0,0 +1,293 @@ +use criterion::{Criterion, criterion_group, criterion_main}; + +use epaint::{ + ClippedShape, Color32, Mesh, PathStroke, Pos2, Rect, Shape, Stroke, TessellationOptions, + Tessellator, TextureAtlas, Vec2, pos2, tessellator::Path, +}; + +use std::hint::black_box; + +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; // Much faster allocator + +fn single_dashed_lines(c: &mut Criterion) { + c.bench_function("single_dashed_lines", move |b| { + b.iter(|| { + let mut v = Vec::new(); + + let line = [pos2(0.0, 0.0), pos2(50.0, 0.0), pos2(100.0, 1.0)]; + + for _ in 0..100 { + v.extend(Shape::dashed_line( + &line, + Stroke::new(1.5, Color32::RED), + 10.0, + 2.5, + )); + } + + black_box(v); + }); + }); +} + +fn many_dashed_lines(c: &mut Criterion) { + c.bench_function("many_dashed_lines", move |b| { + b.iter(|| { + let mut v = Vec::new(); + + let line = [pos2(0.0, 0.0), pos2(50.0, 0.0), pos2(100.0, 1.0)]; + + for _ in 0..100 { + Shape::dashed_line_many(&line, Stroke::new(1.5, Color32::RED), 10.0, 2.5, &mut v); + } + + black_box(v); + }); + }); +} + +fn tessellate_circles(c: &mut Criterion) { + c.bench_function("tessellate_circles_100k", move |b| { + let radii: [f32; 10] = [1.0, 2.0, 3.6, 4.0, 5.7, 8.0, 10.0, 13.0, 15.0, 17.0]; + let mut clipped_shapes = vec![]; + for r in radii { + for _ in 0..10_000 { + let clip_rect = Rect::from_min_size(Pos2::ZERO, Vec2::splat(1024.0)); + let shape = Shape::circle_filled(Pos2::new(10.0, 10.0), r, Color32::WHITE); + clipped_shapes.push(ClippedShape { clip_rect, shape }); + } + } + assert_eq!( + clipped_shapes.len(), + 100_000, + "length of clipped shapes should be 100k, but was {}", + clipped_shapes.len() + ); + + let pixels_per_point = 2.0; + let options = TessellationOptions::default(); + + let atlas = TextureAtlas::new([4096, 256], Default::default()); + let font_tex_size = atlas.size(); + let prepared_discs = atlas.prepared_discs(); + + b.iter(|| { + let mut tessellator = Tessellator::new( + pixels_per_point, + options, + font_tex_size, + prepared_discs.clone(), + ); + let clipped_primitives = tessellator.tessellate_shapes(clipped_shapes.clone()); + black_box(clipped_primitives); + }); + }); +} + +fn thick_line_solid(c: &mut Criterion) { + c.bench_function("thick_solid_line", move |b| { + let line = [pos2(0.0, 0.0), pos2(50.0, 0.0), pos2(100.0, 1.0)]; + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed(1.5, &Stroke::new(2.0, Color32::RED).into(), &mut mesh); + + black_box(mesh); + }); + }); +} + +fn thick_large_line_solid(c: &mut Criterion) { + c.bench_function("thick_large_solid_line", move |b| { + let line = (0..1000).map(|i| pos2(i as f32, 10.0)).collect::>(); + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed(1.5, &Stroke::new(2.0, Color32::RED).into(), &mut mesh); + + black_box(mesh); + }); + }); +} + +fn thin_line_solid(c: &mut Criterion) { + c.bench_function("thin_solid_line", move |b| { + let line = [pos2(0.0, 0.0), pos2(50.0, 0.0), pos2(100.0, 1.0)]; + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed(1.5, &Stroke::new(0.5, Color32::RED).into(), &mut mesh); + + black_box(mesh); + }); + }); +} + +fn thin_large_line_solid(c: &mut Criterion) { + c.bench_function("thin_large_solid_line", move |b| { + let line = (0..1000).map(|i| pos2(i as f32, 10.0)).collect::>(); + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed(1.5, &Stroke::new(0.5, Color32::RED).into(), &mut mesh); + + black_box(mesh); + }); + }); +} + +fn thick_line_uv(c: &mut Criterion) { + c.bench_function("thick_uv_line", move |b| { + let line = [pos2(0.0, 0.0), pos2(50.0, 0.0), pos2(100.0, 1.0)]; + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed( + 1.5, + &PathStroke::new_uv(2.0, |_, p| { + black_box(p * 2.0); + Color32::RED + }), + &mut mesh, + ); + + black_box(mesh); + }); + }); +} + +fn thick_large_line_uv(c: &mut Criterion) { + c.bench_function("thick_large_uv_line", move |b| { + let line = (0..1000).map(|i| pos2(i as f32, 10.0)).collect::>(); + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed( + 1.5, + &PathStroke::new_uv(2.0, |_, p| { + black_box(p * 2.0); + Color32::RED + }), + &mut mesh, + ); + + black_box(mesh); + }); + }); +} + +fn thin_line_uv(c: &mut Criterion) { + c.bench_function("thin_uv_line", move |b| { + let line = [pos2(0.0, 0.0), pos2(50.0, 0.0), pos2(100.0, 1.0)]; + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed( + 1.5, + &PathStroke::new_uv(2.0, |_, p| { + black_box(p * 2.0); + Color32::RED + }), + &mut mesh, + ); + + black_box(mesh); + }); + }); +} + +fn thin_large_line_uv(c: &mut Criterion) { + c.bench_function("thin_large_uv_line", move |b| { + let line = (0..1000).map(|i| pos2(i as f32, 10.0)).collect::>(); + let mut path = Path::default(); + path.add_open_points(&line); + + b.iter(|| { + let mut mesh = Mesh::default(); + path.stroke_closed( + 1.5, + &PathStroke::new_uv(2.0, |_, p| { + black_box(p * 2.0); + Color32::RED + }), + &mut mesh, + ); + + black_box(mesh); + }); + }); +} + +fn rgba_values() -> [[u8; 4]; 1000] { + core::array::from_fn(|i| [5, 7, 11, 13].map(|m| (i * m) as u8)) +} + +fn from_rgba_unmultiplied_0(c: &mut Criterion) { + c.bench_function("from_rgba_unmultiplied_0", move |b| { + let values = black_box(rgba_values().map(|[r, g, b, _]| [r, g, b, 0])); + b.iter(|| { + for [r, g, b, a] in values { + let color = ecolor::Color32::from_rgba_unmultiplied(r, g, b, a); + black_box(color); + } + }); + }); +} + +fn from_rgba_unmultiplied_other(c: &mut Criterion) { + c.bench_function("from_rgba_unmultiplied_other", move |b| { + let values = black_box(rgba_values().map(|[r, g, b, a]| [r, g, b, a.clamp(1, 254)])); + b.iter(|| { + for [r, g, b, a] in values { + let color = ecolor::Color32::from_rgba_unmultiplied(r, g, b, a); + black_box(color); + } + }); + }); +} + +fn from_rgba_unmultiplied_255(c: &mut Criterion) { + c.bench_function("from_rgba_unmultiplied_255", move |b| { + let values = black_box(rgba_values().map(|[r, g, b, _]| [r, g, b, 255])); + b.iter(|| { + for [r, g, b, a] in values { + let color = ecolor::Color32::from_rgba_unmultiplied(r, g, b, a); + black_box(color); + } + }); + }); +} + +criterion_group!( + benches, + single_dashed_lines, + many_dashed_lines, + tessellate_circles, + thick_line_solid, + thick_large_line_solid, + thin_line_solid, + thin_large_line_solid, + thick_line_uv, + thick_large_line_uv, + thin_line_uv, + thin_large_line_uv, + from_rgba_unmultiplied_0, + from_rgba_unmultiplied_other, + from_rgba_unmultiplied_255, +); +criterion_main!(benches); diff --git a/vendor/epaint/src/brush.rs b/vendor/epaint/src/brush.rs new file mode 100644 index 0000000..a414194 --- /dev/null +++ b/vendor/epaint/src/brush.rs @@ -0,0 +1,19 @@ +use crate::{Rect, TextureId}; + +/// Controls texturing of a [`crate::RectShape`]. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Brush { + /// If the rect should be filled with a texture, which one? + /// + /// The texture is multiplied with [`crate::RectShape::fill`]. + pub fill_texture_id: TextureId, + + /// What UV coordinates to use for the texture? + /// + /// To display a texture, set [`Self::fill_texture_id`], + /// and set this to `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))`. + /// + /// Use [`Rect::ZERO`] to turn off texturing. + pub uv: Rect, +} diff --git a/vendor/epaint/src/color.rs b/vendor/epaint/src/color.rs new file mode 100644 index 0000000..54106c1 --- /dev/null +++ b/vendor/epaint/src/color.rs @@ -0,0 +1,48 @@ +use std::{fmt::Debug, sync::Arc}; + +use ecolor::Color32; +use emath::{Pos2, Rect}; + +/// How paths will be colored. +#[derive(Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ColorMode { + /// The entire path is one solid color, this is the default. + Solid(Color32), + + /// Provide a callback which takes in the path's bounding box and a position and converts it to a color. + /// When used with a path, the bounding box will have a margin of [`TessellationOptions::feathering_size_in_pixels`](`crate::tessellator::TessellationOptions::feathering_size_in_pixels`) + /// + /// **This cannot be serialized** + #[cfg_attr(feature = "serde", serde(skip))] + UV(Arc Color32 + Send + Sync>), +} + +impl Default for ColorMode { + fn default() -> Self { + Self::Solid(Color32::TRANSPARENT) + } +} + +impl Debug for ColorMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Solid(arg0) => f.debug_tuple("Solid").field(arg0).finish(), + Self::UV(_arg0) => f.debug_tuple("UV").field(&"").finish(), + } + } +} + +impl PartialEq for ColorMode { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Solid(l0), Self::Solid(r0)) => l0 == r0, + (Self::UV(_l0), Self::UV(_r0)) => false, + _ => false, + } + } +} + +impl ColorMode { + pub const TRANSPARENT: Self = Self::Solid(Color32::TRANSPARENT); +} diff --git a/vendor/epaint/src/corner_radius.rs b/vendor/epaint/src/corner_radius.rs new file mode 100644 index 0000000..07bd56c --- /dev/null +++ b/vendor/epaint/src/corner_radius.rs @@ -0,0 +1,250 @@ +/// How rounded the corners of things should be. +/// +/// This specific the _corner radius_ of the underlying geometric shape (e.g. rectangle). +/// If there is a stroke, then the stroke will have an inner and outer corner radius +/// which will depends on its width and [`crate::StrokeKind`]. +/// +/// The rounding uses `u8` to save space, +/// so the amount of rounding is limited to integers in the range `[0, 255]`. +/// +/// For calculations, you may want to use [`crate::CornerRadiusF32`] instead, which uses `f32`. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CornerRadius { + /// Radius of the rounding of the North-West (left top) corner. + pub nw: u8, + + /// Radius of the rounding of the North-East (right top) corner. + pub ne: u8, + + /// Radius of the rounding of the South-West (left bottom) corner. + pub sw: u8, + + /// Radius of the rounding of the South-East (right bottom) corner. + pub se: u8, +} + +impl Default for CornerRadius { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl From for CornerRadius { + #[inline] + fn from(radius: u8) -> Self { + Self::same(radius) + } +} + +impl From for CornerRadius { + #[inline] + fn from(radius: f32) -> Self { + Self::same(radius.round() as u8) + } +} + +impl CornerRadius { + /// No rounding on any corner. + pub const ZERO: Self = Self { + nw: 0, + ne: 0, + sw: 0, + se: 0, + }; + + /// Same rounding on all four corners. + #[inline] + pub const fn same(radius: u8) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } + + /// Do all corners have the same rounding? + #[inline] + pub fn is_same(self) -> bool { + self.nw == self.ne && self.nw == self.sw && self.nw == self.se + } + + /// Make sure each corner has a rounding of at least this. + #[inline] + pub fn at_least(self, min: u8) -> Self { + Self { + nw: self.nw.max(min), + ne: self.ne.max(min), + sw: self.sw.max(min), + se: self.se.max(min), + } + } + + /// Make sure each corner has a rounding of at most this. + #[inline] + pub fn at_most(self, max: u8) -> Self { + Self { + nw: self.nw.min(max), + ne: self.ne.min(max), + sw: self.sw.min(max), + se: self.se.min(max), + } + } + + /// Average rounding of the corners. + pub fn average(&self) -> f32 { + (self.nw as f32 + self.ne as f32 + self.sw as f32 + self.se as f32) / 4.0 + } +} + +impl std::ops::Add for CornerRadius { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self { + nw: self.nw.saturating_add(rhs.nw), + ne: self.ne.saturating_add(rhs.ne), + sw: self.sw.saturating_add(rhs.sw), + se: self.se.saturating_add(rhs.se), + } + } +} + +impl std::ops::Add for CornerRadius { + type Output = Self; + #[inline] + fn add(self, rhs: u8) -> Self { + Self { + nw: self.nw.saturating_add(rhs), + ne: self.ne.saturating_add(rhs), + sw: self.sw.saturating_add(rhs), + se: self.se.saturating_add(rhs), + } + } +} + +impl std::ops::AddAssign for CornerRadius { + #[inline] + fn add_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw.saturating_add(rhs.nw), + ne: self.ne.saturating_add(rhs.ne), + sw: self.sw.saturating_add(rhs.sw), + se: self.se.saturating_add(rhs.se), + }; + } +} + +impl std::ops::AddAssign for CornerRadius { + #[inline] + fn add_assign(&mut self, rhs: u8) { + *self = Self { + nw: self.nw.saturating_add(rhs), + ne: self.ne.saturating_add(rhs), + sw: self.sw.saturating_add(rhs), + se: self.se.saturating_add(rhs), + }; + } +} + +impl std::ops::Sub for CornerRadius { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self { + nw: self.nw.saturating_sub(rhs.nw), + ne: self.ne.saturating_sub(rhs.ne), + sw: self.sw.saturating_sub(rhs.sw), + se: self.se.saturating_sub(rhs.se), + } + } +} + +impl std::ops::Sub for CornerRadius { + type Output = Self; + #[inline] + fn sub(self, rhs: u8) -> Self { + Self { + nw: self.nw.saturating_sub(rhs), + ne: self.ne.saturating_sub(rhs), + sw: self.sw.saturating_sub(rhs), + se: self.se.saturating_sub(rhs), + } + } +} + +impl std::ops::SubAssign for CornerRadius { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw.saturating_sub(rhs.nw), + ne: self.ne.saturating_sub(rhs.ne), + sw: self.sw.saturating_sub(rhs.sw), + se: self.se.saturating_sub(rhs.se), + }; + } +} + +impl std::ops::SubAssign for CornerRadius { + #[inline] + fn sub_assign(&mut self, rhs: u8) { + *self = Self { + nw: self.nw.saturating_sub(rhs), + ne: self.ne.saturating_sub(rhs), + sw: self.sw.saturating_sub(rhs), + se: self.se.saturating_sub(rhs), + }; + } +} + +impl std::ops::Div for CornerRadius { + type Output = Self; + #[inline] + fn div(self, rhs: f32) -> Self { + Self { + nw: (self.nw as f32 / rhs) as u8, + ne: (self.ne as f32 / rhs) as u8, + sw: (self.sw as f32 / rhs) as u8, + se: (self.se as f32 / rhs) as u8, + } + } +} + +impl std::ops::DivAssign for CornerRadius { + #[inline] + fn div_assign(&mut self, rhs: f32) { + *self = Self { + nw: (self.nw as f32 / rhs) as u8, + ne: (self.ne as f32 / rhs) as u8, + sw: (self.sw as f32 / rhs) as u8, + se: (self.se as f32 / rhs) as u8, + }; + } +} + +impl std::ops::Mul for CornerRadius { + type Output = Self; + #[inline] + fn mul(self, rhs: f32) -> Self { + Self { + nw: (self.nw as f32 * rhs) as u8, + ne: (self.ne as f32 * rhs) as u8, + sw: (self.sw as f32 * rhs) as u8, + se: (self.se as f32 * rhs) as u8, + } + } +} + +impl std::ops::MulAssign for CornerRadius { + #[inline] + fn mul_assign(&mut self, rhs: f32) { + *self = Self { + nw: (self.nw as f32 * rhs) as u8, + ne: (self.ne as f32 * rhs) as u8, + sw: (self.sw as f32 * rhs) as u8, + se: (self.se as f32 * rhs) as u8, + }; + } +} diff --git a/vendor/epaint/src/corner_radius_f32.rs b/vendor/epaint/src/corner_radius_f32.rs new file mode 100644 index 0000000..0a88aaa --- /dev/null +++ b/vendor/epaint/src/corner_radius_f32.rs @@ -0,0 +1,236 @@ +use crate::CornerRadius; + +/// How rounded the corners of things should be, in `f32`. +/// +/// This is used for calculations, but storage is usually done with the more compact [`CornerRadius`]. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CornerRadiusF32 { + /// Radius of the rounding of the North-West (left top) corner. + pub nw: f32, + + /// Radius of the rounding of the North-East (right top) corner. + pub ne: f32, + + /// Radius of the rounding of the South-West (left bottom) corner. + pub sw: f32, + + /// Radius of the rounding of the South-East (right bottom) corner. + pub se: f32, +} + +impl From for CornerRadiusF32 { + #[inline] + fn from(cr: CornerRadius) -> Self { + Self { + nw: cr.nw as f32, + ne: cr.ne as f32, + sw: cr.sw as f32, + se: cr.se as f32, + } + } +} + +impl From for CornerRadius { + #[inline] + fn from(cr: CornerRadiusF32) -> Self { + Self { + nw: cr.nw.round() as u8, + ne: cr.ne.round() as u8, + sw: cr.sw.round() as u8, + se: cr.se.round() as u8, + } + } +} + +impl Default for CornerRadiusF32 { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl From for CornerRadiusF32 { + #[inline] + fn from(radius: f32) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } +} + +impl CornerRadiusF32 { + /// No rounding on any corner. + pub const ZERO: Self = Self { + nw: 0.0, + ne: 0.0, + sw: 0.0, + se: 0.0, + }; + + /// Same rounding on all four corners. + #[inline] + pub const fn same(radius: f32) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } + + /// Do all corners have the same rounding? + #[inline] + pub fn is_same(&self) -> bool { + self.nw == self.ne && self.nw == self.sw && self.nw == self.se + } + + /// Make sure each corner has a rounding of at least this. + #[inline] + pub fn at_least(&self, min: f32) -> Self { + Self { + nw: self.nw.max(min), + ne: self.ne.max(min), + sw: self.sw.max(min), + se: self.se.max(min), + } + } + + /// Make sure each corner has a rounding of at most this. + #[inline] + pub fn at_most(&self, max: f32) -> Self { + Self { + nw: self.nw.min(max), + ne: self.ne.min(max), + sw: self.sw.min(max), + se: self.se.min(max), + } + } +} + +impl std::ops::Add for CornerRadiusF32 { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + } + } +} + +impl std::ops::AddAssign for CornerRadiusF32 { + #[inline] + fn add_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + }; + } +} + +impl std::ops::AddAssign for CornerRadiusF32 { + #[inline] + fn add_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw + rhs, + ne: self.ne + rhs, + sw: self.sw + rhs, + se: self.se + rhs, + }; + } +} + +impl std::ops::Sub for CornerRadiusF32 { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self { + nw: self.nw - rhs.nw, + ne: self.ne - rhs.ne, + sw: self.sw - rhs.sw, + se: self.se - rhs.se, + } + } +} + +impl std::ops::SubAssign for CornerRadiusF32 { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw - rhs.nw, + ne: self.ne - rhs.ne, + sw: self.sw - rhs.sw, + se: self.se - rhs.se, + }; + } +} + +impl std::ops::SubAssign for CornerRadiusF32 { + #[inline] + fn sub_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw - rhs, + ne: self.ne - rhs, + sw: self.sw - rhs, + se: self.se - rhs, + }; + } +} + +impl std::ops::Div for CornerRadiusF32 { + type Output = Self; + #[inline] + fn div(self, rhs: f32) -> Self { + Self { + nw: self.nw / rhs, + ne: self.ne / rhs, + sw: self.sw / rhs, + se: self.se / rhs, + } + } +} + +impl std::ops::DivAssign for CornerRadiusF32 { + #[inline] + fn div_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw / rhs, + ne: self.ne / rhs, + sw: self.sw / rhs, + se: self.se / rhs, + }; + } +} + +impl std::ops::Mul for CornerRadiusF32 { + type Output = Self; + #[inline] + fn mul(self, rhs: f32) -> Self { + Self { + nw: self.nw * rhs, + ne: self.ne * rhs, + sw: self.sw * rhs, + se: self.se * rhs, + } + } +} + +impl std::ops::MulAssign for CornerRadiusF32 { + #[inline] + fn mul_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw * rhs, + ne: self.ne * rhs, + sw: self.sw * rhs, + se: self.se * rhs, + }; + } +} diff --git a/vendor/epaint/src/direction.rs b/vendor/epaint/src/direction.rs new file mode 100644 index 0000000..b2f317c --- /dev/null +++ b/vendor/epaint/src/direction.rs @@ -0,0 +1,27 @@ +/// A cardinal direction, one of [`LeftToRight`](Direction::LeftToRight), [`RightToLeft`](Direction::RightToLeft), [`TopDown`](Direction::TopDown), [`BottomUp`](Direction::BottomUp). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum Direction { + LeftToRight, + RightToLeft, + TopDown, + BottomUp, +} + +impl Direction { + #[inline(always)] + pub fn is_horizontal(self) -> bool { + match self { + Self::LeftToRight | Self::RightToLeft => true, + Self::TopDown | Self::BottomUp => false, + } + } + + #[inline(always)] + pub fn is_vertical(self) -> bool { + match self { + Self::LeftToRight | Self::RightToLeft => false, + Self::TopDown | Self::BottomUp => true, + } + } +} diff --git a/vendor/epaint/src/image.rs b/vendor/epaint/src/image.rs new file mode 100644 index 0000000..059d9d7 --- /dev/null +++ b/vendor/epaint/src/image.rs @@ -0,0 +1,450 @@ +use emath::Vec2; + +use crate::{Color32, textures::TextureOptions}; +use std::sync::Arc; + +/// An image stored in RAM. +/// +/// To load an image file, see [`ColorImage::from_rgba_unmultiplied`]. +/// +/// This is currently an enum with only one variant, but more image types may be added in the future. +/// +/// See also: [`ColorImage`]. +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ImageData { + /// RGBA image. + Color(Arc), +} + +impl ImageData { + pub fn size(&self) -> [usize; 2] { + match self { + Self::Color(image) => image.size, + } + } + + pub fn width(&self) -> usize { + self.size()[0] + } + + pub fn height(&self) -> usize { + self.size()[1] + } + + pub fn bytes_per_pixel(&self) -> usize { + match self { + Self::Color(_) => 4, + } + } +} + +// ---------------------------------------------------------------------------- + +/// A 2D RGBA color image in RAM. +#[derive(Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ColorImage { + /// width, height in texels. + pub size: [usize; 2], + + /// Size of the original SVG image (if any), or just the texel size of the image. + pub source_size: Vec2, + + /// The pixels, row by row, from top to bottom. + pub pixels: Vec, +} + +impl ColorImage { + /// Create an image filled with the given color. + pub fn new(size: [usize; 2], pixels: Vec) -> Self { + debug_assert!( + size[0] * size[1] == pixels.len(), + "size: {size:?}, pixels.len(): {}", + pixels.len() + ); + Self { + size, + source_size: Vec2::new(size[0] as f32, size[1] as f32), + pixels, + } + } + + /// Create an image filled with the given color. + pub fn filled(size: [usize; 2], color: Color32) -> Self { + Self { + size, + source_size: Vec2::new(size[0] as f32, size[1] as f32), + pixels: vec![color; size[0] * size[1]], + } + } + + /// Create a [`ColorImage`] from flat un-multiplied RGBA data. + /// + /// This is usually what you want to use after having loaded an image file. + /// + /// Panics if `size[0] * size[1] * 4 != rgba.len()`. + /// + /// ## Example using the [`image`](crates.io/crates/image) crate: + /// ``` ignore + /// fn load_image_from_path(path: &std::path::Path) -> Result { + /// let image = image::io::Reader::open(path)?.decode()?; + /// let size = [image.width() as _, image.height() as _]; + /// let image_buffer = image.to_rgba8(); + /// let pixels = image_buffer.as_flat_samples(); + /// Ok(egui::ColorImage::from_rgba_unmultiplied( + /// size, + /// pixels.as_slice(), + /// )) + /// } + /// + /// fn load_image_from_memory(image_data: &[u8]) -> Result { + /// let image = image::load_from_memory(image_data)?; + /// let size = [image.width() as _, image.height() as _]; + /// let image_buffer = image.to_rgba8(); + /// let pixels = image_buffer.as_flat_samples(); + /// Ok(ColorImage::from_rgba_unmultiplied( + /// size, + /// pixels.as_slice(), + /// )) + /// } + /// ``` + pub fn from_rgba_unmultiplied(size: [usize; 2], rgba: &[u8]) -> Self { + assert_eq!( + size[0] * size[1] * 4, + rgba.len(), + "size: {:?}, rgba.len(): {}", + size, + rgba.len() + ); + let pixels = rgba + .chunks_exact(4) + .map(|p| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3])) + .collect(); + Self::new(size, pixels) + } + + pub fn from_rgba_premultiplied(size: [usize; 2], rgba: &[u8]) -> Self { + assert_eq!( + size[0] * size[1] * 4, + rgba.len(), + "size: {:?}, rgba.len(): {}", + size, + rgba.len() + ); + let pixels = rgba + .chunks_exact(4) + .map(|p| Color32::from_rgba_premultiplied(p[0], p[1], p[2], p[3])) + .collect(); + Self::new(size, pixels) + } + + /// Create a [`ColorImage`] from flat opaque gray data. + /// + /// Panics if `size[0] * size[1] != gray.len()`. + pub fn from_gray(size: [usize; 2], gray: &[u8]) -> Self { + assert_eq!( + size[0] * size[1], + gray.len(), + "size: {:?}, gray.len(): {}", + size, + gray.len() + ); + let pixels = gray.iter().map(|p| Color32::from_gray(*p)).collect(); + Self::new(size, pixels) + } + + /// Alternative method to `from_gray`. + /// Create a [`ColorImage`] from iterator over flat opaque gray data. + /// + /// Panics if `size[0] * size[1] != gray_iter.len()`. + #[doc(alias = "from_grey_iter")] + pub fn from_gray_iter(size: [usize; 2], gray_iter: impl Iterator) -> Self { + let pixels: Vec<_> = gray_iter.map(Color32::from_gray).collect(); + assert_eq!( + size[0] * size[1], + pixels.len(), + "size: {:?}, pixels.len(): {}", + size, + pixels.len() + ); + Self::new(size, pixels) + } + + /// A view of the underlying data as `&[u8]` + #[cfg(feature = "bytemuck")] + pub fn as_raw(&self) -> &[u8] { + bytemuck::cast_slice(&self.pixels) + } + + /// A view of the underlying data as `&mut [u8]` + #[cfg(feature = "bytemuck")] + pub fn as_raw_mut(&mut self) -> &mut [u8] { + bytemuck::cast_slice_mut(&mut self.pixels) + } + + /// Create a [`ColorImage`] from flat RGB data. + /// + /// This is what you want to use after having loaded an image file (and if + /// you are ignoring the alpha channel - considering it to always be 0xff) + /// + /// Panics if `size[0] * size[1] * 3 != rgb.len()`. + pub fn from_rgb(size: [usize; 2], rgb: &[u8]) -> Self { + assert_eq!( + size[0] * size[1] * 3, + rgb.len(), + "size: {:?}, rgb.len(): {}", + size, + rgb.len() + ); + let pixels = rgb + .chunks_exact(3) + .map(|p| Color32::from_rgb(p[0], p[1], p[2])) + .collect(); + Self::new(size, pixels) + } + + /// An example color image, useful for tests. + pub fn example() -> Self { + let width = 128; + let height = 64; + let mut img = Self::filled([width, height], Color32::TRANSPARENT); + for y in 0..height { + for x in 0..width { + let h = x as f32 / width as f32; + let s = 1.0; + let v = 1.0; + let a = y as f32 / height as f32; + img[(x, y)] = crate::Hsva { h, s, v, a }.into(); + } + } + img + } + + /// Set the source size of e.g. the original SVG image. + #[inline] + pub fn with_source_size(mut self, source_size: Vec2) -> Self { + self.source_size = source_size; + self + } + + #[inline] + pub fn width(&self) -> usize { + self.size[0] + } + + #[inline] + pub fn height(&self) -> usize { + self.size[1] + } + + /// Create a new image from a patch of the current image. + /// + /// This method is especially convenient for screenshotting a part of the app + /// since `region` can be interpreted as screen coordinates of the entire screenshot if `pixels_per_point` is provided for the native application. + /// The floats of [`emath::Rect`] are cast to usize, rounding them down in order to interpret them as indices to the image data. + /// + /// Panics if `region.min.x > region.max.x || region.min.y > region.max.y`, or if a region larger than the image is passed. + pub fn region(&self, region: &emath::Rect, pixels_per_point: Option) -> Self { + let pixels_per_point = pixels_per_point.unwrap_or(1.0); + let min_x = (region.min.x * pixels_per_point) as usize; + let max_x = (region.max.x * pixels_per_point) as usize; + let min_y = (region.min.y * pixels_per_point) as usize; + let max_y = (region.max.y * pixels_per_point) as usize; + assert!( + min_x <= max_x && min_y <= max_y, + "Screenshot region is invalid: {region:?}" + ); + let width = max_x - min_x; + let height = max_y - min_y; + let mut output = Vec::with_capacity(width * height); + let row_stride = self.size[0]; + + for row in min_y..max_y { + output.extend_from_slice( + &self.pixels[row * row_stride + min_x..row * row_stride + max_x], + ); + } + Self::new([width, height], output) + } + + /// Clone a sub-region as a new image. + pub fn region_by_pixels(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self { + assert!( + x + w <= self.width(), + "x + w should be <= self.width(), but x: {}, w: {}, width: {}", + x, + w, + self.width() + ); + assert!( + y + h <= self.height(), + "y + h should be <= self.height(), but y: {}, h: {}, height: {}", + y, + h, + self.height() + ); + + let mut pixels = Vec::with_capacity(w * h); + for y in y..y + h { + let offset = y * self.width() + x; + pixels.extend(&self.pixels[offset..(offset + w)]); + } + assert_eq!( + pixels.len(), + w * h, + "pixels.len should be w * h, but got {}", + pixels.len() + ); + Self::new([w, h], pixels) + } +} + +impl std::ops::Index<(usize, usize)> for ColorImage { + type Output = Color32; + + #[inline] + fn index(&self, (x, y): (usize, usize)) -> &Color32 { + let [w, h] = self.size; + assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); + &self.pixels[y * w + x] + } +} + +impl std::ops::IndexMut<(usize, usize)> for ColorImage { + #[inline] + fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut Color32 { + let [w, h] = self.size; + assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); + &mut self.pixels[y * w + x] + } +} + +impl From for ImageData { + #[inline(always)] + fn from(image: ColorImage) -> Self { + Self::Color(Arc::new(image)) + } +} + +impl From> for ImageData { + #[inline] + fn from(image: Arc) -> Self { + Self::Color(image) + } +} + +impl std::fmt::Debug for ColorImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ColorImage") + .field("size", &self.size) + .field("pixel-count", &self.pixels.len()) + .finish_non_exhaustive() + } +} + +// ---------------------------------------------------------------------------- + +/// How to convert font coverage values into alpha and color values. +// +// This whole thing is less than rigorous. +// Ideally we should do this in a shader instead, and use different computations +// for different text colors. +// See https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html for an in-depth analysis. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum AlphaFromCoverage { + /// `alpha = coverage`. + /// + /// Looks good for black-on-white text, i.e. light mode. + /// + /// Same as [`Self::Gamma`]`(1.0)`, but more efficient. + Linear, + + /// `alpha = coverage^gamma`. + Gamma(f32), + + /// `alpha = 2 * coverage - coverage^2` + /// + /// This looks good for white-on-black text, i.e. dark mode. + /// + /// Very similar to a gamma of 0.5, but produces sharper text. + /// See for a comparison to gamma=0.5. + #[default] + TwoCoverageMinusCoverageSq, +} + +impl AlphaFromCoverage { + /// A good-looking default for light mode (black-on-white text). + pub const LIGHT_MODE_DEFAULT: Self = Self::Linear; + + /// A good-looking default for dark mode (white-on-black text). + pub const DARK_MODE_DEFAULT: Self = Self::TwoCoverageMinusCoverageSq; + + /// Convert coverage to alpha. + #[inline(always)] + pub fn alpha_from_coverage(&self, coverage: f32) -> f32 { + let coverage = coverage.clamp(0.0, 1.0); + match self { + Self::Linear => coverage, + Self::Gamma(gamma) => coverage.powf(*gamma), + Self::TwoCoverageMinusCoverageSq => 2.0 * coverage - coverage * coverage, + } + } + + #[inline(always)] + pub fn color_from_coverage(&self, coverage: f32) -> Color32 { + let alpha = self.alpha_from_coverage(coverage); + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } +} + +// ---------------------------------------------------------------------------- + +/// A change to an image. +/// +/// Either a whole new image, or an update to a rectangular region of it. +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[must_use = "The painter must take care of this"] +pub struct ImageDelta { + /// What to set the texture to. + /// + /// If [`Self::pos`] is `None`, this describes the whole texture. + /// + /// If [`Self::pos`] is `Some`, this describes a patch of the whole image starting at [`Self::pos`]. + pub image: ImageData, + + pub options: TextureOptions, + + /// If `None`, set the whole texture to [`Self::image`]. + /// + /// If `Some(pos)`, update a sub-region of an already allocated texture with the patch in [`Self::image`]. + pub pos: Option<[usize; 2]>, +} + +impl ImageDelta { + /// Update the whole texture. + pub fn full(image: impl Into, options: TextureOptions) -> Self { + Self { + image: image.into(), + options, + pos: None, + } + } + + /// Update a sub-region of an existing texture. + pub fn partial(pos: [usize; 2], image: impl Into, options: TextureOptions) -> Self { + Self { + image: image.into(), + options, + pos: Some(pos), + } + } + + /// Is this affecting the whole texture? + /// If `false`, this is a partial (sub-region) update. + pub fn is_whole(&self) -> bool { + self.pos.is_none() + } +} diff --git a/vendor/epaint/src/lib.rs b/vendor/epaint/src/lib.rs new file mode 100644 index 0000000..6f574e6 --- /dev/null +++ b/vendor/epaint/src/lib.rs @@ -0,0 +1,167 @@ +//! A simple 2D graphics library for turning simple 2D shapes and text into textured triangles. +//! +//! Made for [`egui`](https://github.com/emilk/egui/). +//! +//! Create some [`Shape`]:s and pass them to [`Tessellator::tessellate_shapes`] to generate [`Mesh`]:es +//! that you can then paint using some graphics API of your choice (e.g. OpenGL). +//! +//! ## Coordinate system +//! The left-top corner of the screen is `(0.0, 0.0)`, +//! with X increasing to the right and Y increasing downwards. +//! +//! `epaint` uses logical _points_ as its coordinate system. +//! Those related to physical _pixels_ by the `pixels_per_point` scale factor. +//! For example, a high-dpi screen can have `pixels_per_point = 2.0`, +//! meaning there are two physical screen pixels for each logical point. +//! +//! Angles are in radians, and are measured clockwise from the X-axis, which has angle=0. +//! +//! ## Feature flags +#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] +//! + +#![expect(clippy::float_cmp)] +#![expect(clippy::manual_range_contains)] + +mod brush; +pub mod color; +mod corner_radius; +mod corner_radius_f32; +mod direction; +pub mod image; +mod margin; +mod margin_f32; +mod mesh; +pub mod mutex; +mod shadow; +pub mod shape_transform; +mod shapes; +pub mod stats; +mod stroke; +pub mod tessellator; +pub mod text; +mod texture_atlas; +mod texture_handle; +pub mod textures; +pub mod util; +mod viewport; + +pub use self::{ + brush::Brush, + color::ColorMode, + corner_radius::CornerRadius, + corner_radius_f32::CornerRadiusF32, + direction::Direction, + image::{AlphaFromCoverage, ColorImage, ImageData, ImageDelta}, + margin::Margin, + margin_f32::*, + mesh::{Mesh, Mesh16, Vertex}, + shadow::Shadow, + shapes::{ + CircleShape, CubicBezierShape, EllipseShape, PaintCallback, PaintCallbackInfo, PathShape, + QuadraticBezierShape, RectShape, Shape, TextShape, + }, + stats::PaintStats, + stroke::{PathStroke, Stroke, StrokeKind}, + tessellator::{TessellationOptions, Tessellator}, + text::{FontFamily, FontId, Fonts, FontsView, Galley, TextOptions}, + texture_atlas::TextureAtlas, + texture_handle::TextureHandle, + textures::TextureManager, + viewport::ViewportInPixels, +}; + +#[deprecated = "Renamed to CornerRadius"] +pub type Rounding = CornerRadius; + +pub use ecolor::{Color32, Hsva, HsvaGamma, Rgba}; +pub use emath::{Pos2, Rect, Vec2, pos2, vec2}; + +#[deprecated = "Use the ahash crate directly."] +pub use ahash; + +pub use ecolor; +pub use emath; + +#[cfg(feature = "color-hex")] +pub use ecolor::hex_color; + +/// The UV coordinate of a white region of the texture mesh. +/// +/// The default egui texture has the top-left corner pixel fully white. +/// You need need use a clamping texture sampler for this to work +/// (so it doesn't do bilinear blending with bottom right corner). +pub const WHITE_UV: emath::Pos2 = emath::pos2(0.0, 0.0); + +/// What texture to use in a [`Mesh`] mesh. +/// +/// If you don't want to use a texture, use `TextureId::Managed(0)` and the [`WHITE_UV`] for uv-coord. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextureId { + /// Textures allocated using [`TextureManager`]. + /// + /// The first texture (`TextureId::Managed(0)`) is used for the font data. + Managed(u64), + + /// Your own texture, defined in any which way you want. + /// The backend renderer will presumably use this to look up what texture to use. + User(u64), +} + +impl Default for TextureId { + /// The epaint font texture. + fn default() -> Self { + Self::Managed(0) + } +} + +/// A [`Shape`] within a clip rectangle. +/// +/// Everything is using logical points. +#[derive(Clone, Debug, PartialEq)] +pub struct ClippedShape { + /// Clip / scissor rectangle. + /// Only show the part of the [`Shape`] that falls within this. + pub clip_rect: emath::Rect, + + /// The shape + pub shape: Shape, +} + +impl ClippedShape { + /// Transform (move/scale) the shape in-place. + /// + /// If using a [`PaintCallback`], note that only the rect is scaled as opposed + /// to other shapes where the stroke is also scaled. + pub fn transform(&mut self, transform: emath::TSTransform) { + let Self { clip_rect, shape } = self; + *clip_rect = transform * *clip_rect; + shape.transform(transform); + } +} + +/// A [`Mesh`] or [`PaintCallback`] within a clip rectangle. +/// +/// Everything is using logical points. +#[derive(Clone, Debug)] +pub struct ClippedPrimitive { + /// Clip / scissor rectangle. + /// Only show the part of the [`Mesh`] that falls within this. + pub clip_rect: emath::Rect, + + /// What to paint - either a [`Mesh`] or a [`PaintCallback`]. + pub primitive: Primitive, +} + +/// A rendering primitive - either a [`Mesh`] or a [`PaintCallback`]. +#[derive(Clone, Debug)] +pub enum Primitive { + Mesh(Mesh), + Callback(PaintCallback), +} + +// --------------------------------------------------------------------------- + +/// Was epaint compiled with the `rayon` feature? +pub const HAS_RAYON: bool = cfg!(feature = "rayon"); diff --git a/vendor/epaint/src/margin.rs b/vendor/epaint/src/margin.rs new file mode 100644 index 0000000..0e2063e --- /dev/null +++ b/vendor/epaint/src/margin.rs @@ -0,0 +1,280 @@ +use emath::{Rect, Vec2, vec2}; + +/// A value for all four sides of a rectangle, +/// often used to express padding or spacing. +/// +/// Can be added and subtracted to/from [`Rect`]s. +/// +/// Negative margins are possible, but may produce weird behavior. +/// Use with care. +/// +/// All values are stored as [`i8`] to keep the size of [`Margin`] small. +/// If you want floats, use [`crate::MarginF32`] instead. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Margin { + pub left: i8, + pub right: i8, + pub top: i8, + pub bottom: i8, +} + +impl Margin { + pub const ZERO: Self = Self { + left: 0, + right: 0, + top: 0, + bottom: 0, + }; + + /// The same margin on every side. + #[doc(alias = "symmetric")] + #[inline] + pub const fn same(margin: i8) -> Self { + Self { + left: margin, + right: margin, + top: margin, + bottom: margin, + } + } + + /// Margins with the same size on opposing sides + #[inline] + pub const fn symmetric(x: i8, y: i8) -> Self { + Self { + left: x, + right: x, + top: y, + bottom: y, + } + } + + /// Left margin, as `f32` + #[inline] + pub const fn leftf(self) -> f32 { + self.left as _ + } + + /// Right margin, as `f32` + #[inline] + pub const fn rightf(self) -> f32 { + self.right as _ + } + + /// Top margin, as `f32` + #[inline] + pub const fn topf(self) -> f32 { + self.top as _ + } + + /// Bottom margin, as `f32` + #[inline] + pub const fn bottomf(self) -> f32 { + self.bottom as _ + } + + /// Total margins on both sides + #[inline] + pub fn sum(self) -> Vec2 { + vec2(self.leftf() + self.rightf(), self.topf() + self.bottomf()) + } + + #[inline] + pub const fn left_top(self) -> Vec2 { + vec2(self.leftf(), self.topf()) + } + + #[inline] + pub const fn right_bottom(self) -> Vec2 { + vec2(self.rightf(), self.bottomf()) + } + + /// Are the margin on every side the same? + #[doc(alias = "symmetric")] + #[inline] + pub const fn is_same(self) -> bool { + self.left == self.right && self.left == self.top && self.left == self.bottom + } +} + +impl From for Margin { + #[inline] + fn from(v: i8) -> Self { + Self::same(v) + } +} + +impl From for Margin { + #[inline] + fn from(v: f32) -> Self { + Self::same(v.round() as _) + } +} + +impl From for Margin { + #[inline] + fn from(v: Vec2) -> Self { + Self::symmetric(v.x.round() as _, v.y.round() as _) + } +} + +/// `Margin + Margin` +impl std::ops::Add for Margin { + type Output = Self; + + #[inline] + fn add(self, other: Self) -> Self { + Self { + left: self.left.saturating_add(other.left), + right: self.right.saturating_add(other.right), + top: self.top.saturating_add(other.top), + bottom: self.bottom.saturating_add(other.bottom), + } + } +} + +/// `Margin + i8` +impl std::ops::Add for Margin { + type Output = Self; + + #[inline] + fn add(self, v: i8) -> Self { + Self { + left: self.left.saturating_add(v), + right: self.right.saturating_add(v), + top: self.top.saturating_add(v), + bottom: self.bottom.saturating_add(v), + } + } +} + +/// `Margin += i8` +impl std::ops::AddAssign for Margin { + #[inline] + fn add_assign(&mut self, v: i8) { + *self = *self + v; + } +} + +/// `Margin * f32` +impl std::ops::Mul for Margin { + type Output = Self; + + #[inline] + fn mul(self, v: f32) -> Self { + Self { + left: (self.leftf() * v).round() as _, + right: (self.rightf() * v).round() as _, + top: (self.topf() * v).round() as _, + bottom: (self.bottomf() * v).round() as _, + } + } +} + +/// `Margin *= f32` +impl std::ops::MulAssign for Margin { + #[inline] + fn mul_assign(&mut self, v: f32) { + *self = *self * v; + } +} + +/// `Margin / f32` +impl std::ops::Div for Margin { + type Output = Self; + + #[inline] + fn div(self, v: f32) -> Self { + #![expect(clippy::suspicious_arithmetic_impl)] + self * v.recip() + } +} + +/// `Margin /= f32` +impl std::ops::DivAssign for Margin { + #[inline] + fn div_assign(&mut self, v: f32) { + *self = *self / v; + } +} + +/// `Margin - Margin` +impl std::ops::Sub for Margin { + type Output = Self; + + #[inline] + fn sub(self, other: Self) -> Self { + Self { + left: self.left.saturating_sub(other.left), + right: self.right.saturating_sub(other.right), + top: self.top.saturating_sub(other.top), + bottom: self.bottom.saturating_sub(other.bottom), + } + } +} + +/// `Margin - i8` +impl std::ops::Sub for Margin { + type Output = Self; + + #[inline] + fn sub(self, v: i8) -> Self { + Self { + left: self.left.saturating_sub(v), + right: self.right.saturating_sub(v), + top: self.top.saturating_sub(v), + bottom: self.bottom.saturating_sub(v), + } + } +} + +/// `Margin -= i8` +impl std::ops::SubAssign for Margin { + #[inline] + fn sub_assign(&mut self, v: i8) { + *self = *self - v; + } +} + +/// `Rect + Margin` +impl std::ops::Add for Rect { + type Output = Self; + + #[inline] + fn add(self, margin: Margin) -> Self { + Self::from_min_max( + self.min - margin.left_top(), + self.max + margin.right_bottom(), + ) + } +} + +/// `Rect += Margin` +impl std::ops::AddAssign for Rect { + #[inline] + fn add_assign(&mut self, margin: Margin) { + *self = *self + margin; + } +} + +/// `Rect - Margin` +impl std::ops::Sub for Rect { + type Output = Self; + + #[inline] + fn sub(self, margin: Margin) -> Self { + Self::from_min_max( + self.min + margin.left_top(), + self.max - margin.right_bottom(), + ) + } +} + +/// `Rect -= Margin` +impl std::ops::SubAssign for Rect { + #[inline] + fn sub_assign(&mut self, margin: Margin) { + *self = *self - margin; + } +} diff --git a/vendor/epaint/src/margin_f32.rs b/vendor/epaint/src/margin_f32.rs new file mode 100644 index 0000000..22bc8b3 --- /dev/null +++ b/vendor/epaint/src/margin_f32.rs @@ -0,0 +1,302 @@ +use emath::{Rect, Vec2, vec2}; + +use crate::Margin; + +/// A value for all four sides of a rectangle, +/// often used to express padding or spacing. +/// +/// Can be added and subtracted to/from [`Rect`]s. +/// +/// For storage, use [`crate::Margin`] instead. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct MarginF32 { + pub left: f32, + pub right: f32, + pub top: f32, + pub bottom: f32, +} + +#[deprecated = "Renamed to MarginF32"] +pub type Marginf = MarginF32; + +impl From for MarginF32 { + #[inline] + fn from(margin: Margin) -> Self { + Self { + left: margin.left as _, + right: margin.right as _, + top: margin.top as _, + bottom: margin.bottom as _, + } + } +} + +impl From for Margin { + #[inline] + fn from(marginf: MarginF32) -> Self { + Self { + left: marginf.left as _, + right: marginf.right as _, + top: marginf.top as _, + bottom: marginf.bottom as _, + } + } +} + +impl MarginF32 { + pub const ZERO: Self = Self { + left: 0.0, + right: 0.0, + top: 0.0, + bottom: 0.0, + }; + + /// The same margin on every side. + #[doc(alias = "symmetric")] + #[inline] + pub const fn same(margin: f32) -> Self { + Self { + left: margin, + right: margin, + top: margin, + bottom: margin, + } + } + + /// Margins with the same size on opposing sides + #[inline] + pub const fn symmetric(x: f32, y: f32) -> Self { + Self { + left: x, + right: x, + top: y, + bottom: y, + } + } + + /// Total margins on both sides + #[inline] + pub fn sum(&self) -> Vec2 { + vec2(self.left + self.right, self.top + self.bottom) + } + + #[inline] + pub const fn left_top(&self) -> Vec2 { + vec2(self.left, self.top) + } + + #[inline] + pub const fn right_bottom(&self) -> Vec2 { + vec2(self.right, self.bottom) + } + + /// Are the margin on every side the same? + #[doc(alias = "symmetric")] + #[inline] + pub fn is_same(&self) -> bool { + self.left == self.right && self.left == self.top && self.left == self.bottom + } + + #[deprecated = "Use `rect + margin` instead"] + #[inline] + pub fn expand_rect(&self, rect: Rect) -> Rect { + Rect::from_min_max(rect.min - self.left_top(), rect.max + self.right_bottom()) + } + + #[deprecated = "Use `rect - margin` instead"] + #[inline] + pub fn shrink_rect(&self, rect: Rect) -> Rect { + Rect::from_min_max(rect.min + self.left_top(), rect.max - self.right_bottom()) + } +} + +impl From for MarginF32 { + #[inline] + fn from(v: f32) -> Self { + Self::same(v) + } +} + +impl From for MarginF32 { + #[inline] + fn from(v: Vec2) -> Self { + Self::symmetric(v.x, v.y) + } +} + +/// `MarginF32 + MarginF32` +impl std::ops::Add for MarginF32 { + type Output = Self; + + #[inline] + fn add(self, other: Self) -> Self { + Self { + left: self.left + other.left, + right: self.right + other.right, + top: self.top + other.top, + bottom: self.bottom + other.bottom, + } + } +} + +/// `MarginF32 + f32` +impl std::ops::Add for MarginF32 { + type Output = Self; + + #[inline] + fn add(self, v: f32) -> Self { + Self { + left: self.left + v, + right: self.right + v, + top: self.top + v, + bottom: self.bottom + v, + } + } +} + +/// `Margind += f32` +impl std::ops::AddAssign for MarginF32 { + #[inline] + fn add_assign(&mut self, v: f32) { + self.left += v; + self.right += v; + self.top += v; + self.bottom += v; + } +} + +/// `MarginF32 * f32` +impl std::ops::Mul for MarginF32 { + type Output = Self; + + #[inline] + fn mul(self, v: f32) -> Self { + Self { + left: self.left * v, + right: self.right * v, + top: self.top * v, + bottom: self.bottom * v, + } + } +} + +/// `MarginF32 *= f32` +impl std::ops::MulAssign for MarginF32 { + #[inline] + fn mul_assign(&mut self, v: f32) { + self.left *= v; + self.right *= v; + self.top *= v; + self.bottom *= v; + } +} + +/// `MarginF32 / f32` +impl std::ops::Div for MarginF32 { + type Output = Self; + + #[inline] + fn div(self, v: f32) -> Self { + Self { + left: self.left / v, + right: self.right / v, + top: self.top / v, + bottom: self.bottom / v, + } + } +} + +/// `MarginF32 /= f32` +impl std::ops::DivAssign for MarginF32 { + #[inline] + fn div_assign(&mut self, v: f32) { + self.left /= v; + self.right /= v; + self.top /= v; + self.bottom /= v; + } +} + +/// `MarginF32 - MarginF32` +impl std::ops::Sub for MarginF32 { + type Output = Self; + + #[inline] + fn sub(self, other: Self) -> Self { + Self { + left: self.left - other.left, + right: self.right - other.right, + top: self.top - other.top, + bottom: self.bottom - other.bottom, + } + } +} + +/// `MarginF32 - f32` +impl std::ops::Sub for MarginF32 { + type Output = Self; + + #[inline] + fn sub(self, v: f32) -> Self { + Self { + left: self.left - v, + right: self.right - v, + top: self.top - v, + bottom: self.bottom - v, + } + } +} + +/// `MarginF32 -= f32` +impl std::ops::SubAssign for MarginF32 { + #[inline] + fn sub_assign(&mut self, v: f32) { + self.left -= v; + self.right -= v; + self.top -= v; + self.bottom -= v; + } +} + +/// `Rect + MarginF32` +impl std::ops::Add for Rect { + type Output = Self; + + #[inline] + fn add(self, margin: MarginF32) -> Self { + Self::from_min_max( + self.min - margin.left_top(), + self.max + margin.right_bottom(), + ) + } +} + +/// `Rect += MarginF32` +impl std::ops::AddAssign for Rect { + #[inline] + fn add_assign(&mut self, margin: MarginF32) { + *self = *self + margin; + } +} + +/// `Rect - MarginF32` +impl std::ops::Sub for Rect { + type Output = Self; + + #[inline] + fn sub(self, margin: MarginF32) -> Self { + Self::from_min_max( + self.min + margin.left_top(), + self.max - margin.right_bottom(), + ) + } +} + +/// `Rect -= MarginF32` +impl std::ops::SubAssign for Rect { + #[inline] + fn sub_assign(&mut self, margin: MarginF32) { + *self = *self - margin; + } +} diff --git a/vendor/epaint/src/mesh.rs b/vendor/epaint/src/mesh.rs new file mode 100644 index 0000000..d48c98b --- /dev/null +++ b/vendor/epaint/src/mesh.rs @@ -0,0 +1,359 @@ +use crate::{Color32, TextureId, WHITE_UV, emath}; +use emath::{Pos2, Rect, Rot2, TSTransform, Vec2}; + +/// The 2D vertex type. +/// +/// Should be friendly to send to GPU as is. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg(any(not(feature = "unity"), feature = "_override_unity"))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] +pub struct Vertex { + /// Logical pixel coordinates (points). + /// (0,0) is the top left corner of the screen. + pub pos: Pos2, // 64 bit + + /// Normalized texture coordinates. + /// (0, 0) is the top left corner of the texture. + /// (1, 1) is the bottom right corner of the texture. + pub uv: Pos2, // 64 bit + + /// sRGBA with premultiplied alpha + pub color: Color32, // 32 bit +} + +impl Vertex { + /// An untextured vertex + #[inline] + pub fn untextured(pos: Pos2, color: Color32) -> Self { + Self { + pos, + uv: WHITE_UV, + color, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg(all(feature = "unity", not(feature = "_override_unity")))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] +pub struct Vertex { + /// Logical pixel coordinates (points). + /// (0,0) is the top left corner of the screen. + pub pos: Pos2, // 64 bit + + /// sRGBA with premultiplied alpha + pub color: Color32, // 32 bit + + /// Normalized texture coordinates. + /// (0, 0) is the top left corner of the texture. + /// (1, 1) is the bottom right corner of the texture. + pub uv: Pos2, // 64 bit +} + +/// Textured triangles in two dimensions. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Mesh { + /// Draw as triangles (i.e. the length is always multiple of three). + /// + /// If you only support 16-bit indices you can use [`Mesh::split_to_u16`]. + /// + /// egui is NOT consistent with what winding order it uses, so turn off backface culling. + pub indices: Vec, + + /// The vertex data indexed by `indices`. + pub vertices: Vec, + + /// The texture to use when drawing these triangles. + pub texture_id: TextureId, + // TODO(emilk): bounding rectangle +} + +impl Mesh { + pub fn with_texture(texture_id: TextureId) -> Self { + Self { + texture_id, + ..Default::default() + } + } + + /// Restore to default state, but without freeing memory. + pub fn clear(&mut self) { + self.indices.clear(); + self.vertices.clear(); + self.vertices = Default::default(); + } + + /// Returns the amount of memory used by the vertices and indices. + pub fn bytes_used(&self) -> usize { + std::mem::size_of::() + + self.vertices.len() * std::mem::size_of::() + + self.indices.len() * std::mem::size_of::() + } + + /// Are all indices within the bounds of the contained vertices? + pub fn is_valid(&self) -> bool { + profiling::function_scope!(); + + if let Ok(n) = u32::try_from(self.vertices.len()) { + self.indices.iter().all(|&i| i < n) + } else { + false + } + } + + pub fn is_empty(&self) -> bool { + self.indices.is_empty() && self.vertices.is_empty() + } + + /// Iterate over the triangles of this mesh, returning vertex indices. + pub fn triangles(&self) -> impl Iterator + '_ { + self.indices + .chunks_exact(3) + .map(|chunk| [chunk[0], chunk[1], chunk[2]]) + } + + /// Calculate a bounding rectangle. + pub fn calc_bounds(&self) -> Rect { + let mut bounds = Rect::NOTHING; + for v in &self.vertices { + bounds.extend_with(v.pos); + } + bounds + } + + /// Append all the indices and vertices of `other` to `self`. + /// + /// Panics when `other` mesh has a different texture. + pub fn append(&mut self, other: Self) { + profiling::function_scope!(); + debug_assert!(other.is_valid(), "Other mesh is invalid"); + + if self.is_empty() { + *self = other; + } else { + self.append_ref(&other); + } + } + + /// Append all the indices and vertices of `other` to `self` without + /// taking ownership. + /// + /// Panics when `other` mesh has a different texture. + pub fn append_ref(&mut self, other: &Self) { + debug_assert!(other.is_valid(), "Other mesh is invalid"); + + if self.is_empty() { + self.texture_id = other.texture_id; + } else { + assert_eq!( + self.texture_id, other.texture_id, + "Can't merge Mesh using different textures" + ); + } + + let index_offset = self.vertices.len() as u32; + self.indices + .extend(other.indices.iter().map(|index| index + index_offset)); + self.vertices.extend(other.vertices.iter()); + } + + /// Add a colored vertex. + /// + /// Panics when the mesh has assigned a texture. + #[inline(always)] + pub fn colored_vertex(&mut self, pos: Pos2, color: Color32) { + debug_assert!( + self.texture_id == TextureId::default(), + "Mesh has an assigned texture" + ); + self.vertices.push(Vertex::untextured(pos, color)); + } + + /// Add a triangle. + #[inline(always)] + pub fn add_triangle(&mut self, a: u32, b: u32, c: u32) { + self.indices.extend_from_slice(&[a, b, c]); + } + + /// Make room for this many additional triangles (will reserve 3x as many indices). + /// See also `reserve_vertices`. + #[inline(always)] + pub fn reserve_triangles(&mut self, additional_triangles: usize) { + self.indices.reserve(3 * additional_triangles); + } + + /// Make room for this many additional vertices. + /// See also `reserve_triangles`. + #[inline(always)] + pub fn reserve_vertices(&mut self, additional: usize) { + self.vertices.reserve(additional); + } + + /// Rectangle with a texture and color. + #[inline(always)] + pub fn add_rect_with_uv(&mut self, rect: Rect, uv: Rect, color: Color32) { + #![expect(clippy::identity_op)] + let idx = self.vertices.len() as u32; + self.indices + .extend_from_slice(&[idx + 0, idx + 1, idx + 2, idx + 2, idx + 1, idx + 3]); + + self.vertices.extend_from_slice(&[ + Vertex { + pos: rect.left_top(), + uv: uv.left_top(), + color, + }, + Vertex { + pos: rect.right_top(), + uv: uv.right_top(), + color, + }, + Vertex { + pos: rect.left_bottom(), + uv: uv.left_bottom(), + color, + }, + Vertex { + pos: rect.right_bottom(), + uv: uv.right_bottom(), + color, + }, + ]); + } + + /// Uniformly colored rectangle. + #[inline(always)] + pub fn add_colored_rect(&mut self, rect: Rect, color: Color32) { + debug_assert!( + self.texture_id == TextureId::default(), + "Mesh has an assigned texture" + ); + self.add_rect_with_uv(rect, [WHITE_UV, WHITE_UV].into(), color); + } + + /// This is for platforms that only support 16-bit index buffers. + /// + /// Splits this mesh into many smaller meshes (if needed) + /// where the smaller meshes have 16-bit indices. + pub fn split_to_u16(self) -> Vec { + debug_assert!(self.is_valid(), "Mesh is invalid"); + + const MAX_SIZE: u32 = u16::MAX as u32; + + if self.vertices.len() <= MAX_SIZE as usize { + // Common-case optimization: + return vec![Mesh16 { + indices: self.indices.iter().map(|&i| i as u16).collect(), + vertices: self.vertices, + texture_id: self.texture_id, + }]; + } + + let mut output = vec![]; + let mut index_cursor = 0; + + while index_cursor < self.indices.len() { + let span_start = index_cursor; + let mut min_vindex = self.indices[index_cursor]; + let mut max_vindex = self.indices[index_cursor]; + + while index_cursor < self.indices.len() { + let (mut new_min, mut new_max) = (min_vindex, max_vindex); + for i in 0..3 { + let idx = self.indices[index_cursor + i]; + new_min = new_min.min(idx); + new_max = new_max.max(idx); + } + + let new_span_size = new_max - new_min + 1; // plus one, because it is an inclusive range + if new_span_size <= MAX_SIZE { + // Triangle fits + min_vindex = new_min; + max_vindex = new_max; + index_cursor += 3; + } else { + break; + } + } + + assert!( + index_cursor > span_start, + "One triangle spanned more than {MAX_SIZE} vertices" + ); + + let mesh = Mesh16 { + indices: self.indices[span_start..index_cursor] + .iter() + .map(|vi| { + #[expect(clippy::unwrap_used)] + { + u16::try_from(vi - min_vindex).unwrap() + } + }) + .collect(), + vertices: self.vertices[(min_vindex as usize)..=(max_vindex as usize)].to_vec(), + texture_id: self.texture_id, + }; + debug_assert!(mesh.is_valid(), "Mesh is invalid"); + output.push(mesh); + } + output + } + + /// Translate location by this much, in-place + pub fn translate(&mut self, delta: Vec2) { + for v in &mut self.vertices { + v.pos += delta; + } + } + + /// Transform the mesh in-place with the given transform. + pub fn transform(&mut self, transform: TSTransform) { + for v in &mut self.vertices { + v.pos = transform * v.pos; + } + } + + /// Rotate by some angle about an origin, in-place. + /// + /// Origin is a position in screen space. + pub fn rotate(&mut self, rot: Rot2, origin: Pos2) { + for v in &mut self.vertices { + v.pos = origin + rot * (v.pos - origin); + } + } +} + +// ---------------------------------------------------------------------------- + +/// A version of [`Mesh`] that uses 16-bit indices. +/// +/// This is produced by [`Mesh::split_to_u16`] and is meant to be used for legacy render backends. +pub struct Mesh16 { + /// Draw as triangles (i.e. the length is always multiple of three). + /// + /// egui is NOT consistent with what winding order it uses, so turn off backface culling. + pub indices: Vec, + + /// The vertex data indexed by `indices`. + pub vertices: Vec, + + /// The texture to use when drawing these triangles. + pub texture_id: TextureId, +} + +impl Mesh16 { + /// Are all indices within the bounds of the contained vertices? + pub fn is_valid(&self) -> bool { + if let Ok(n) = u16::try_from(self.vertices.len()) { + self.indices.iter().all(|&i| i < n) + } else { + false + } + } +} diff --git a/vendor/epaint/src/mutex.rs b/vendor/epaint/src/mutex.rs new file mode 100644 index 0000000..2720468 --- /dev/null +++ b/vendor/epaint/src/mutex.rs @@ -0,0 +1,277 @@ +//! Wrappers around `parking_lot` locks, with a simple deadlock detection mechanism. + +// ---------------------------------------------------------------------------- + +const DEADLOCK_DURATION: std::time::Duration = std::time::Duration::from_secs(10); + +/// Provides interior mutability. +/// +/// It's tailored for internal use in egui should only be used for short locks (as a guideline, +/// locks should never be held longer than a single frame). In debug builds, when a lock can't +/// be acquired within 10 seconds, we assume a deadlock and will panic. +/// +/// This is a thin wrapper around [`parking_lot::Mutex`]. +#[derive(Default)] +pub struct Mutex(parking_lot::Mutex); + +/// The lock you get from [`Mutex`]. +pub use parking_lot::MutexGuard; + +impl Mutex { + #[inline(always)] + pub fn new(val: T) -> Self { + Self(parking_lot::Mutex::new(val)) + } + + /// Try to acquire the lock. + /// + /// ## Panics + /// Will panic in debug builds if the lock can't be acquired within 10 seconds. + #[inline(always)] + #[cfg_attr(debug_assertions, track_caller)] + pub fn lock(&self) -> MutexGuard<'_, T> { + if cfg!(debug_assertions) { + self.0.try_lock_for(DEADLOCK_DURATION).unwrap_or_else(|| { + panic!( + "DEBUG PANIC: Failed to acquire Mutex after {}s. Deadlock?", + DEADLOCK_DURATION.as_secs() + ) + }) + } else { + self.0.lock() + } + } +} + +// ---------------------------------------------------------------------------- + +/// The lock you get from [`RwLock::read`]. +pub use parking_lot::MappedRwLockReadGuard as RwLockReadGuard; + +/// The lock you get from [`RwLock::write`]. +pub use parking_lot::MappedRwLockWriteGuard as RwLockWriteGuard; + +/// Provides interior mutability. +/// +/// It's tailored for internal use in egui should only be used for short locks (as a guideline, +/// locks should never be held longer than a single frame). In debug builds, when a lock can't +/// be acquired within 10 seconds, we assume a deadlock and will panic. +/// +/// This is a thin wrapper around [`parking_lot::RwLock`]. +#[derive(Default)] +pub struct RwLock(parking_lot::RwLock); + +impl RwLock { + #[inline(always)] + pub fn new(val: T) -> Self { + Self(parking_lot::RwLock::new(val)) + } +} + +impl RwLock { + /// Try to acquire read-access to the lock. + /// + /// ## Panics + /// Will panic in debug builds if the lock can't be acquired within 10 seconds. + #[inline(always)] + #[cfg_attr(debug_assertions, track_caller)] + pub fn read(&self) -> RwLockReadGuard<'_, T> { + let guard = if cfg!(debug_assertions) { + self.0.try_read_for(DEADLOCK_DURATION).unwrap_or_else(|| { + panic!( + "DEBUG PANIC: Failed to acquire RwLock read after {}s. Deadlock?", + DEADLOCK_DURATION.as_secs() + ) + }) + } else { + self.0.read() + }; + parking_lot::RwLockReadGuard::map(guard, |v| v) + } + + /// Try to acquire write-access to the lock. + /// + /// ## Panics + /// Will panic in debug builds if the lock can't be acquired within 10 seconds. + #[inline(always)] + #[cfg_attr(debug_assertions, track_caller)] + pub fn write(&self) -> RwLockWriteGuard<'_, T> { + let guard = if cfg!(debug_assertions) { + self.0.try_write_for(DEADLOCK_DURATION).unwrap_or_else(|| { + panic!( + "DEBUG PANIC: Failed to acquire RwLock write after {}s. Deadlock?", + DEADLOCK_DURATION.as_secs() + ) + }) + } else { + self.0.write() + }; + parking_lot::RwLockWriteGuard::map(guard, |v| v) + } +} + +// ---------------------------------------------------------------------------- + +impl Clone for Mutex +where + T: Clone, +{ + fn clone(&self) -> Self { + Self::new(self.lock().clone()) + } +} + +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + #![expect(clippy::disallowed_methods)] // Ok for tests + + use crate::mutex::Mutex; + use std::time::Duration; + + #[test] + fn lock_two_different_mutexes_single_thread() { + let one = Mutex::new(()); + let two = Mutex::new(()); + let _a = one.lock(); + let _b = two.lock(); + } + + #[test] + fn lock_multiple_threads() { + use std::sync::Arc; + let one = Arc::new(Mutex::new(())); + let our_lock = one.lock(); + let other_thread = { + let one = Arc::clone(&one); + std::thread::spawn(move || { + let _lock = one.lock(); + }) + }; + std::thread::sleep(Duration::from_millis(200)); + drop(our_lock); + other_thread.join().unwrap(); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(test)] +mod tests_rwlock { + #![expect(clippy::disallowed_methods)] // Ok for tests + + use crate::mutex::RwLock; + use std::time::Duration; + + #[test] + fn lock_two_different_rwlocks_single_thread() { + let one = RwLock::new(()); + let two = RwLock::new(()); + let _a = one.write(); + let _b = two.write(); + } + + #[test] + fn rwlock_multiple_threads() { + use std::sync::Arc; + let one = Arc::new(RwLock::new(())); + let our_lock = one.write(); + let other_thread1 = { + let one = Arc::clone(&one); + std::thread::spawn(move || { + let _ = one.write(); + }) + }; + let other_thread2 = { + let one = Arc::clone(&one); + std::thread::spawn(move || { + let _ = one.read(); + }) + }; + std::thread::sleep(Duration::from_millis(200)); + drop(our_lock); + other_thread1.join().unwrap(); + other_thread2.join().unwrap(); + } + + #[test] + #[should_panic] + fn rwlock_write_write_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.write(); + let _a2 = one.write(); // panics + } + + #[test] + #[should_panic] + fn rwlock_write_read_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.write(); + let _a2 = one.read(); // panics + } + + #[test] + #[should_panic] + fn rwlock_read_write_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.read(); + let _a2 = one.write(); // panics + } + + #[test] + fn rwlock_read_read_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.read(); + // This is legal: this test suite specifically targets native, which relies + // on parking_lot's rw-locks, which are reentrant. + let _a2 = one.read(); + } + + #[test] + fn rwlock_short_read_foreign_read_write_reentrancy() { + use std::sync::Arc; + + let lock = Arc::new(RwLock::new(())); + + // Thread #0 grabs a read lock + let t0r0 = lock.read(); + + // Thread #1 grabs the same read lock + let other_thread = { + let lock = Arc::clone(&lock); + std::thread::spawn(move || { + let _t1r0 = lock.read(); + }) + }; + other_thread.join().unwrap(); + + // Thread #0 releases its read lock + drop(t0r0); + + // Thread #0 now grabs a write lock, which is legal + let _t0w0 = lock.write(); + } + + #[test] + #[should_panic] + fn rwlock_read_foreign_read_write_reentrancy() { + use std::sync::Arc; + + let lock = Arc::new(RwLock::new(())); + + // Thread #0 grabs a read lock + let _t0r0 = lock.read(); + + // Thread #1 grabs the same read lock + let other_thread = { + let lock = Arc::clone(&lock); + std::thread::spawn(move || { + let _t1r0 = lock.read(); + }) + }; + other_thread.join().unwrap(); + + // Thread #0 now grabs a write lock, which should panic (read-write) + let _t0w0 = lock.write(); // panics + } +} diff --git a/vendor/epaint/src/shadow.rs b/vendor/epaint/src/shadow.rs new file mode 100644 index 0000000..ace5ab9 --- /dev/null +++ b/vendor/epaint/src/shadow.rs @@ -0,0 +1,85 @@ +use crate::{Color32, CornerRadius, MarginF32, Rect, RectShape, Vec2}; + +/// The color and fuzziness of a fuzzy shape. +/// +/// Can be used for a rectangular shadow with a soft penumbra. +/// +/// Very similar to a box-shadow in CSS. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Shadow { + /// Move the shadow by this much. + /// + /// For instance, a value of `[1.0, 2.0]` will move the shadow 1 point to the right and 2 points down, + /// causing a drop-shadow effect. + pub offset: [i8; 2], + + /// The width of the blur, i.e. the width of the fuzzy penumbra. + /// + /// A value of 0 means a sharp shadow. + pub blur: u8, + + /// Expand the shadow in all directions by this much. + pub spread: u8, + + /// Color of the opaque center of the shadow. + pub color: Color32, +} + +#[test] +fn shadow_size() { + assert_eq!( + std::mem::size_of::(), + 8, + "Shadow changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); +} + +impl Shadow { + /// No shadow at all. + pub const NONE: Self = Self { + offset: [0, 0], + blur: 0, + spread: 0, + color: Color32::TRANSPARENT, + }; + + /// The argument is the rectangle of the shadow caster. + pub fn as_shape(&self, rect: Rect, corner_radius: impl Into) -> RectShape { + // tessellator.clip_rect = clip_rect; // TODO(emilk): culling + + let Self { + offset, + blur, + spread, + color, + } = *self; + let [offset_x, offset_y] = offset; + + let rect = rect + .translate(Vec2::new(offset_x as _, offset_y as _)) + .expand(spread as _); + let corner_radius = corner_radius.into() + CornerRadius::from(spread); + + RectShape::filled(rect, corner_radius, color).with_blur_width(blur as _) + } + + /// How much larger than the parent rect are we in each direction? + pub fn margin(&self) -> MarginF32 { + let Self { + offset, + blur, + spread, + color: _, + } = *self; + let spread = spread as f32; + let blur = blur as f32; + let [offset_x, offset_y] = offset; + MarginF32 { + left: spread + 0.5 * blur - offset_x as f32, + right: spread + 0.5 * blur + offset_x as f32, + top: spread + 0.5 * blur - offset_y as f32, + bottom: spread + 0.5 * blur + offset_y as f32, + } + } +} diff --git a/vendor/epaint/src/shape_transform.rs b/vendor/epaint/src/shape_transform.rs new file mode 100644 index 0000000..71cc133 --- /dev/null +++ b/vendor/epaint/src/shape_transform.rs @@ -0,0 +1,136 @@ +use std::sync::Arc; + +use crate::{ + CircleShape, Color32, ColorMode, CubicBezierShape, EllipseShape, Mesh, PathShape, + QuadraticBezierShape, RectShape, Shape, TextShape, color, +}; + +/// Remember to handle [`Color32::PLACEHOLDER`] specially! +pub fn adjust_colors( + shape: &mut Shape, + adjust_color: impl Fn(&mut Color32) + Send + Sync + Copy + 'static, +) { + #![expect(clippy::match_same_arms)] + match shape { + Shape::Noop => {} + + Shape::Vec(shapes) => { + for shape in shapes { + adjust_colors(shape, adjust_color); + } + } + + Shape::LineSegment { stroke, points: _ } => { + adjust_color(&mut stroke.color); + } + + Shape::Path(PathShape { + points: _, + closed: _, + fill, + stroke, + }) + | Shape::QuadraticBezier(QuadraticBezierShape { + points: _, + closed: _, + fill, + stroke, + }) + | Shape::CubicBezier(CubicBezierShape { + points: _, + closed: _, + fill, + stroke, + }) => { + adjust_color(fill); + adjust_color_mode(&mut stroke.color, adjust_color); + } + + Shape::Circle(CircleShape { + center: _, + radius: _, + fill, + stroke, + }) + | Shape::Ellipse(EllipseShape { + center: _, + radius: _, + fill, + stroke, + angle: _, + }) + | Shape::Rect(RectShape { + rect: _, + corner_radius: _, + fill, + stroke, + stroke_kind: _, + round_to_pixels: _, + blur_width: _, + brush: _, + angle: _, + }) => { + adjust_color(fill); + adjust_color(&mut stroke.color); + } + + Shape::Text(TextShape { + pos: _, + galley, + underline, + fallback_color, + override_text_color, + opacity_factor: _, + angle: _, + }) => { + adjust_color(&mut underline.color); + adjust_color(fallback_color); + if let Some(override_text_color) = override_text_color { + adjust_color(override_text_color); + } + + if !galley.is_empty() { + let galley = Arc::make_mut(galley); + for placed_row in &mut galley.rows { + let row = Arc::make_mut(&mut placed_row.row); + for vertex in &mut row.visuals.mesh.vertices { + adjust_color(&mut vertex.color); + } + } + } + } + + Shape::Mesh(mesh) => { + let Mesh { + indices: _, + vertices, + texture_id: _, + } = Arc::make_mut(mesh); + + for v in vertices { + adjust_color(&mut v.color); + } + } + + Shape::Callback(_) => { + // Can't tint user callback code + } + } +} + +fn adjust_color_mode( + color_mode: &mut ColorMode, + adjust_color: impl Fn(&mut Color32) + Send + Sync + Copy + 'static, +) { + match color_mode { + color::ColorMode::Solid(color) => adjust_color(color), + color::ColorMode::UV(callback) => { + let callback = Arc::clone(callback); + *color_mode = color::ColorMode::UV(Arc::new(Box::new(move |rect, pos| { + let mut color = callback(rect, pos); + adjust_color(&mut color); + color + }))); + } + } +} diff --git a/vendor/epaint/src/shapes/bezier_shape.rs b/vendor/epaint/src/shapes/bezier_shape.rs new file mode 100644 index 0000000..b20c566 --- /dev/null +++ b/vendor/epaint/src/shapes/bezier_shape.rs @@ -0,0 +1,1137 @@ +#![expect(clippy::many_single_char_names)] + +use std::ops::Range; + +use crate::{Color32, PathShape, PathStroke, Shape}; +use emath::{Pos2, Rect, RectTransform}; + +// ---------------------------------------------------------------------------- + +/// A cubic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). +/// +/// See also [`QuadraticBezierShape`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CubicBezierShape { + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle points are the control points. + pub points: [Pos2; 4], + pub closed: bool, + + pub fill: Color32, + pub stroke: PathStroke, +} + +impl CubicBezierShape { + /// Creates a cubic Bézier curve based on 4 points and stroke. + /// + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle points are the control points. + pub fn from_points_stroke( + points: [Pos2; 4], + closed: bool, + fill: Color32, + stroke: impl Into, + ) -> Self { + Self { + points, + closed, + fill, + stroke: stroke.into(), + } + } + + /// Transform the curve with the given transform. + pub fn transform(&self, transform: &RectTransform) -> Self { + let mut points = [Pos2::default(); 4]; + for (i, origin_point) in self.points.iter().enumerate() { + points[i] = transform * *origin_point; + } + Self { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + /// Convert the cubic Bézier curve to one or two [`PathShape`]'s. + /// When the curve is closed and it has to intersect with the base line, it will be converted into two shapes. + /// Otherwise, it will be converted into one shape. + /// The `tolerance` will be used to control the max distance between the curve and the base line. + /// The `epsilon` is used when comparing two floats. + pub fn to_path_shapes(&self, tolerance: Option, epsilon: Option) -> Vec { + let mut pathshapes = Vec::new(); + let mut points_vec = self.flatten_closed(tolerance, epsilon); + for points in points_vec.drain(..) { + let pathshape = PathShape { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + }; + pathshapes.push(pathshape); + } + pathshapes + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + self.logical_bounding_rect().expand(self.stroke.width / 2.0) + } + } + + /// Logical bounding rectangle (ignoring stroke width) + pub fn logical_bounding_rect(&self) -> Rect { + //temporary solution + let (mut min_x, mut max_x) = if self.points[0].x < self.points[3].x { + (self.points[0].x, self.points[3].x) + } else { + (self.points[3].x, self.points[0].x) + }; + let (mut min_y, mut max_y) = if self.points[0].y < self.points[3].y { + (self.points[0].y, self.points[3].y) + } else { + (self.points[3].y, self.points[0].y) + }; + + // find the inflection points and get the x value + cubic_for_each_local_extremum( + self.points[0].x, + self.points[1].x, + self.points[2].x, + self.points[3].x, + &mut |t| { + let x = self.sample(t).x; + if x < min_x { + min_x = x; + } + if x > max_x { + max_x = x; + } + }, + ); + + // find the inflection points and get the y value + cubic_for_each_local_extremum( + self.points[0].y, + self.points[1].y, + self.points[2].y, + self.points[3].y, + &mut |t| { + let y = self.sample(t).y; + if y < min_y { + min_y = y; + } + if y > max_y { + max_y = y; + } + }, + ); + + Rect { + min: Pos2 { x: min_x, y: min_y }, + max: Pos2 { x: max_x, y: max_y }, + } + } + + /// split the original cubic curve into a new one within a range. + pub fn split_range(&self, t_range: Range) -> Self { + debug_assert!( + 0.0 <= t_range.start && t_range.end <= 1.0 && t_range.start <= t_range.end, + "range should be in [0.0,1.0]" + ); + + let from = self.sample(t_range.start); + let to = self.sample(t_range.end); + + let d_from = self.points[1] - self.points[0].to_vec2(); + let d_ctrl = self.points[2] - self.points[1].to_vec2(); + let d_to = self.points[3] - self.points[2].to_vec2(); + let q = QuadraticBezierShape { + points: [d_from, d_ctrl, d_to], + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + }; + let delta_t = t_range.end - t_range.start; + let q_start = q.sample(t_range.start); + let q_end = q.sample(t_range.end); + let ctrl1 = from + q_start.to_vec2() * delta_t; + let ctrl2 = to - q_end.to_vec2() * delta_t; + + Self { + points: [from, ctrl1, ctrl2, to], + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + // copied from + // Computes the number of quadratic bézier segments to approximate a cubic one. + // Derived by Raph Levien from section 10.6 of Sedeberg's CAGD notes + // https://scholarsarchive.byu.edu/cgi/viewcontent.cgi?article=1000&context=facpub#section.10.6 + // and the error metric from the caffein owl blog post http://caffeineowl.com/graphics/2d/vectorial/cubic2quad01.html + pub fn num_quadratics(&self, tolerance: f32) -> u32 { + debug_assert!(tolerance > 0.0, "the tolerance should be positive"); + + let x = + self.points[0].x - 3.0 * self.points[1].x + 3.0 * self.points[2].x - self.points[3].x; + let y = + self.points[0].y - 3.0 * self.points[1].y + 3.0 * self.points[2].y - self.points[3].y; + let err = x * x + y * y; + + (err / (432.0 * tolerance * tolerance)) + .powf(1.0 / 6.0) + .ceil() + .max(1.0) as u32 + } + + /// Find out the t value for the point where the curve is intersected with the base line. + /// The base line is the line from P0 to P3. + /// If the curve only has two intersection points with the base line, they should be 0.0 and 1.0. + /// In this case, the "fill" will be simple since the curve is a convex line. + /// If the curve has more than two intersection points with the base line, the "fill" will be a problem. + /// We need to find out where is the 3rd t value (0 0, there will be one real root, two complex roots + /// when p = 0, there will be two real roots, when p=q=0, there will be three real roots but all 0. + /// when p < 0, there will be three unique real roots. this is what we need. (x1, x2, x3) + /// t = x + b / (3 * a), then we have: t1, t2, t3. + /// the one between 0.0 and 1.0 is what we need. + /// <`https://baike.baidu.com/item/%E4%B8%80%E5%85%83%E4%B8%89%E6%AC%A1%E6%96%B9%E7%A8%8B/8388473 /`> + /// + pub fn find_cross_t(&self, epsilon: f32) -> Option { + let p0 = self.points[0]; + let p1 = self.points[1]; + let p2 = self.points[2]; + let p3 = self.points[3]; + + let a = (p3.x - 3.0 * p2.x + 3.0 * p1.x - p0.x) * (p3.y - p0.y) + - (p3.y - 3.0 * p2.y + 3.0 * p1.y - p0.y) * (p3.x - p0.x); + let b = (3.0 * p2.x - 6.0 * p1.x + 3.0 * p0.x) * (p3.y - p0.y) + - (3.0 * p2.y - 6.0 * p1.y + 3.0 * p0.y) * (p3.x - p0.x); + let c = + (3.0 * p1.x - 3.0 * p0.x) * (p3.y - p0.y) - (3.0 * p1.y - 3.0 * p0.y) * (p3.x - p0.x); + let d = p0.x * (p3.y - p0.y) - p0.y * (p3.x - p0.x) + + p0.x * (p0.y - p3.y) + + p0.y * (p3.x - p0.x); + + let h = -b / (3.0 * a); + let p = (3.0 * a * c - b * b) / (3.0 * a * a); + let q = (2.0 * b * b * b - 9.0 * a * b * c + 27.0 * a * a * d) / (27.0 * a * a * a); + + if p > 0.0 { + return None; + } + let r = (-(p / 3.0).powi(3)).sqrt(); + let theta = (-q / (2.0 * r)).acos() / 3.0; + + let t1 = 2.0 * r.cbrt() * theta.cos() + h; + let t2 = 2.0 * r.cbrt() * (theta + 120.0 * std::f32::consts::PI / 180.0).cos() + h; + let t3 = 2.0 * r.cbrt() * (theta + 240.0 * std::f32::consts::PI / 180.0).cos() + h; + + if t1 > epsilon && t1 < 1.0 - epsilon { + return Some(t1); + } + if t2 > epsilon && t2 < 1.0 - epsilon { + return Some(t2); + } + if t3 > epsilon && t3 < 1.0 - epsilon { + return Some(t3); + } + None + } + + /// Calculate the point (x,y) at t based on the cubic Bézier curve equation. + /// t is in [0.0,1.0] + /// [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B.C3.A9zier_curves) + /// + pub fn sample(&self, t: f32) -> Pos2 { + debug_assert!( + t >= 0.0 && t <= 1.0, + "the sample value should be in [0.0,1.0]" + ); + + let h = 1.0 - t; + let a = t * t * t; + let b = 3.0 * t * t * h; + let c = 3.0 * t * h * h; + let d = h * h * h; + let result = self.points[3].to_vec2() * a + + self.points[2].to_vec2() * b + + self.points[1].to_vec2() * c + + self.points[0].to_vec2() * d; + result.to_pos2() + } + + /// find a set of points that approximate the cubic Bézier curve. + /// the number of points is determined by the tolerance. + /// the points may not be evenly distributed in the range [0.0,1.0] (t value) + pub fn flatten(&self, tolerance: Option) -> Vec { + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[3].x).abs() * 0.001); + let mut result = vec![self.points[0]]; + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + result.push(p); + }); + result + } + + /// find a set of points that approximate the cubic Bézier curve. + /// the number of points is determined by the tolerance. + /// the points may not be evenly distributed in the range [0.0,1.0] (t value) + /// this api will check whether the curve will cross the base line or not when closed = true. + /// The result will be a vec of vec of Pos2. it will store two closed aren in different vec. + /// The epsilon is used to compare a float value. + pub fn flatten_closed(&self, tolerance: Option, epsilon: Option) -> Vec> { + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[3].x).abs() * 0.001); + let epsilon = epsilon.unwrap_or(1.0e-5); + let mut result = Vec::new(); + let mut first_half = Vec::new(); + let mut second_half = Vec::new(); + let mut flipped = false; + first_half.push(self.points[0]); + + let cross = self.find_cross_t(epsilon); + match cross { + Some(cross) => { + if self.closed { + self.for_each_flattened_with_t(tolerance, &mut |p, t| { + if t < cross { + first_half.push(p); + } else { + if !flipped { + // when just crossed the base line, flip the order of the points + // add the cross point to the first half as the last point + // and add the cross point to the second half as the first point + flipped = true; + let cross_point = self.sample(cross); + first_half.push(cross_point); + second_half.push(cross_point); + } + second_half.push(p); + } + }); + } else { + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + first_half.push(p); + }); + } + } + None => { + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + first_half.push(p); + }); + } + } + + result.push(first_half); + if !second_half.is_empty() { + result.push(second_half); + } + result + } + // from lyon_geom::cubic_bezier.rs + /// Iterates through the curve invoking a callback at each point. + pub fn for_each_flattened_with_t(&self, tolerance: f32, callback: &mut F) { + flatten_cubic_bezier_with_t(self, tolerance, callback); + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: CubicBezierShape) -> Self { + Self::CubicBezier(shape) + } +} + +// ---------------------------------------------------------------------------- + +/// A quadratic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). +/// +/// See also [`CubicBezierShape`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct QuadraticBezierShape { + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle point is the control points. + pub points: [Pos2; 3], + pub closed: bool, + + pub fill: Color32, + pub stroke: PathStroke, +} + +impl QuadraticBezierShape { + /// Create a new quadratic Bézier shape based on the 3 points and stroke. + /// + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle point is the control points. + /// The points should be in the order [start, control, end] + pub fn from_points_stroke( + points: [Pos2; 3], + closed: bool, + fill: Color32, + stroke: impl Into, + ) -> Self { + Self { + points, + closed, + fill, + stroke: stroke.into(), + } + } + + /// Transform the curve with the given transform. + pub fn transform(&self, transform: &RectTransform) -> Self { + let mut points = [Pos2::default(); 3]; + for (i, origin_point) in self.points.iter().enumerate() { + points[i] = transform * *origin_point; + } + Self { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + /// Convert the quadratic Bézier curve to one [`PathShape`]. + /// The `tolerance` will be used to control the max distance between the curve and the base line. + pub fn to_path_shape(&self, tolerance: Option) -> PathShape { + let points = self.flatten(tolerance); + PathShape { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + self.logical_bounding_rect().expand(self.stroke.width / 2.0) + } + } + + /// Logical bounding rectangle (ignoring stroke width) + pub fn logical_bounding_rect(&self) -> Rect { + let (mut min_x, mut max_x) = if self.points[0].x < self.points[2].x { + (self.points[0].x, self.points[2].x) + } else { + (self.points[2].x, self.points[0].x) + }; + let (mut min_y, mut max_y) = if self.points[0].y < self.points[2].y { + (self.points[0].y, self.points[2].y) + } else { + (self.points[2].y, self.points[0].y) + }; + + quadratic_for_each_local_extremum( + self.points[0].x, + self.points[1].x, + self.points[2].x, + &mut |t| { + let x = self.sample(t).x; + if x < min_x { + min_x = x; + } + if x > max_x { + max_x = x; + } + }, + ); + + quadratic_for_each_local_extremum( + self.points[0].y, + self.points[1].y, + self.points[2].y, + &mut |t| { + let y = self.sample(t).y; + if y < min_y { + min_y = y; + } + if y > max_y { + max_y = y; + } + }, + ); + + Rect { + min: Pos2 { x: min_x, y: min_y }, + max: Pos2 { x: max_x, y: max_y }, + } + } + + /// Calculate the point (x,y) at t based on the quadratic Bézier curve equation. + /// t is in [0.0,1.0] + /// [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B.C3.A9zier_curves) + /// + pub fn sample(&self, t: f32) -> Pos2 { + debug_assert!( + t >= 0.0 && t <= 1.0, + "the sample value should be in [0.0,1.0]" + ); + + let h = 1.0 - t; + let a = t * t; + let b = 2.0 * t * h; + let c = h * h; + let result = self.points[2].to_vec2() * a + + self.points[1].to_vec2() * b + + self.points[0].to_vec2() * c; + result.to_pos2() + } + + /// find a set of points that approximate the quadratic Bézier curve. + /// the number of points is determined by the tolerance. + /// the points may not be evenly distributed in the range [0.0,1.0] (t value) + pub fn flatten(&self, tolerance: Option) -> Vec { + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[2].x).abs() * 0.001); + let mut result = vec![self.points[0]]; + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + result.push(p); + }); + result + } + + // copied from https://docs.rs/lyon_geom/latest/lyon_geom/ + /// Compute a flattened approximation of the curve, invoking a callback at + /// each step. + /// + /// The callback takes the point and corresponding curve parameter at each step. + /// + /// This implements the algorithm described by Raph Levien at + /// + pub fn for_each_flattened_with_t(&self, tolerance: f32, callback: &mut F) + where + F: FnMut(Pos2, f32), + { + let params = FlatteningParameters::from_curve(self, tolerance); + if params.is_point { + return; + } + + let count = params.count as u32; + for index in 1..count { + let t = params.t_at_iteration(index as f32); + + callback(self.sample(t), t); + } + + callback(self.sample(1.0), 1.0); + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: QuadraticBezierShape) -> Self { + Self::QuadraticBezier(shape) + } +} + +// ---------------------------------------------------------------------------- + +// lyon_geom::flatten_cubic.rs +// copied from https://docs.rs/lyon_geom/latest/lyon_geom/ +fn flatten_cubic_bezier_with_t( + curve: &CubicBezierShape, + tolerance: f32, + callback: &mut F, +) { + // debug_assert!(tolerance >= S::EPSILON * S::EPSILON); + let quadratics_tolerance = tolerance * 0.2; + let flattening_tolerance = tolerance * 0.8; + + let num_quadratics = curve.num_quadratics(quadratics_tolerance); + let step = 1.0 / num_quadratics as f32; + let n = num_quadratics; + let mut t0 = 0.0; + for _ in 0..(n - 1) { + let t1 = t0 + step; + + let quadratic = single_curve_approximation(&curve.split_range(t0..t1)); + quadratic.for_each_flattened_with_t(flattening_tolerance, &mut |point, t_sub| { + let t = t0 + step * t_sub; + callback(point, t); + }); + + t0 = t1; + } + + // Do the last step manually to make sure we finish at t = 1.0 exactly. + let quadratic = single_curve_approximation(&curve.split_range(t0..1.0)); + quadratic.for_each_flattened_with_t(flattening_tolerance, &mut |point, t_sub| { + let t = t0 + step * t_sub; + callback(point, t); + }); +} + +// from lyon_geom::quadratic_bezier.rs +// copied from https://docs.rs/lyon_geom/latest/lyon_geom/ +struct FlatteningParameters { + count: f32, + integral_from: f32, + integral_step: f32, + inv_integral_from: f32, + div_inv_integral_diff: f32, + is_point: bool, +} + +impl FlatteningParameters { + // https://raphlinus.github.io/graphics/curves/2019/12/23/flatten-quadbez.html + pub fn from_curve(curve: &QuadraticBezierShape, tolerance: f32) -> Self { + #![expect(clippy::useless_let_if_seq)] + + // Map the quadratic bézier segment to y = x^2 parabola. + let from = curve.points[0]; + let ctrl = curve.points[1]; + let to = curve.points[2]; + + let ddx = 2.0 * ctrl.x - from.x - to.x; + let ddy = 2.0 * ctrl.y - from.y - to.y; + let cross = (to.x - from.x) * ddy - (to.y - from.y) * ddx; + let inv_cross = 1.0 / cross; + let parabola_from = ((ctrl.x - from.x) * ddx + (ctrl.y - from.y) * ddy) * inv_cross; + let parabola_to = ((to.x - ctrl.x) * ddx + (to.y - ctrl.y) * ddy) * inv_cross; + // Note, scale can be NaN, for example with straight lines. When it happens the NaN will + // propagate to other parameters. We catch it all by setting the iteration count to zero + // and leave the rest as garbage. + let scale = cross.abs() / (ddx.hypot(ddy) * (parabola_to - parabola_from).abs()); + + let integral_from = approx_parabola_integral(parabola_from); + let integral_to = approx_parabola_integral(parabola_to); + let integral_diff = integral_to - integral_from; + + let inv_integral_from = approx_parabola_inv_integral(integral_from); + let inv_integral_to = approx_parabola_inv_integral(integral_to); + let div_inv_integral_diff = 1.0 / (inv_integral_to - inv_integral_from); + + // the original author thinks it can be stored as integer if it's not generic. + // but if so, we have to handle the edge case of the integral being infinite. + let mut count = (0.5 * integral_diff.abs() * (scale / tolerance).sqrt()).ceil(); + let mut is_point = false; + // If count is NaN the curve can be approximated by a single straight line or a point. + if !count.is_finite() { + count = 0.0; + is_point = (to.x - from.x).hypot(to.y - from.y) < tolerance * tolerance; + } + + let integral_step = integral_diff / count; + + Self { + count, + integral_from, + integral_step, + inv_integral_from, + div_inv_integral_diff, + is_point, + } + } + + fn t_at_iteration(&self, iteration: f32) -> f32 { + let u = approx_parabola_inv_integral(self.integral_from + self.integral_step * iteration); + (u - self.inv_integral_from) * self.div_inv_integral_diff + } +} + +/// Compute an approximation to integral (1 + 4x^2) ^ -0.25 dx used in the flattening code. +fn approx_parabola_integral(x: f32) -> f32 { + let d: f32 = 0.67; + let quarter = 0.25; + x / (1.0 - d + (d.powi(4) + quarter * x * x).sqrt().sqrt()) +} + +/// Approximate the inverse of the function above. +fn approx_parabola_inv_integral(x: f32) -> f32 { + let b = 0.39; + let quarter = 0.25; + x * (1.0 - b + (b * b + quarter * x * x).sqrt()) +} + +fn single_curve_approximation(curve: &CubicBezierShape) -> QuadraticBezierShape { + let c1_x = (curve.points[1].x * 3.0 - curve.points[0].x) * 0.5; + let c1_y = (curve.points[1].y * 3.0 - curve.points[0].y) * 0.5; + let c2_x = (curve.points[2].x * 3.0 - curve.points[3].x) * 0.5; + let c2_y = (curve.points[2].y * 3.0 - curve.points[3].y) * 0.5; + let c = Pos2 { + x: (c1_x + c2_x) * 0.5, + y: (c1_y + c2_y) * 0.5, + }; + QuadraticBezierShape { + points: [curve.points[0], c, curve.points[3]], + closed: curve.closed, + fill: curve.fill, + stroke: curve.stroke.clone(), + } +} + +fn quadratic_for_each_local_extremum(p0: f32, p1: f32, p2: f32, cb: &mut F) { + // A quadratic Bézier curve can be derived by a linear function: + // p(t) = p0 + t(p1 - p0) + t^2(p2 - 2p1 + p0) + // The derivative is: + // p'(t) = (p1 - p0) + 2(p2 - 2p1 + p0)t or: + // f(x) = a* x + b + let a = p2 - 2.0 * p1 + p0; + // let b = p1 - p0; + // no need to check for zero, since we're only interested in local extrema + if a == 0.0 { + return; + } + + let t = (p0 - p1) / a; + if t > 0.0 && t < 1.0 { + cb(t); + } +} + +fn cubic_for_each_local_extremum(p0: f32, p1: f32, p2: f32, p3: f32, cb: &mut F) { + // See www.faculty.idc.ac.il/arik/quality/appendixa.html for an explanation + // A cubic Bézier curve can be derived by the following equation: + // B'(t) = 3(1-t)^2(p1-p0) + 6(1-t)t(p2-p1) + 3t^2(p3-p2) or + // f(x) = a * x² + b * x + c + let a = 3.0 * (p3 + 3.0 * (p1 - p2) - p0); + let b = 6.0 * (p2 - 2.0 * p1 + p0); + let c = 3.0 * (p1 - p0); + + let in_range = |t: f32| t <= 1.0 && t >= 0.0; + + // linear situation + if a == 0.0 { + if b != 0.0 { + let t = -c / b; + if in_range(t) { + cb(t); + } + } + return; + } + + let discr = b * b - 4.0 * a * c; + // no Real solution + if discr < 0.0 { + return; + } + + // one Real solution + if discr == 0.0 { + let t = -b / (2.0 * a); + if in_range(t) { + cb(t); + } + return; + } + + // two Real solutions + let discr = discr.sqrt(); + let t1 = (-b - discr) / (2.0 * a); + let t2 = (-b + discr) / (2.0 * a); + if in_range(t1) { + cb(t1); + } + if in_range(t2) { + cb(t2); + } +} + +#[cfg(test)] +mod tests { + use emath::pos2; + + use super::*; + + #[test] + fn test_quadratic_bounding_box() { + let curve = QuadraticBezierShape { + points: [ + Pos2 { x: 110.0, y: 170.0 }, + Pos2 { x: 10.0, y: 10.0 }, + Pos2 { x: 180.0, y: 30.0 }, + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + let bbox = curve.logical_bounding_rect(); + assert!((bbox.min.x - 72.96).abs() < 0.01); + assert!((bbox.min.y - 27.78).abs() < 0.01); + + assert!((bbox.max.x - 180.0).abs() < 0.01); + assert!((bbox.max.y - 170.0).abs() < 0.01); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 26); + + let curve = QuadraticBezierShape { + points: [ + Pos2 { x: 110.0, y: 170.0 }, + Pos2 { x: 180.0, y: 30.0 }, + Pos2 { x: 10.0, y: 10.0 }, + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + let bbox = curve.logical_bounding_rect(); + assert!((bbox.min.x - 10.0).abs() < 0.01); + assert!((bbox.min.y - 10.0).abs() < 0.01); + + assert!((bbox.max.x - 130.42).abs() < 0.01); + assert!((bbox.max.y - 170.0).abs() < 0.01); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 25); + } + + #[test] + fn test_quadratic_different_tolerance() { + let curve = QuadraticBezierShape { + points: [ + Pos2 { x: 110.0, y: 170.0 }, + Pos2 { x: 180.0, y: 30.0 }, + Pos2 { x: 10.0, y: 10.0 }, + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(1.0, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 9); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 25); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 77); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.001, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 240); + } + + #[test] + fn test_cubic_bounding_box() { + let curve = CubicBezierShape { + points: [ + pos2(10.0, 10.0), + pos2(110.0, 170.0), + pos2(180.0, 30.0), + pos2(270.0, 210.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let bbox = curve.logical_bounding_rect(); + assert_eq!(bbox.min.x, 10.0); + assert_eq!(bbox.min.y, 10.0); + assert_eq!(bbox.max.x, 270.0); + assert_eq!(bbox.max.y, 210.0); + + let curve = CubicBezierShape { + points: [ + pos2(10.0, 10.0), + pos2(110.0, 170.0), + pos2(270.0, 210.0), + pos2(180.0, 30.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let bbox = curve.logical_bounding_rect(); + assert_eq!(bbox.min.x, 10.0); + assert_eq!(bbox.min.y, 10.0); + assert!((bbox.max.x - 206.50).abs() < 0.01); + assert!((bbox.max.y - 148.48).abs() < 0.01); + + let curve = CubicBezierShape { + points: [ + pos2(110.0, 170.0), + pos2(10.0, 10.0), + pos2(270.0, 210.0), + pos2(180.0, 30.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let bbox = curve.logical_bounding_rect(); + assert!((bbox.min.x - 86.71).abs() < 0.01); + assert!((bbox.min.y - 30.0).abs() < 0.01); + + assert!((bbox.max.x - 199.27).abs() < 0.01); + assert!((bbox.max.y - 170.0).abs() < 0.01); + } + + #[test] + fn test_cubic_different_tolerance_flattening() { + let curve = CubicBezierShape { + points: [ + pos2(0.0, 0.0), + pos2(100.0, 0.0), + pos2(100.0, 100.0), + pos2(100.0, 200.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(1.0, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 10); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.5, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 13); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 28); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 83); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.001, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 248); + } + + #[test] + fn test_cubic_different_shape_flattening() { + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(30.0, 170.0), + pos2(210.0, 170.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 117); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(90.0, 170.0), + pos2(170.0, 170.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 91); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(150.0, 170.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 75); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(230.0, 110.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 100); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(210.0, 70.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 71); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(150.0, 50.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 88); + } + + #[test] + fn test_quadratic_flattening() { + let curve = QuadraticBezierShape { + points: [pos2(0.0, 0.0), pos2(80.0, 200.0), pos2(100.0, 30.0)], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(1.0, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 9); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.5, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 11); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 24); + + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 72); + let mut result = vec![curve.points[0]]; //add the start point + curve.for_each_flattened_with_t(0.001, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 223); + } +} diff --git a/vendor/epaint/src/shapes/circle_shape.rs b/vendor/epaint/src/shapes/circle_shape.rs new file mode 100644 index 0000000..a86ae3f --- /dev/null +++ b/vendor/epaint/src/shapes/circle_shape.rs @@ -0,0 +1,52 @@ +use crate::{Color32, Pos2, Rect, Shape, Stroke, Vec2}; + +/// How to paint a circle. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CircleShape { + pub center: Pos2, + pub radius: f32, + pub fill: Color32, + pub stroke: Stroke, +} + +impl CircleShape { + #[inline] + pub fn filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { + Self { + center, + radius, + fill: fill_color.into(), + stroke: Default::default(), + } + } + + #[inline] + pub fn stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { + Self { + center, + radius, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_center_size( + self.center, + Vec2::splat(self.radius * 2.0 + self.stroke.width), + ) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: CircleShape) -> Self { + Self::Circle(shape) + } +} diff --git a/vendor/epaint/src/shapes/ellipse_shape.rs b/vendor/epaint/src/shapes/ellipse_shape.rs new file mode 100644 index 0000000..b436eb8 --- /dev/null +++ b/vendor/epaint/src/shapes/ellipse_shape.rs @@ -0,0 +1,78 @@ +use crate::*; + +/// How to paint an ellipse. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct EllipseShape { + pub center: Pos2, + + /// Radius is the vector (a, b) where the width of the Ellipse is 2a and the height is 2b + pub radius: Vec2, + pub fill: Color32, + pub stroke: Stroke, + + /// Rotate ellipse by this many radians clockwise around its center. + pub angle: f32, +} + +impl EllipseShape { + #[inline] + pub fn filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { + Self { + center, + radius, + fill: fill_color.into(), + stroke: Default::default(), + angle: 0.0, + } + } + + #[inline] + pub fn stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { + Self { + center, + radius, + fill: Default::default(), + stroke: stroke.into(), + angle: 0.0, + } + } + + /// Set the rotation of the ellipse (in radians, clockwise). + /// The ellipse rotates around its center. + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the rotation of the ellipse (in radians, clockwise) around a custom pivot point. + #[inline] + pub fn with_angle_and_pivot(mut self, angle: f32, pivot: Pos2) -> Self { + self.angle = angle; + let rot = emath::Rot2::from_angle(angle); + self.center = pivot + rot * (self.center - pivot); + self + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + let rect = Rect::from_center_size( + Pos2::ZERO, + self.radius * 2.0 + Vec2::splat(self.stroke.width), + ); + rect.rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(self.center.to_vec2()) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: EllipseShape) -> Self { + Self::Ellipse(shape) + } +} diff --git a/vendor/epaint/src/shapes/mod.rs b/vendor/epaint/src/shapes/mod.rs new file mode 100644 index 0000000..8a42b2c --- /dev/null +++ b/vendor/epaint/src/shapes/mod.rs @@ -0,0 +1,19 @@ +mod bezier_shape; +mod circle_shape; +mod ellipse_shape; +mod paint_callback; +mod path_shape; +mod rect_shape; +mod shape; +mod text_shape; + +pub use self::{ + bezier_shape::{CubicBezierShape, QuadraticBezierShape}, + circle_shape::CircleShape, + ellipse_shape::EllipseShape, + paint_callback::{PaintCallback, PaintCallbackInfo}, + path_shape::PathShape, + rect_shape::RectShape, + shape::Shape, + text_shape::TextShape, +}; diff --git a/vendor/epaint/src/shapes/paint_callback.rs b/vendor/epaint/src/shapes/paint_callback.rs new file mode 100644 index 0000000..00882f0 --- /dev/null +++ b/vendor/epaint/src/shapes/paint_callback.rs @@ -0,0 +1,103 @@ +use std::{any::Any, sync::Arc}; + +use crate::*; + +/// Information passed along with [`PaintCallback`] ([`Shape::Callback`]). +pub struct PaintCallbackInfo { + /// Viewport in points. + /// + /// This specifies where on the screen to paint, and the borders of this + /// Rect is the [-1, +1] of the Normalized Device Coordinates. + /// + /// Note than only a portion of this may be visible due to [`Self::clip_rect`]. + /// + /// This comes from [`PaintCallback::rect`]. + pub viewport: Rect, + + /// Clip rectangle in points. + pub clip_rect: Rect, + + /// Pixels per point. + pub pixels_per_point: f32, + + /// Full size of the screen, in pixels. + pub screen_size_px: [u32; 2], +} + +#[test] +fn test_viewport_rounding() { + for i in 0..=10_000 { + // Two adjacent viewports should never overlap: + let x = i as f32 / 97.0; + let left = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_max_x(x); + let right = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_min_x(x); + + for pixels_per_point in [0.618, 1.0, std::f32::consts::PI] { + let left = ViewportInPixels::from_points(&left, pixels_per_point, [100, 100]); + let right = ViewportInPixels::from_points(&right, pixels_per_point, [100, 100]); + assert_eq!(left.left_px + left.width_px, right.left_px); + } + } +} + +impl PaintCallbackInfo { + /// The viewport rectangle. This is what you would use in e.g. `glViewport`. + pub fn viewport_in_pixels(&self) -> ViewportInPixels { + ViewportInPixels::from_points(&self.viewport, self.pixels_per_point, self.screen_size_px) + } + + /// The "scissor" or "clip" rectangle. This is what you would use in e.g. `glScissor`. + pub fn clip_rect_in_pixels(&self) -> ViewportInPixels { + ViewportInPixels::from_points(&self.clip_rect, self.pixels_per_point, self.screen_size_px) + } +} + +/// If you want to paint some 3D shapes inside an egui region, you can use this. +/// +/// This is advanced usage, and is backend specific. +#[derive(Clone)] +pub struct PaintCallback { + /// Where to paint. + /// + /// This will become [`PaintCallbackInfo::viewport`]. + pub rect: Rect, + + /// Paint something custom (e.g. 3D stuff). + /// + /// The concrete value of `callback` depends on the rendering backend used. For instance, the + /// `glow` backend requires that callback be an `egui_glow::CallbackFn` while the `wgpu` + /// backend requires a `egui_wgpu::Callback`. + /// + /// If the type cannot be downcast to the type expected by the current backend the callback + /// will not be drawn. + /// + /// The rendering backend is responsible for first setting the active viewport to + /// [`Self::rect`]. + /// + /// The rendering backend is also responsible for restoring any state, such as the bound shader + /// program, vertex array, etc. + /// + /// Shape has to be clone, therefore this has to be an `Arc` instead of a `Box`. + pub callback: Arc, +} + +impl std::fmt::Debug for PaintCallback { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CustomShape") + .field("rect", &self.rect) + .finish_non_exhaustive() + } +} + +impl std::cmp::PartialEq for PaintCallback { + fn eq(&self, other: &Self) -> bool { + self.rect.eq(&other.rect) && Arc::ptr_eq(&self.callback, &other.callback) + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: PaintCallback) -> Self { + Self::Callback(shape) + } +} diff --git a/vendor/epaint/src/shapes/path_shape.rs b/vendor/epaint/src/shapes/path_shape.rs new file mode 100644 index 0000000..8486055 --- /dev/null +++ b/vendor/epaint/src/shapes/path_shape.rs @@ -0,0 +1,81 @@ +use crate::*; + +/// A path which can be stroked and/or filled (if closed). +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PathShape { + /// Filled paths should prefer clockwise order. + pub points: Vec, + + /// If true, connect the first and last of the points together. + /// This is required if `fill != TRANSPARENT`. + pub closed: bool, + + /// Fill is only supported for convex polygons. + pub fill: Color32, + + /// Color and thickness of the line. + pub stroke: PathStroke, + // TODO(emilk): Add texture support either by supplying uv for each point, + // or by some transform from points to uv (e.g. a callback or a linear transform matrix). +} + +impl PathShape { + /// A line through many points. + /// + /// Use [`Shape::line_segment`] instead if your line only connects two points. + #[inline] + pub fn line(points: Vec, stroke: impl Into) -> Self { + Self { + points, + closed: false, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// A line that closes back to the start point again. + #[inline] + pub fn closed_line(points: Vec, stroke: impl Into) -> Self { + Self { + points, + closed: true, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// A convex polygon with a fill and optional stroke. + /// + /// The most performant winding order is clockwise. + #[inline] + pub fn convex_polygon( + points: Vec, + fill: impl Into, + stroke: impl Into, + ) -> Self { + Self { + points, + closed: true, + fill: fill.into(), + stroke: stroke.into(), + } + } + + /// The visual bounding rectangle (includes stroke width) + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_points(&self.points).expand(self.stroke.width / 2.0) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: PathShape) -> Self { + Self::Path(shape) + } +} diff --git a/vendor/epaint/src/shapes/rect_shape.rs b/vendor/epaint/src/shapes/rect_shape.rs new file mode 100644 index 0000000..e0c5283 --- /dev/null +++ b/vendor/epaint/src/shapes/rect_shape.rs @@ -0,0 +1,223 @@ +use std::sync::Arc; + +use crate::*; + +/// How to paint a rectangle. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RectShape { + pub rect: Rect, + + /// How rounded the corners of the rectangle are. + /// + /// Use [`CornerRadius::ZERO`] for for sharp corners. + /// + /// This is the corner radii of the rectangle. + /// If there is a stroke, then the stroke will have an inner and outer corner radius, + /// and those will depend on [`StrokeKind`] and the stroke width. + /// + /// For [`StrokeKind::Inside`], the outside of the stroke coincides with the rectangle, + /// so the rounding will in this case specify the outer corner radius. + pub corner_radius: CornerRadius, + + /// How to fill the rectangle. + pub fill: Color32, + + /// The thickness and color of the outline. + /// + /// Whether or not the stroke is inside or outside the edge of [`Self::rect`], + /// is controlled by [`Self::stroke_kind`]. + pub stroke: Stroke, + + /// Is the stroke on the inside, outside, or centered on the rectangle? + /// + /// If you want to perfectly tile rectangles, use [`StrokeKind::Inside`]. + pub stroke_kind: StrokeKind, + + /// Snap the rectangle to pixels? + /// + /// Rounding produces sharper rectangles. + /// + /// If `None`, [`crate::TessellationOptions::round_rects_to_pixels`] will be used. + pub round_to_pixels: Option, + + /// If larger than zero, the edges of the rectangle + /// (for both fill and stroke) will be blurred. + /// + /// This can be used to produce shadows and glow effects. + /// + /// The blur is currently implemented using a simple linear blur in sRGBA gamma space. + pub blur_width: f32, + + /// Controls texturing, if any. + /// + /// Since most rectangles do not have a texture, this is optional and in an `Arc`, + /// so that [`RectShape`] is kept small.. + pub brush: Option>, + + /// Rotate rectangle by this many radians clockwise around its center. + pub angle: f32, +} + +#[test] +fn rect_shape_size() { + assert_eq!( + std::mem::size_of::(), + 56, + "RectShape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); + assert!( + std::mem::size_of::() <= 64, + "RectShape is getting way too big!" + ); +} + +impl RectShape { + /// See also [`Self::filled`] and [`Self::stroke`]. + #[inline] + pub fn new( + rect: Rect, + corner_radius: impl Into, + fill_color: impl Into, + stroke: impl Into, + stroke_kind: StrokeKind, + ) -> Self { + Self { + rect, + corner_radius: corner_radius.into(), + fill: fill_color.into(), + stroke: stroke.into(), + stroke_kind, + round_to_pixels: None, + blur_width: 0.0, + brush: Default::default(), + angle: 0.0, + } + } + + #[inline] + pub fn filled( + rect: Rect, + corner_radius: impl Into, + fill_color: impl Into, + ) -> Self { + Self::new( + rect, + corner_radius, + fill_color, + Stroke::NONE, + StrokeKind::Outside, // doesn't matter + ) + } + + #[inline] + pub fn stroke( + rect: Rect, + corner_radius: impl Into, + stroke: impl Into, + stroke_kind: StrokeKind, + ) -> Self { + let fill = Color32::TRANSPARENT; + Self::new(rect, corner_radius, fill, stroke, stroke_kind) + } + + /// Set if the stroke is on the inside, outside, or centered on the rectangle. + #[inline] + pub fn with_stroke_kind(mut self, stroke_kind: StrokeKind) -> Self { + self.stroke_kind = stroke_kind; + self + } + + /// Snap the rectangle to pixels? + /// + /// Rounding produces sharper rectangles. + /// + /// If `None`, [`crate::TessellationOptions::round_rects_to_pixels`] will be used. + #[inline] + pub fn with_round_to_pixels(mut self, round_to_pixels: bool) -> Self { + self.round_to_pixels = Some(round_to_pixels); + self + } + + /// If larger than zero, the edges of the rectangle + /// (for both fill and stroke) will be blurred. + /// + /// This can be used to produce shadows and glow effects. + /// + /// The blur is currently implemented using a simple linear blur in `sRGBA` gamma space. + #[inline] + pub fn with_blur_width(mut self, blur_width: f32) -> Self { + self.blur_width = blur_width; + self + } + + /// Set the texture to use when painting this rectangle, if any. + #[inline] + pub fn with_texture(mut self, fill_texture_id: TextureId, uv: Rect) -> Self { + self.brush = Some(Arc::new(Brush { + fill_texture_id, + uv, + })); + self + } + + /// Set the rotation of the rectangle (in radians, clockwise). + /// The rectangle rotates around its center. + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the rotation of the rectangle (in radians, clockwise) around a custom pivot point. + #[inline] + pub fn with_angle_and_pivot(mut self, angle: f32, pivot: Pos2) -> Self { + self.angle = angle; + let rot = emath::Rot2::from_angle(angle); + let center = self.rect.center(); + let new_center = pivot + rot * (center - pivot); + self.rect = self.rect.translate(new_center - center); + self + } + + /// The visual bounding rectangle (includes stroke width) + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + let expand = match self.stroke_kind { + StrokeKind::Inside => 0.0, + StrokeKind::Middle => self.stroke.width / 2.0, + StrokeKind::Outside => self.stroke.width, + }; + let expanded = self.rect.expand(expand + self.blur_width / 2.0); + if self.angle == 0.0 { + expanded + } else { + // Rotate around the rectangle's center and compute bounding box + let center = self.rect.center(); + let rect_relative = Rect::from_center_size(Pos2::ZERO, expanded.size()); + rect_relative + .rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(center.to_vec2()) + } + } + } + + /// The texture to use when painting this rectangle, if any. + /// + /// If no texture is set, this will return [`TextureId::default`]. + pub fn fill_texture_id(&self) -> TextureId { + self.brush + .as_ref() + .map_or_else(TextureId::default, |brush| brush.fill_texture_id) + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: RectShape) -> Self { + Self::Rect(shape) + } +} diff --git a/vendor/epaint/src/shapes/shape.rs b/vendor/epaint/src/shapes/shape.rs new file mode 100644 index 0000000..f5ca6a3 --- /dev/null +++ b/vendor/epaint/src/shapes/shape.rs @@ -0,0 +1,586 @@ +//! The different shapes that can be painted. + +use std::sync::Arc; + +use emath::{Align2, Pos2, Rangef, Rect, TSTransform, Vec2, pos2}; + +use crate::{ + Color32, CornerRadius, Direction, Mesh, Stroke, StrokeKind, TextureId, Vertex, + stroke::PathStroke, + text::{FontId, FontsView, Galley}, +}; + +use super::{ + CircleShape, CubicBezierShape, EllipseShape, PaintCallback, PathShape, QuadraticBezierShape, + RectShape, TextShape, +}; + +/// A paint primitive such as a circle or a piece of text. +/// Coordinates are all screen space points (not physical pixels). +/// +/// You should generally recreate your [`Shape`]s each frame, +/// but storing them should also be fine with one exception: +/// [`Shape::Text`] depends on the current `pixels_per_point` (dpi scale) +/// and so must be recreated every time `pixels_per_point` changes. +#[must_use = "Add a Shape to a Painter"] +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + /// Paint nothing. This can be useful as a placeholder. + Noop, + + /// Recursively nest more shapes - sometimes a convenience to be able to do. + /// For performance reasons it is better to avoid it. + Vec(Vec), + + /// Circle with optional outline and fill. + Circle(CircleShape), + + /// Ellipse with optional outline and fill. + Ellipse(EllipseShape), + + /// A line between two points. + LineSegment { points: [Pos2; 2], stroke: Stroke }, + + /// A series of lines between points. + /// The path can have a stroke and/or fill (if closed). + Path(PathShape), + + /// Rectangle with optional outline and fill. + Rect(RectShape), + + /// Text. + /// + /// This needs to be recreated if `pixels_per_point` (dpi scale) changes. + Text(TextShape), + + /// A general triangle mesh. + /// + /// Can be used to display images. + /// + /// Wrapped in an [`Arc`] to minimize the size of [`Shape`]. + Mesh(Arc), + + /// A quadratic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). + QuadraticBezier(QuadraticBezierShape), + + /// A cubic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). + CubicBezier(CubicBezierShape), + + /// Backend-specific painting. + Callback(PaintCallback), +} + +#[test] +fn shape_size() { + assert_eq!( + std::mem::size_of::(), + 64, + "Shape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); + assert!( + std::mem::size_of::() <= 64, + "Shape is getting way too big!" + ); +} + +#[test] +fn shape_impl_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +impl From> for Shape { + #[inline(always)] + fn from(shapes: Vec) -> Self { + Self::Vec(shapes) + } +} + +impl From for Shape { + #[inline(always)] + fn from(mesh: Mesh) -> Self { + Self::Mesh(mesh.into()) + } +} + +impl From> for Shape { + #[inline(always)] + fn from(mesh: Arc) -> Self { + Self::Mesh(mesh) + } +} + +/// ## Constructors +impl Shape { + /// A line between two points. + /// More efficient than calling [`Self::line`]. + #[inline] + pub fn line_segment(points: [Pos2; 2], stroke: impl Into) -> Self { + Self::LineSegment { + points, + stroke: stroke.into(), + } + } + + /// A horizontal line. + pub fn hline(x: impl Into, y: f32, stroke: impl Into) -> Self { + let x = x.into(); + Self::LineSegment { + points: [pos2(x.min, y), pos2(x.max, y)], + stroke: stroke.into(), + } + } + + /// A vertical line. + pub fn vline(x: f32, y: impl Into, stroke: impl Into) -> Self { + let y = y.into(); + Self::LineSegment { + points: [pos2(x, y.min), pos2(x, y.max)], + stroke: stroke.into(), + } + } + + /// A line through many points. + /// + /// Use [`Self::line_segment`] instead if your line only connects two points. + #[inline] + pub fn line(points: Vec, stroke: impl Into) -> Self { + Self::Path(PathShape::line(points, stroke)) + } + + /// A line that closes back to the start point again. + #[inline] + pub fn closed_line(points: Vec, stroke: impl Into) -> Self { + Self::Path(PathShape::closed_line(points, stroke)) + } + + /// Turn a line into equally spaced dots. + pub fn dotted_line( + path: &[Pos2], + color: impl Into, + spacing: f32, + radius: f32, + ) -> Vec { + let mut shapes = Vec::new(); + points_from_line(path, spacing, radius, color.into(), &mut shapes); + shapes + } + + /// Turn a line into dashes. + pub fn dashed_line( + path: &[Pos2], + stroke: impl Into, + dash_length: f32, + gap_length: f32, + ) -> Vec { + let mut shapes = Vec::new(); + dashes_from_line( + path, + stroke.into(), + &[dash_length], + &[gap_length], + &mut shapes, + 0., + ); + shapes + } + + /// Turn a line into dashes with different dash/gap lengths and a start offset. + pub fn dashed_line_with_offset( + path: &[Pos2], + stroke: impl Into, + dash_lengths: &[f32], + gap_lengths: &[f32], + dash_offset: f32, + ) -> Vec { + let mut shapes = Vec::new(); + dashes_from_line( + path, + stroke.into(), + dash_lengths, + gap_lengths, + &mut shapes, + dash_offset, + ); + shapes + } + + /// Turn a line into dashes. If you need to create many dashed lines use this instead of + /// [`Self::dashed_line`]. + pub fn dashed_line_many( + points: &[Pos2], + stroke: impl Into, + dash_length: f32, + gap_length: f32, + shapes: &mut Vec, + ) { + dashes_from_line( + points, + stroke.into(), + &[dash_length], + &[gap_length], + shapes, + 0., + ); + } + + /// Turn a line into dashes with different dash/gap lengths and a start offset. If you need to + /// create many dashed lines use this instead of [`Self::dashed_line_with_offset`]. + pub fn dashed_line_many_with_offset( + points: &[Pos2], + stroke: impl Into, + dash_lengths: &[f32], + gap_lengths: &[f32], + dash_offset: f32, + shapes: &mut Vec, + ) { + dashes_from_line( + points, + stroke.into(), + dash_lengths, + gap_lengths, + shapes, + dash_offset, + ); + } + + /// A convex polygon with a fill and optional stroke. + /// + /// The most performant winding order is clockwise. + #[inline] + pub fn convex_polygon( + points: Vec, + fill: impl Into, + stroke: impl Into, + ) -> Self { + Self::Path(PathShape::convex_polygon(points, fill, stroke)) + } + + #[inline] + pub fn circle_filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { + Self::Circle(CircleShape::filled(center, radius, fill_color)) + } + + #[inline] + pub fn circle_stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { + Self::Circle(CircleShape::stroke(center, radius, stroke)) + } + + #[inline] + pub fn ellipse_filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { + Self::Ellipse(EllipseShape::filled(center, radius, fill_color)) + } + + #[inline] + pub fn ellipse_stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { + Self::Ellipse(EllipseShape::stroke(center, radius, stroke)) + } + + /// See also [`Self::rect_stroke`]. + #[inline] + pub fn rect_filled( + rect: Rect, + corner_radius: impl Into, + fill_color: impl Into, + ) -> Self { + Self::Rect(RectShape::filled(rect, corner_radius, fill_color)) + } + + /// See also [`Self::rect_filled`]. + #[inline] + pub fn rect_stroke( + rect: Rect, + corner_radius: impl Into, + stroke: impl Into, + stroke_kind: StrokeKind, + ) -> Self { + Self::Rect(RectShape::stroke(rect, corner_radius, stroke, stroke_kind)) + } + + /// Paints a gradient rectangle that transitions from `color_from` to `color_to` + /// along the given `direction`. + /// + /// For example, [`Direction::TopDown`] paints `color_from` at the top edge fading + /// to `color_to` at the bottom edge. + #[inline] + pub fn gradient_rect(rect: Rect, direction: Direction, [from, to]: [Color32; 2]) -> Self { + let (left_top, right_top, left_bottom, right_bottom) = match direction { + Direction::TopDown => (from, from, to, to), + Direction::BottomUp => (to, to, from, from), + Direction::LeftToRight => (from, to, from, to), + Direction::RightToLeft => (to, from, to, from), + }; + + Self::from(Mesh { + indices: vec![0, 1, 2, 2, 1, 3], + vertices: vec![ + Vertex::untextured(rect.left_top(), left_top), + Vertex::untextured(rect.right_top(), right_top), + Vertex::untextured(rect.left_bottom(), left_bottom), + Vertex::untextured(rect.right_bottom(), right_bottom), + ], + texture_id: Default::default(), + }) + } + + #[expect(clippy::needless_pass_by_value)] + pub fn text( + fonts: &mut FontsView<'_>, + pos: Pos2, + anchor: Align2, + text: impl ToString, + font_id: FontId, + color: Color32, + ) -> Self { + let galley = fonts.layout_no_wrap(text.to_string(), font_id, color); + let rect = anchor.anchor_size(pos, galley.size()); + Self::galley(rect.min, galley, color) + } + + /// Any uncolored parts of the [`Galley`] (using [`Color32::PLACEHOLDER`]) will be replaced with the given color. + /// + /// Any non-placeholder color in the galley takes precedence over this fallback color. + #[inline] + pub fn galley(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { + TextShape::new(pos, galley, fallback_color).into() + } + + /// All text color in the [`Galley`] will be replaced with the given color. + #[inline] + pub fn galley_with_override_text_color( + pos: Pos2, + galley: Arc, + text_color: Color32, + ) -> Self { + TextShape::new(pos, galley, text_color) + .with_override_text_color(text_color) + .into() + } + + #[inline] + #[deprecated = "Use `Shape::galley` or `Shape::galley_with_override_text_color` instead"] + pub fn galley_with_color(pos: Pos2, galley: Arc, text_color: Color32) -> Self { + Self::galley_with_override_text_color(pos, galley, text_color) + } + + #[inline] + pub fn mesh(mesh: impl Into>) -> Self { + let mesh = mesh.into(); + debug_assert!(mesh.is_valid(), "Invalid mesh: {mesh:#?}"); + Self::Mesh(mesh) + } + + /// An image at the given position. + /// + /// `uv` should normally be `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))` + /// unless you want to crop or flip the image. + /// + /// `tint` is a color multiplier. Use [`Color32::WHITE`] if you don't want to tint the image. + pub fn image(texture_id: TextureId, rect: Rect, uv: Rect, tint: Color32) -> Self { + let mut mesh = Mesh::with_texture(texture_id); + mesh.add_rect_with_uv(rect, uv, tint); + Self::mesh(mesh) + } + + /// The visual bounding rectangle (includes stroke widths) + pub fn visual_bounding_rect(&self) -> Rect { + match self { + Self::Noop => Rect::NOTHING, + Self::Vec(shapes) => { + let mut rect = Rect::NOTHING; + for shape in shapes { + rect |= shape.visual_bounding_rect(); + } + rect + } + Self::Circle(circle_shape) => circle_shape.visual_bounding_rect(), + Self::Ellipse(ellipse_shape) => ellipse_shape.visual_bounding_rect(), + Self::LineSegment { points, stroke } => { + if stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_two_pos(points[0], points[1]).expand(stroke.width / 2.0) + } + } + Self::Path(path_shape) => path_shape.visual_bounding_rect(), + Self::Rect(rect_shape) => rect_shape.visual_bounding_rect(), + Self::Text(text_shape) => text_shape.visual_bounding_rect(), + Self::Mesh(mesh) => mesh.calc_bounds(), + Self::QuadraticBezier(bezier) => bezier.visual_bounding_rect(), + Self::CubicBezier(bezier) => bezier.visual_bounding_rect(), + Self::Callback(custom) => custom.rect, + } + } +} + +/// ## Inspection and transforms +impl Shape { + #[inline(always)] + pub fn texture_id(&self) -> crate::TextureId { + if let Self::Mesh(mesh) = self { + mesh.texture_id + } else if let Self::Rect(rect_shape) = self { + rect_shape.fill_texture_id() + } else { + crate::TextureId::default() + } + } + + /// Scale the shape by `factor`, in-place. + /// + /// A wrapper around [`Self::transform`]. + #[inline(always)] + pub fn scale(&mut self, factor: f32) { + self.transform(TSTransform::from_scaling(factor)); + } + + /// Move the shape by `delta`, in-place. + /// + /// A wrapper around [`Self::transform`]. + #[inline(always)] + pub fn translate(&mut self, delta: Vec2) { + self.transform(TSTransform::from_translation(delta)); + } + + /// Transform (move/scale) the shape in-place. + /// + /// If using a [`PaintCallback`], note that only the rect is scaled as opposed + /// to other shapes where the stroke is also scaled. + pub fn transform(&mut self, transform: TSTransform) { + match self { + Self::Noop => {} + Self::Vec(shapes) => { + for shape in shapes { + shape.transform(transform); + } + } + Self::Circle(circle_shape) => { + circle_shape.center = transform * circle_shape.center; + circle_shape.radius *= transform.scaling; + circle_shape.stroke.width *= transform.scaling; + } + Self::Ellipse(ellipse_shape) => { + ellipse_shape.center = transform * ellipse_shape.center; + ellipse_shape.radius *= transform.scaling; + ellipse_shape.stroke.width *= transform.scaling; + } + Self::LineSegment { points, stroke } => { + for p in points { + *p = transform * *p; + } + stroke.width *= transform.scaling; + } + Self::Path(path_shape) => { + for p in &mut path_shape.points { + *p = transform * *p; + } + path_shape.stroke.width *= transform.scaling; + } + Self::Rect(rect_shape) => { + rect_shape.rect = transform * rect_shape.rect; + rect_shape.corner_radius *= transform.scaling; + rect_shape.stroke.width *= transform.scaling; + rect_shape.blur_width *= transform.scaling; + } + Self::Text(text_shape) => { + text_shape.transform(transform); + } + Self::Mesh(mesh) => { + Arc::make_mut(mesh).transform(transform); + } + Self::QuadraticBezier(bezier) => { + for p in &mut bezier.points { + *p = transform * *p; + } + bezier.stroke.width *= transform.scaling; + } + Self::CubicBezier(bezier) => { + for p in &mut bezier.points { + *p = transform * *p; + } + bezier.stroke.width *= transform.scaling; + } + Self::Callback(shape) => { + shape.rect = transform * shape.rect; + } + } + } +} + +// ---------------------------------------------------------------------------- + +/// Creates equally spaced filled circles from a line. +fn points_from_line( + path: &[Pos2], + spacing: f32, + radius: f32, + color: Color32, + shapes: &mut Vec, +) { + let mut position_on_segment = 0.0; + for window in path.windows(2) { + let (start, end) = (window[0], window[1]); + let vector = end - start; + let segment_length = vector.length(); + while position_on_segment < segment_length { + let new_point = start + vector * (position_on_segment / segment_length); + shapes.push(Shape::circle_filled(new_point, radius, color)); + position_on_segment += spacing; + } + position_on_segment -= segment_length; + } +} + +/// Creates dashes from a line. +fn dashes_from_line( + path: &[Pos2], + stroke: Stroke, + dash_lengths: &[f32], + gap_lengths: &[f32], + shapes: &mut Vec, + dash_offset: f32, +) { + assert_eq!( + dash_lengths.len(), + gap_lengths.len(), + "Mismatched dash and gap lengths, got dash_lengths: {}, gap_lengths: {}", + dash_lengths.len(), + gap_lengths.len() + ); + let mut position_on_segment = dash_offset; + let mut drawing_dash = false; + let mut step = 0; + let steps = dash_lengths.len(); + for window in path.windows(2) { + let (start, end) = (window[0], window[1]); + let vector = end - start; + let segment_length = vector.length(); + + let mut start_point = start; + while position_on_segment < segment_length { + let new_point = start + vector * (position_on_segment / segment_length); + if drawing_dash { + // This is the end point. + shapes.push(Shape::line_segment([start_point, new_point], stroke)); + position_on_segment += gap_lengths[step]; + // Increment step counter + step += 1; + if step >= steps { + step = 0; + } + } else { + // Start a new dash. + start_point = new_point; + position_on_segment += dash_lengths[step]; + } + drawing_dash = !drawing_dash; + } + + // If the segment ends and the dash is not finished, add the segment's end point. + if drawing_dash { + shapes.push(Shape::line_segment([start_point, end], stroke)); + } + + position_on_segment -= segment_length; + } +} diff --git a/vendor/epaint/src/shapes/text_shape.rs b/vendor/epaint/src/shapes/text_shape.rs new file mode 100644 index 0000000..3e177db --- /dev/null +++ b/vendor/epaint/src/shapes/text_shape.rs @@ -0,0 +1,213 @@ +use std::sync::Arc; + +use emath::{Align2, Rot2}; + +use crate::*; + +/// How to paint some text on screen. +/// +/// This needs to be recreated if `pixels_per_point` (dpi scale) changes. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextShape { + /// Where the origin of [`Self::galley`] is. + /// + /// Usually the top left corner of the first character. + pub pos: Pos2, + + /// The laid out text, from [`FontsView::layout_job`]. + pub galley: Arc, + + /// Add this underline to the whole text. + /// You can also set an underline when creating the galley. + pub underline: Stroke, + + /// Any [`Color32::PLACEHOLDER`] in the galley will be replaced by the given color. + /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. + pub fallback_color: Color32, + + /// If set, the text color in the galley will be ignored and replaced + /// with the given color. + /// + /// This only affects the glyphs and will NOT replace background color nor strikethrough/underline color. + pub override_text_color: Option, + + /// If set, the text will be rendered with the given opacity in gamma space + /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. + pub opacity_factor: f32, + + /// Rotate text by this many radians clockwise. + /// The pivot is `pos` (the upper left corner of the text). + pub angle: f32, +} + +impl TextShape { + /// The given fallback color will be used for any uncolored part of the galley (using [`Color32::PLACEHOLDER`]). + /// + /// Any non-placeholder color in the galley takes precedence over this fallback color. + #[inline] + pub fn new(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { + Self { + pos, + galley, + underline: Stroke::NONE, + fallback_color, + override_text_color: None, + opacity_factor: 1.0, + angle: 0.0, + } + } + + /// The visual bounding rectangle + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + self.galley + .mesh_bounds + .rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(self.pos.to_vec2()) + } + + #[inline] + pub fn with_underline(mut self, underline: Stroke) -> Self { + self.underline = underline; + self + } + + /// Use the given color for the text, regardless of what color is already in the galley. + #[inline] + pub fn with_override_text_color(mut self, override_text_color: Color32) -> Self { + self.override_text_color = Some(override_text_color); + self + } + + /// Set text rotation to `angle` radians clockwise. + /// The pivot is `pos` (the upper left corner of the text). + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the text rotation to the `angle` radians clockwise. + /// The pivot is determined by the given `anchor` point on the text bounding box. + #[inline] + pub fn with_angle_and_anchor(mut self, angle: f32, anchor: Align2) -> Self { + self.angle = angle; + let a0 = anchor.pos_in_rect(&self.galley.rect).to_vec2(); + let a1 = Rot2::from_angle(angle) * a0; + self.pos += a0 - a1; + self + } + + /// Render text with this opacity in gamma space + #[inline] + pub fn with_opacity_factor(mut self, opacity_factor: f32) -> Self { + self.opacity_factor = opacity_factor; + self + } + + /// Move the shape by this many points, in-place. + pub fn transform(&mut self, transform: emath::TSTransform) { + let Self { + pos, + galley, + underline, + fallback_color: _, + override_text_color: _, + opacity_factor: _, + angle: _, + } = self; + + *pos = transform * *pos; + underline.width *= transform.scaling; + + let Galley { + job: _, + rows, + elided: _, + rect, + mesh_bounds, + num_vertices: _, + num_indices: _, + pixels_per_point: _, + intrinsic_size, + } = Arc::make_mut(galley); + + *rect = transform.scaling * *rect; + *mesh_bounds = transform.scaling * *mesh_bounds; + *intrinsic_size = transform.scaling * *intrinsic_size; + + for text::PlacedRow { + pos, + row, + ends_with_newline: _, + } in rows + { + *pos *= transform.scaling; + + let text::Row { + section_index_at_start: _, + glyphs: _, // TODO(emilk): would it make sense to transform these? + size, + visuals, + } = Arc::make_mut(row); + + *size *= transform.scaling; + + let text::RowVisuals { + mesh, + mesh_bounds, + glyph_index_start: _, + glyph_vertex_range: _, + } = visuals; + + *mesh_bounds = transform.scaling * *mesh_bounds; + + for v in &mut mesh.vertices { + v.pos *= transform.scaling; + } + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: TextShape) -> Self { + Self::Text(shape) + } +} + +#[cfg(test)] +mod tests { + use super::{super::*, *}; + use crate::text::FontDefinitions; + use emath::almost_equal; + + #[test] + fn text_bounding_box_under_rotation() { + let mut fonts = Fonts::new(TextOptions::default(), FontDefinitions::default()); + let font = FontId::monospace(12.0); + + let mut t = crate::Shape::text( + &mut fonts.with_pixels_per_point(1.0), + Pos2::ZERO, + emath::Align2::CENTER_CENTER, + "testing123", + font, + Color32::BLACK, + ); + + let size_orig = t.visual_bounding_rect().size(); + + // 90 degree rotation + if let Shape::Text(ts) = &mut t { + ts.angle = std::f32::consts::PI / 2.0; + } + + let size_rot = t.visual_bounding_rect().size(); + + // make sure the box is actually rotated + assert!(almost_equal(size_orig.x, size_rot.y, 1e-4)); + assert!(almost_equal(size_orig.y, size_rot.x, 1e-4)); + } +} diff --git a/vendor/epaint/src/stats.rs b/vendor/epaint/src/stats.rs new file mode 100644 index 0000000..1eef4f4 --- /dev/null +++ b/vendor/epaint/src/stats.rs @@ -0,0 +1,244 @@ +//! Collect statistics about what is being painted. + +use crate::{ClippedShape, Galley, Mesh, Primitive, Shape}; + +/// Size of the elements in a vector/array. +#[derive(Clone, Copy, Default, PartialEq)] +enum ElementSize { + #[default] + Unknown, + Homogeneous(usize), + Heterogenous, +} + +/// Aggregate information about a bunch of allocations. +#[derive(Clone, Copy, Default, PartialEq)] +pub struct AllocInfo { + element_size: ElementSize, + num_allocs: usize, + num_elements: usize, + num_bytes: usize, +} + +impl From<&[T]> for AllocInfo { + fn from(slice: &[T]) -> Self { + Self::from_slice(slice) + } +} + +impl std::ops::Add for AllocInfo { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + use ElementSize::{Heterogenous, Homogeneous, Unknown}; + let element_size = match (self.element_size, rhs.element_size) { + (Heterogenous, _) | (_, Heterogenous) => Heterogenous, + (Unknown, other) | (other, Unknown) => other, + (Homogeneous(lhs), Homogeneous(rhs)) if lhs == rhs => Homogeneous(lhs), + _ => Heterogenous, + }; + + Self { + element_size, + num_allocs: self.num_allocs + rhs.num_allocs, + num_elements: self.num_elements + rhs.num_elements, + num_bytes: self.num_bytes + rhs.num_bytes, + } + } +} + +impl std::ops::AddAssign for AllocInfo { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl std::iter::Sum for AllocInfo { + fn sum(iter: I) -> Self + where + I: Iterator, + { + let mut sum = Self::default(); + for value in iter { + sum += value; + } + sum + } +} + +impl AllocInfo { + // pub fn from_shape(shape: &Shape) -> Self { + // match shape { + // Shape::Noop + // Shape::Vec(shapes) => Self::from_shapes(shapes) + // | Shape::Circle { .. } + // | Shape::LineSegment { .. } + // | Shape::Rect { .. } => Self::default(), + // Shape::Path { points, .. } => Self::from_slice(points), + // Shape::Text { galley, .. } => Self::from_galley(galley), + // Shape::Mesh(mesh) => Self::from_mesh(mesh), + // } + // } + + pub fn from_galley(galley: &Galley) -> Self { + Self::from_slice(galley.text().as_bytes()) + + Self::from_slice(&galley.rows) + + galley.rows.iter().map(Self::from_galley_row).sum() + } + + fn from_galley_row(row: &crate::text::PlacedRow) -> Self { + Self::from_mesh(&row.visuals.mesh) + Self::from_slice(&row.glyphs) + } + + pub fn from_mesh(mesh: &Mesh) -> Self { + Self::from_slice(&mesh.indices) + Self::from_slice(&mesh.vertices) + } + + pub fn from_slice(slice: &[T]) -> Self { + use std::mem::size_of; + let element_size = size_of::(); + Self { + element_size: ElementSize::Homogeneous(element_size), + num_allocs: 1, + num_elements: slice.len(), + num_bytes: std::mem::size_of_val(slice), + } + } + + pub fn num_elements(&self) -> usize { + assert!( + self.element_size != ElementSize::Heterogenous, + "Heterogenous element size" + ); + self.num_elements + } + + pub fn num_allocs(&self) -> usize { + self.num_allocs + } + + pub fn num_bytes(&self) -> usize { + self.num_bytes + } + + pub fn megabytes(&self) -> String { + megabytes(self.num_bytes()) + } + + pub fn format(&self, what: &str) -> String { + if self.num_allocs() == 0 { + format!("{:6} {:16}", 0, what) + } else if self.num_allocs() == 1 { + format!( + "{:6} {:16} {} 1 allocation", + self.num_elements, + what, + self.megabytes() + ) + } else if self.element_size != ElementSize::Heterogenous { + format!( + "{:6} {:16} {} {:3} allocations", + self.num_elements(), + what, + self.megabytes(), + self.num_allocs() + ) + } else { + format!( + "{:6} {:16} {} {:3} allocations", + "", + what, + self.megabytes(), + self.num_allocs() + ) + } + } +} + +/// Collected allocation statistics for shapes and meshes. +#[derive(Clone, Copy, Default)] +pub struct PaintStats { + pub shapes: AllocInfo, + pub shape_text: AllocInfo, + pub shape_path: AllocInfo, + pub shape_mesh: AllocInfo, + pub shape_vec: AllocInfo, + pub num_callbacks: usize, + + pub text_shape_vertices: AllocInfo, + pub text_shape_indices: AllocInfo, + + /// Number of separate clip rectangles + pub clipped_primitives: AllocInfo, + pub vertices: AllocInfo, + pub indices: AllocInfo, +} + +impl PaintStats { + pub fn from_shapes(shapes: &[ClippedShape]) -> Self { + let mut stats = Self::default(); + stats.shape_path.element_size = ElementSize::Heterogenous; // nicer display later + stats.shape_vec.element_size = ElementSize::Heterogenous; // nicer display later + + stats.shapes = AllocInfo::from_slice(shapes); + for ClippedShape { shape, .. } in shapes { + stats.add(shape); + } + stats + } + + fn add(&mut self, shape: &Shape) { + match shape { + Shape::Vec(shapes) => { + // self += PaintStats::from_shapes(&shapes); // TODO(emilk) + self.shapes += AllocInfo::from_slice(shapes); + self.shape_vec += AllocInfo::from_slice(shapes); + for shape in shapes { + self.add(shape); + } + } + Shape::Noop + | Shape::Circle { .. } + | Shape::Ellipse { .. } + | Shape::LineSegment { .. } + | Shape::Rect { .. } + | Shape::CubicBezier(_) + | Shape::QuadraticBezier(_) => {} + Shape::Path(path_shape) => { + self.shape_path += AllocInfo::from_slice(&path_shape.points); + } + Shape::Text(text_shape) => { + self.shape_text += AllocInfo::from_galley(&text_shape.galley); + + for row in &text_shape.galley.rows { + self.text_shape_indices += AllocInfo::from_slice(&row.visuals.mesh.indices); + self.text_shape_vertices += AllocInfo::from_slice(&row.visuals.mesh.vertices); + } + } + Shape::Mesh(mesh) => { + self.shape_mesh += AllocInfo::from_mesh(mesh); + } + Shape::Callback(_) => { + self.num_callbacks += 1; + } + } + } + + pub fn with_clipped_primitives( + mut self, + clipped_primitives: &[crate::ClippedPrimitive], + ) -> Self { + self.clipped_primitives += AllocInfo::from_slice(clipped_primitives); + for clipped_primitive in clipped_primitives { + if let Primitive::Mesh(mesh) = &clipped_primitive.primitive { + self.vertices += AllocInfo::from_slice(&mesh.vertices); + self.indices += AllocInfo::from_slice(&mesh.indices); + } + } + self + } +} + +fn megabytes(size: usize) -> String { + format!("{:.2} MB", size as f64 / 1e6) +} diff --git a/vendor/epaint/src/stroke.rs b/vendor/epaint/src/stroke.rs new file mode 100644 index 0000000..4adb1f3 --- /dev/null +++ b/vendor/epaint/src/stroke.rs @@ -0,0 +1,242 @@ +use std::{fmt::Debug, sync::Arc}; + +use emath::GuiRounding as _; + +use super::{Color32, ColorMode, Pos2, Rect, emath}; + +/// Describes the width and color of a line. +/// +/// The default stroke is the same as [`Stroke::NONE`]. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Stroke { + pub width: f32, + pub color: Color32, +} + +impl Stroke { + /// Same as [`Stroke::default`]. + pub const NONE: Self = Self { + width: 0.0, + color: Color32::TRANSPARENT, + }; + + #[inline] + pub fn new(width: impl Into, color: impl Into) -> Self { + Self { + width: width.into(), + color: color.into(), + } + } + + /// True if width is zero or color is transparent + #[inline] + pub fn is_empty(&self) -> bool { + self.width <= 0.0 || self.color == Color32::TRANSPARENT + } + + /// For vertical or horizontal lines: + /// round the stroke center to produce a sharp, pixel-aligned line. + pub fn round_center_to_pixel(&self, pixels_per_point: f32, coord: &mut f32) { + // If the stroke is an odd number of pixels wide, + // we want to round the center of it to the center of a pixel. + // + // If however it is an even number of pixels wide, + // we want to round the center to be between two pixels. + // + // We also want to treat strokes that are _almost_ odd as it it was odd, + // to make it symmetric. Same for strokes that are _almost_ even. + // + // For strokes less than a pixel wide we also round to the center, + // because it will rendered as a single row of pixels by the tessellator. + + let pixel_size = 1.0 / pixels_per_point; + + if self.width <= pixel_size || is_nearest_integer_odd(pixels_per_point * self.width) { + *coord = coord.round_to_pixel_center(pixels_per_point); + } else { + *coord = coord.round_to_pixels(pixels_per_point); + } + } + + pub(crate) fn round_rect_to_pixel(&self, pixels_per_point: f32, rect: &mut Rect) { + // We put odd-width strokes in the center of pixels. + // To understand why, see `fn round_center_to_pixel`. + + let pixel_size = 1.0 / pixels_per_point; + + let width = self.width; + if width <= 0.0 { + *rect = rect.round_to_pixels(pixels_per_point); + } else if width <= pixel_size || is_nearest_integer_odd(pixels_per_point * width) { + *rect = rect.round_to_pixel_center(pixels_per_point); + } else { + *rect = rect.round_to_pixels(pixels_per_point); + } + } +} + +impl From<(f32, Color)> for Stroke +where + Color: Into, +{ + #[inline(always)] + fn from((width, color): (f32, Color)) -> Self { + Self::new(width, color) + } +} + +impl std::hash::Hash for Stroke { + #[inline(always)] + fn hash(&self, state: &mut H) { + let Self { width, color } = *self; + emath::OrderedFloat(width).hash(state); + color.hash(state); + } +} + +/// Describes how the stroke of a shape should be painted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum StrokeKind { + /// The stroke should be painted entirely inside of the shape + Inside, + + /// The stroke should be painted right on the edge of the shape, half inside and half outside. + Middle, + + /// The stroke should be painted entirely outside of the shape + Outside, +} + +/// Describes the width and color of paths. The color can either be solid or provided by a callback. For more information, see [`ColorMode`] +/// +/// The default stroke is the same as [`Stroke::NONE`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PathStroke { + pub width: f32, + pub color: ColorMode, + pub kind: StrokeKind, +} + +impl Default for PathStroke { + #[inline] + fn default() -> Self { + Self::NONE + } +} + +impl PathStroke { + /// Same as [`PathStroke::default`]. + pub const NONE: Self = Self { + width: 0.0, + color: ColorMode::TRANSPARENT, + kind: StrokeKind::Middle, + }; + + #[inline] + pub fn new(width: impl Into, color: impl Into) -> Self { + Self { + width: width.into(), + color: ColorMode::Solid(color.into()), + kind: StrokeKind::Middle, + } + } + + /// Create a new `PathStroke` with a UV function + /// + /// The bounding box passed to the callback will have a margin of [`TessellationOptions::feathering_size_in_pixels`](`crate::tessellator::TessellationOptions::feathering_size_in_pixels`) + #[inline] + pub fn new_uv( + width: impl Into, + callback: impl Fn(Rect, Pos2) -> Color32 + Send + Sync + 'static, + ) -> Self { + Self { + width: width.into(), + color: ColorMode::UV(Arc::new(callback)), + kind: StrokeKind::Middle, + } + } + + #[inline] + pub fn with_kind(self, kind: StrokeKind) -> Self { + Self { kind, ..self } + } + + /// Set the stroke to be painted right on the edge of the shape, half inside and half outside. + #[inline] + pub fn middle(self) -> Self { + Self { + kind: StrokeKind::Middle, + ..self + } + } + + /// Set the stroke to be painted entirely outside of the shape + #[inline] + pub fn outside(self) -> Self { + Self { + kind: StrokeKind::Outside, + ..self + } + } + + /// Set the stroke to be painted entirely inside of the shape + #[inline] + pub fn inside(self) -> Self { + Self { + kind: StrokeKind::Inside, + ..self + } + } + + /// True if width is zero or color is solid and transparent + #[inline] + pub fn is_empty(&self) -> bool { + self.width <= 0.0 || self.color == ColorMode::TRANSPARENT + } +} + +impl From<(f32, Color)> for PathStroke +where + Color: Into, +{ + #[inline(always)] + fn from((width, color): (f32, Color)) -> Self { + Self::new(width, color) + } +} + +impl From for PathStroke { + fn from(value: Stroke) -> Self { + if value.is_empty() { + // Important, since we use the stroke color when doing feathering of the fill! + Self::NONE + } else { + Self { + width: value.width, + color: ColorMode::Solid(value.color), + kind: StrokeKind::Middle, + } + } + } +} + +/// Returns true if the nearest integer is odd. +fn is_nearest_integer_odd(x: f32) -> bool { + (x * 0.5 + 0.25).fract() > 0.5 +} + +#[test] +fn test_is_nearest_integer_odd() { + assert!(is_nearest_integer_odd(0.6)); + assert!(is_nearest_integer_odd(1.0)); + assert!(is_nearest_integer_odd(1.4)); + assert!(!is_nearest_integer_odd(1.6)); + assert!(!is_nearest_integer_odd(2.0)); + assert!(!is_nearest_integer_odd(2.4)); + assert!(is_nearest_integer_odd(2.6)); + assert!(is_nearest_integer_odd(3.0)); + assert!(is_nearest_integer_odd(3.4)); +} diff --git a/vendor/epaint/src/tessellator.rs b/vendor/epaint/src/tessellator.rs new file mode 100644 index 0000000..a732402 --- /dev/null +++ b/vendor/epaint/src/tessellator.rs @@ -0,0 +1,2429 @@ +//! Converts graphics primitives into textured triangles. +//! +//! This module converts lines, circles, text and more represented by [`Shape`] +//! into textured triangles represented by [`Mesh`]. + +#![expect(clippy::identity_op)] + +use emath::{GuiRounding as _, NumExt as _, Pos2, Rect, Rot2, Vec2, pos2, remap, vec2}; + +use crate::{ + CircleShape, ClippedPrimitive, ClippedShape, Color32, CornerRadiusF32, CubicBezierShape, + EllipseShape, Mesh, PathShape, Primitive, QuadraticBezierShape, RectShape, Shape, Stroke, + StrokeKind, TextShape, TextureId, Vertex, color::ColorMode, emath, stroke::PathStroke, + texture_atlas::PreparedDisc, +}; + +// ---------------------------------------------------------------------------- + +#[expect(clippy::approx_constant)] +mod precomputed_vertices { + // fn main() { + // let n = 64; + // println!("pub const CIRCLE_{}: [Vec2; {}] = [", n, n+1); + // for i in 0..=n { + // let a = std::f64::consts::TAU * i as f64 / n as f64; + // println!(" vec2({:.06}, {:.06}),", a.cos(), a.sin()); + // } + // println!("];") + // } + + use emath::{Vec2, vec2}; + + pub const CIRCLE_8: [Vec2; 9] = [ + vec2(1.000000, 0.000000), + vec2(0.707107, 0.707107), + vec2(0.000000, 1.000000), + vec2(-0.707107, 0.707107), + vec2(-1.000000, 0.000000), + vec2(-0.707107, -0.707107), + vec2(0.000000, -1.000000), + vec2(0.707107, -0.707107), + vec2(1.000000, 0.000000), + ]; + + pub const CIRCLE_16: [Vec2; 17] = [ + vec2(1.000000, 0.000000), + vec2(0.923880, 0.382683), + vec2(0.707107, 0.707107), + vec2(0.382683, 0.923880), + vec2(0.000000, 1.000000), + vec2(-0.382684, 0.923880), + vec2(-0.707107, 0.707107), + vec2(-0.923880, 0.382683), + vec2(-1.000000, 0.000000), + vec2(-0.923880, -0.382683), + vec2(-0.707107, -0.707107), + vec2(-0.382684, -0.923880), + vec2(0.000000, -1.000000), + vec2(0.382684, -0.923879), + vec2(0.707107, -0.707107), + vec2(0.923880, -0.382683), + vec2(1.000000, 0.000000), + ]; + + pub const CIRCLE_32: [Vec2; 33] = [ + vec2(1.000000, 0.000000), + vec2(0.980785, 0.195090), + vec2(0.923880, 0.382683), + vec2(0.831470, 0.555570), + vec2(0.707107, 0.707107), + vec2(0.555570, 0.831470), + vec2(0.382683, 0.923880), + vec2(0.195090, 0.980785), + vec2(0.000000, 1.000000), + vec2(-0.195090, 0.980785), + vec2(-0.382683, 0.923880), + vec2(-0.555570, 0.831470), + vec2(-0.707107, 0.707107), + vec2(-0.831470, 0.555570), + vec2(-0.923880, 0.382683), + vec2(-0.980785, 0.195090), + vec2(-1.000000, 0.000000), + vec2(-0.980785, -0.195090), + vec2(-0.923880, -0.382683), + vec2(-0.831470, -0.555570), + vec2(-0.707107, -0.707107), + vec2(-0.555570, -0.831470), + vec2(-0.382683, -0.923880), + vec2(-0.195090, -0.980785), + vec2(-0.000000, -1.000000), + vec2(0.195090, -0.980785), + vec2(0.382683, -0.923880), + vec2(0.555570, -0.831470), + vec2(0.707107, -0.707107), + vec2(0.831470, -0.555570), + vec2(0.923880, -0.382683), + vec2(0.980785, -0.195090), + vec2(1.000000, -0.000000), + ]; + + pub const CIRCLE_64: [Vec2; 65] = [ + vec2(1.000000, 0.000000), + vec2(0.995185, 0.098017), + vec2(0.980785, 0.195090), + vec2(0.956940, 0.290285), + vec2(0.923880, 0.382683), + vec2(0.881921, 0.471397), + vec2(0.831470, 0.555570), + vec2(0.773010, 0.634393), + vec2(0.707107, 0.707107), + vec2(0.634393, 0.773010), + vec2(0.555570, 0.831470), + vec2(0.471397, 0.881921), + vec2(0.382683, 0.923880), + vec2(0.290285, 0.956940), + vec2(0.195090, 0.980785), + vec2(0.098017, 0.995185), + vec2(0.000000, 1.000000), + vec2(-0.098017, 0.995185), + vec2(-0.195090, 0.980785), + vec2(-0.290285, 0.956940), + vec2(-0.382683, 0.923880), + vec2(-0.471397, 0.881921), + vec2(-0.555570, 0.831470), + vec2(-0.634393, 0.773010), + vec2(-0.707107, 0.707107), + vec2(-0.773010, 0.634393), + vec2(-0.831470, 0.555570), + vec2(-0.881921, 0.471397), + vec2(-0.923880, 0.382683), + vec2(-0.956940, 0.290285), + vec2(-0.980785, 0.195090), + vec2(-0.995185, 0.098017), + vec2(-1.000000, 0.000000), + vec2(-0.995185, -0.098017), + vec2(-0.980785, -0.195090), + vec2(-0.956940, -0.290285), + vec2(-0.923880, -0.382683), + vec2(-0.881921, -0.471397), + vec2(-0.831470, -0.555570), + vec2(-0.773010, -0.634393), + vec2(-0.707107, -0.707107), + vec2(-0.634393, -0.773010), + vec2(-0.555570, -0.831470), + vec2(-0.471397, -0.881921), + vec2(-0.382683, -0.923880), + vec2(-0.290285, -0.956940), + vec2(-0.195090, -0.980785), + vec2(-0.098017, -0.995185), + vec2(-0.000000, -1.000000), + vec2(0.098017, -0.995185), + vec2(0.195090, -0.980785), + vec2(0.290285, -0.956940), + vec2(0.382683, -0.923880), + vec2(0.471397, -0.881921), + vec2(0.555570, -0.831470), + vec2(0.634393, -0.773010), + vec2(0.707107, -0.707107), + vec2(0.773010, -0.634393), + vec2(0.831470, -0.555570), + vec2(0.881921, -0.471397), + vec2(0.923880, -0.382683), + vec2(0.956940, -0.290285), + vec2(0.980785, -0.195090), + vec2(0.995185, -0.098017), + vec2(1.000000, -0.000000), + ]; + + pub const CIRCLE_128: [Vec2; 129] = [ + vec2(1.000000, 0.000000), + vec2(0.998795, 0.049068), + vec2(0.995185, 0.098017), + vec2(0.989177, 0.146730), + vec2(0.980785, 0.195090), + vec2(0.970031, 0.242980), + vec2(0.956940, 0.290285), + vec2(0.941544, 0.336890), + vec2(0.923880, 0.382683), + vec2(0.903989, 0.427555), + vec2(0.881921, 0.471397), + vec2(0.857729, 0.514103), + vec2(0.831470, 0.555570), + vec2(0.803208, 0.595699), + vec2(0.773010, 0.634393), + vec2(0.740951, 0.671559), + vec2(0.707107, 0.707107), + vec2(0.671559, 0.740951), + vec2(0.634393, 0.773010), + vec2(0.595699, 0.803208), + vec2(0.555570, 0.831470), + vec2(0.514103, 0.857729), + vec2(0.471397, 0.881921), + vec2(0.427555, 0.903989), + vec2(0.382683, 0.923880), + vec2(0.336890, 0.941544), + vec2(0.290285, 0.956940), + vec2(0.242980, 0.970031), + vec2(0.195090, 0.980785), + vec2(0.146730, 0.989177), + vec2(0.098017, 0.995185), + vec2(0.049068, 0.998795), + vec2(0.000000, 1.000000), + vec2(-0.049068, 0.998795), + vec2(-0.098017, 0.995185), + vec2(-0.146730, 0.989177), + vec2(-0.195090, 0.980785), + vec2(-0.242980, 0.970031), + vec2(-0.290285, 0.956940), + vec2(-0.336890, 0.941544), + vec2(-0.382683, 0.923880), + vec2(-0.427555, 0.903989), + vec2(-0.471397, 0.881921), + vec2(-0.514103, 0.857729), + vec2(-0.555570, 0.831470), + vec2(-0.595699, 0.803208), + vec2(-0.634393, 0.773010), + vec2(-0.671559, 0.740951), + vec2(-0.707107, 0.707107), + vec2(-0.740951, 0.671559), + vec2(-0.773010, 0.634393), + vec2(-0.803208, 0.595699), + vec2(-0.831470, 0.555570), + vec2(-0.857729, 0.514103), + vec2(-0.881921, 0.471397), + vec2(-0.903989, 0.427555), + vec2(-0.923880, 0.382683), + vec2(-0.941544, 0.336890), + vec2(-0.956940, 0.290285), + vec2(-0.970031, 0.242980), + vec2(-0.980785, 0.195090), + vec2(-0.989177, 0.146730), + vec2(-0.995185, 0.098017), + vec2(-0.998795, 0.049068), + vec2(-1.000000, 0.000000), + vec2(-0.998795, -0.049068), + vec2(-0.995185, -0.098017), + vec2(-0.989177, -0.146730), + vec2(-0.980785, -0.195090), + vec2(-0.970031, -0.242980), + vec2(-0.956940, -0.290285), + vec2(-0.941544, -0.336890), + vec2(-0.923880, -0.382683), + vec2(-0.903989, -0.427555), + vec2(-0.881921, -0.471397), + vec2(-0.857729, -0.514103), + vec2(-0.831470, -0.555570), + vec2(-0.803208, -0.595699), + vec2(-0.773010, -0.634393), + vec2(-0.740951, -0.671559), + vec2(-0.707107, -0.707107), + vec2(-0.671559, -0.740951), + vec2(-0.634393, -0.773010), + vec2(-0.595699, -0.803208), + vec2(-0.555570, -0.831470), + vec2(-0.514103, -0.857729), + vec2(-0.471397, -0.881921), + vec2(-0.427555, -0.903989), + vec2(-0.382683, -0.923880), + vec2(-0.336890, -0.941544), + vec2(-0.290285, -0.956940), + vec2(-0.242980, -0.970031), + vec2(-0.195090, -0.980785), + vec2(-0.146730, -0.989177), + vec2(-0.098017, -0.995185), + vec2(-0.049068, -0.998795), + vec2(-0.000000, -1.000000), + vec2(0.049068, -0.998795), + vec2(0.098017, -0.995185), + vec2(0.146730, -0.989177), + vec2(0.195090, -0.980785), + vec2(0.242980, -0.970031), + vec2(0.290285, -0.956940), + vec2(0.336890, -0.941544), + vec2(0.382683, -0.923880), + vec2(0.427555, -0.903989), + vec2(0.471397, -0.881921), + vec2(0.514103, -0.857729), + vec2(0.555570, -0.831470), + vec2(0.595699, -0.803208), + vec2(0.634393, -0.773010), + vec2(0.671559, -0.740951), + vec2(0.707107, -0.707107), + vec2(0.740951, -0.671559), + vec2(0.773010, -0.634393), + vec2(0.803208, -0.595699), + vec2(0.831470, -0.555570), + vec2(0.857729, -0.514103), + vec2(0.881921, -0.471397), + vec2(0.903989, -0.427555), + vec2(0.923880, -0.382683), + vec2(0.941544, -0.336890), + vec2(0.956940, -0.290285), + vec2(0.970031, -0.242980), + vec2(0.980785, -0.195090), + vec2(0.989177, -0.146730), + vec2(0.995185, -0.098017), + vec2(0.998795, -0.049068), + vec2(1.000000, -0.000000), + ]; +} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct PathPoint { + pos: Pos2, + + /// For filled paths the normal is used for anti-aliasing (both strokes and filled areas). + /// + /// For strokes the normal is also used for giving thickness to the path + /// (i.e. in what direction to expand). + /// + /// The normal could be estimated by differences between successive points, + /// but that would be less accurate (and in some cases slower). + /// + /// Normals are normally unit-length. + normal: Vec2, +} + +/// A connected line (without thickness or gaps) which can be tessellated +/// to either to a stroke (with thickness) or a filled convex area. +/// Used as a scratch-pad during tessellation. +#[derive(Clone, Debug, Default)] +pub struct Path(Vec); + +impl Path { + #[inline(always)] + pub fn clear(&mut self) { + self.0.clear(); + } + + #[inline(always)] + pub fn reserve(&mut self, additional: usize) { + self.0.reserve(additional); + } + + #[inline(always)] + pub fn add_point(&mut self, pos: Pos2, normal: Vec2) { + self.0.push(PathPoint { pos, normal }); + } + + pub fn add_circle(&mut self, center: Pos2, radius: f32) { + use precomputed_vertices::{CIRCLE_8, CIRCLE_16, CIRCLE_32, CIRCLE_64, CIRCLE_128}; + + // These cutoffs are based on a high-dpi display. TODO(emilk): use pixels_per_point here? + // same cutoffs as in add_circle_quadrant + + if radius <= 2.0 { + self.0.extend(CIRCLE_8.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else if radius <= 5.0 { + self.0.extend(CIRCLE_16.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else if radius < 18.0 { + self.0.extend(CIRCLE_32.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else if radius < 50.0 { + self.0.extend(CIRCLE_64.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else { + self.0.extend(CIRCLE_128.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } + } + + pub fn add_line_segment(&mut self, points: [Pos2; 2]) { + self.reserve(2); + let normal = (points[1] - points[0]).normalized().rot90(); + self.add_point(points[0], normal); + self.add_point(points[1], normal); + } + + pub fn add_open_points(&mut self, points: &[Pos2]) { + let n = points.len(); + assert!(n >= 2, "A path needs at least two points, but got {n}"); + + if n == 2 { + // Common case optimization: + self.add_line_segment([points[0], points[1]]); + } else { + self.reserve(n); + self.add_point(points[0], (points[1] - points[0]).normalized().rot90()); + let mut n0 = (points[1] - points[0]).normalized().rot90(); + for i in 1..n - 1 { + let mut n1 = (points[i + 1] - points[i]).normalized().rot90(); + + // Handle duplicated points (but not triplicated…): + if n0 == Vec2::ZERO { + n0 = n1; + } else if n1 == Vec2::ZERO { + n1 = n0; + } + + let normal = (n0 + n1) / 2.0; + let length_sq = normal.length_sq(); + let right_angle_length_sq = 0.5; + let sharper_than_a_right_angle = length_sq < right_angle_length_sq; + if sharper_than_a_right_angle { + // cut off the sharp corner + let center_normal = normal.normalized(); + let n0c = (n0 + center_normal) / 2.0; + let n1c = (n1 + center_normal) / 2.0; + self.add_point(points[i], n0c / n0c.length_sq()); + self.add_point(points[i], n1c / n1c.length_sq()); + } else { + // miter join + self.add_point(points[i], normal / length_sq); + } + + n0 = n1; + } + self.add_point( + points[n - 1], + (points[n - 1] - points[n - 2]).normalized().rot90(), + ); + } + } + + pub fn add_line_loop(&mut self, points: &[Pos2]) { + let n = points.len(); + assert!(n >= 2, "A path needs at least two points, but got {n}"); + self.reserve(n); + + let mut n0 = (points[0] - points[n - 1]).normalized().rot90(); + + for i in 0..n { + let next_i = if i + 1 == n { 0 } else { i + 1 }; + let mut n1 = (points[next_i] - points[i]).normalized().rot90(); + + // Handle duplicated points (but not triplicated…): + if n0 == Vec2::ZERO { + n0 = n1; + } else if n1 == Vec2::ZERO { + n1 = n0; + } + + let normal = (n0 + n1) / 2.0; + let length_sq = normal.length_sq(); + + // We can't just cut off corners for filled shapes like this, + // because the feather will both expand and contract the corner along the provided normals + // to make sure it doesn't grow, and the shrinking will make the inner points cross each other. + // + // A better approach is to shrink the vertices in by half the feather-width here + // and then only expand during feathering. + // + // See https://github.com/emilk/egui/issues/1226 + const CUT_OFF_SHARP_CORNERS: bool = false; + + let right_angle_length_sq = 0.5; + let sharper_than_a_right_angle = length_sq < right_angle_length_sq; + if CUT_OFF_SHARP_CORNERS && sharper_than_a_right_angle { + // cut off the sharp corner + let center_normal = normal.normalized(); + let n0c = (n0 + center_normal) / 2.0; + let n1c = (n1 + center_normal) / 2.0; + self.add_point(points[i], n0c / n0c.length_sq()); + self.add_point(points[i], n1c / n1c.length_sq()); + } else { + // miter join + self.add_point(points[i], normal / length_sq); + } + + n0 = n1; + } + } + + /// The path is taken to be closed (i.e. returning to the start again). + /// + /// Calling this may reverse the vertices in the path if they are wrong winding order. + /// The preferred winding order is clockwise. + pub fn fill_and_stroke( + &mut self, + feathering: f32, + fill: Color32, + stroke: &PathStroke, + out: &mut Mesh, + ) { + stroke_and_fill_path(feathering, &mut self.0, PathType::Closed, stroke, fill, out); + } + + /// Open-ended. + pub fn stroke_open(&mut self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { + stroke_path(feathering, &mut self.0, PathType::Open, stroke, out); + } + + /// A closed path (returning to the first point). + pub fn stroke_closed(&mut self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { + stroke_path(feathering, &mut self.0, PathType::Closed, stroke, out); + } + + pub fn stroke( + &mut self, + feathering: f32, + path_type: PathType, + stroke: &PathStroke, + out: &mut Mesh, + ) { + stroke_path(feathering, &mut self.0, path_type, stroke, out); + } + + /// The path is taken to be closed (i.e. returning to the start again). + /// + /// Calling this may reverse the vertices in the path if they are wrong winding order. + /// The preferred winding order is clockwise. + pub fn fill(&mut self, feathering: f32, color: Color32, out: &mut Mesh) { + fill_closed_path(feathering, &mut self.0, color, out); + } + + /// Like [`Self::fill`] but with texturing. + /// + /// The `uv_from_pos` is called for each vertex position. + pub fn fill_with_uv( + &mut self, + feathering: f32, + color: Color32, + texture_id: TextureId, + uv_from_pos: impl Fn(Pos2) -> Pos2, + out: &mut Mesh, + ) { + fill_closed_path_with_uv(feathering, &mut self.0, color, texture_id, uv_from_pos, out); + } +} + +pub mod path { + //! Helpers for constructing paths + use crate::CornerRadiusF32; + use emath::{Pos2, Rect, pos2}; + + /// overwrites existing points + pub fn rounded_rectangle(path: &mut Vec, rect: Rect, cr: CornerRadiusF32) { + path.clear(); + + let min = rect.min; + let max = rect.max; + + let cr = clamp_corner_radius(cr, rect); + + if cr == CornerRadiusF32::ZERO { + path.reserve(4); + path.push(pos2(min.x, min.y)); // left top + path.push(pos2(max.x, min.y)); // right top + path.push(pos2(max.x, max.y)); // right bottom + path.push(pos2(min.x, max.y)); // left bottom + } else { + // We need to avoid duplicated vertices, because that leads to visual artifacts later. + // Duplicated vertices can happen when one side is all rounding, with no straight edge between. + let eps = f32::EPSILON * rect.size().max_elem(); + + add_circle_quadrant(path, pos2(max.x - cr.se, max.y - cr.se), cr.se, 0.0); // south east + + if rect.width() <= cr.se + cr.sw + eps { + path.pop(); // avoid duplicated vertex + } + + add_circle_quadrant(path, pos2(min.x + cr.sw, max.y - cr.sw), cr.sw, 1.0); // south west + + if rect.height() <= cr.sw + cr.nw + eps { + path.pop(); // avoid duplicated vertex + } + + add_circle_quadrant(path, pos2(min.x + cr.nw, min.y + cr.nw), cr.nw, 2.0); // north west + + if rect.width() <= cr.nw + cr.ne + eps { + path.pop(); // avoid duplicated vertex + } + + add_circle_quadrant(path, pos2(max.x - cr.ne, min.y + cr.ne), cr.ne, 3.0); // north east + + if rect.height() <= cr.ne + cr.se + eps { + path.pop(); // avoid duplicated vertex + } + } + } + + /// Add one quadrant of a circle + /// + /// * quadrant 0: right bottom + /// * quadrant 1: left bottom + /// * quadrant 2: left top + /// * quadrant 3: right top + // + // Derivation: + // + // * angle 0 * TAU / 4 = right + // - quadrant 0: right bottom + // * angle 1 * TAU / 4 = bottom + // - quadrant 1: left bottom + // * angle 2 * TAU / 4 = left + // - quadrant 2: left top + // * angle 3 * TAU / 4 = top + // - quadrant 3: right top + // * angle 4 * TAU / 4 = right + pub fn add_circle_quadrant(path: &mut Vec, center: Pos2, radius: f32, quadrant: f32) { + use super::precomputed_vertices::{CIRCLE_8, CIRCLE_16, CIRCLE_32, CIRCLE_64, CIRCLE_128}; + + // These cutoffs are based on a high-dpi display. TODO(emilk): use pixels_per_point here? + // same cutoffs as in add_circle + + if radius <= 0.0 { + path.push(center); + } else if radius <= 2.0 { + let offset = quadrant as usize * 2; + let quadrant_vertices = &CIRCLE_8[offset..=offset + 2]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else if radius <= 5.0 { + let offset = quadrant as usize * 4; + let quadrant_vertices = &CIRCLE_16[offset..=offset + 4]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else if radius < 18.0 { + let offset = quadrant as usize * 8; + let quadrant_vertices = &CIRCLE_32[offset..=offset + 8]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else if radius < 50.0 { + let offset = quadrant as usize * 16; + let quadrant_vertices = &CIRCLE_64[offset..=offset + 16]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else { + let offset = quadrant as usize * 32; + let quadrant_vertices = &CIRCLE_128[offset..=offset + 32]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } + } + + // Ensures the radius of each corner is within a valid range + fn clamp_corner_radius(cr: CornerRadiusF32, rect: Rect) -> CornerRadiusF32 { + let half_width = rect.width() * 0.5; + let half_height = rect.height() * 0.5; + let max_cr = half_width.min(half_height); + cr.at_most(max_cr).at_least(0.0) + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PathType { + Open, + Closed, +} + +/// Tessellation quality options +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct TessellationOptions { + /// Use "feathering" to smooth out the edges of shapes as a form of anti-aliasing. + /// + /// Feathering works by making each edge into a thin gradient into transparency. + /// The size of this edge is controlled by [`Self::feathering_size_in_pixels`]. + /// + /// This makes shapes appear smoother, but requires more triangles and is therefore slower. + /// + /// This setting does not affect text. + /// + /// Default: `true`. + pub feathering: bool, + + /// The size of the feathering, in physical pixels. + /// + /// The default, and suggested, value for this is `1.0`. + /// If you use a larger value, edges will appear blurry. + pub feathering_size_in_pixels: f32, + + /// If `true` (default) cull certain primitives before tessellating them. + /// This likely makes + pub coarse_tessellation_culling: bool, + + /// If `true`, small filled circled will be optimized by using pre-rasterized circled + /// from the font atlas. + pub prerasterized_discs: bool, + + /// If `true` (default) align text to the physical pixel grid. + /// This makes the text sharper on most platforms. + pub round_text_to_pixels: bool, + + /// If `true` (default), align right-angled line segments to the physical pixel grid. + /// + /// This makes the line segments appear crisp on any display. + pub round_line_segments_to_pixels: bool, + + /// If `true` (default), align rectangles to the physical pixel grid. + /// + /// This makes the rectangle strokes more crisp, + /// and makes filled rectangles tile perfectly (without feathering). + /// + /// You can override this with [`crate::RectShape::round_to_pixels`]. + pub round_rects_to_pixels: bool, + + /// Output the clip rectangles to be painted. + pub debug_paint_clip_rects: bool, + + /// Output the text-containing rectangles. + pub debug_paint_text_rects: bool, + + /// If true, no clipping will be done. + pub debug_ignore_clip_rects: bool, + + /// The maximum distance between the original curve and the flattened curve. + pub bezier_tolerance: f32, + + /// The default value will be 1.0e-5, it will be used during float compare. + pub epsilon: f32, + + /// If `rayon` feature is activated, should we parallelize tessellation? + pub parallel_tessellation: bool, + + /// If `true`, invalid meshes will be silently ignored. + /// If `false`, invalid meshes will cause a panic. + /// + /// The default is `false` to save performance. + pub validate_meshes: bool, +} + +impl Default for TessellationOptions { + fn default() -> Self { + Self { + feathering: true, + feathering_size_in_pixels: 1.0, + coarse_tessellation_culling: true, + prerasterized_discs: true, + round_text_to_pixels: true, + round_line_segments_to_pixels: true, + round_rects_to_pixels: true, + debug_paint_text_rects: false, + debug_paint_clip_rects: false, + debug_ignore_clip_rects: false, + bezier_tolerance: 0.1, + epsilon: 1.0e-5, + parallel_tessellation: true, + validate_meshes: false, + } + } +} + +fn cw_signed_area(path: &[PathPoint]) -> f64 { + if let Some(last) = path.last() { + let mut previous = last.pos; + let mut area = 0.0; + for p in path { + area += (previous.x * p.pos.y - p.pos.x * previous.y) as f64; + previous = p.pos; + } + area + } else { + 0.0 + } +} + +/// Tessellate the given convex area into a polygon. +/// +/// Calling this may reverse the vertices in the path if they are wrong winding order. +/// +/// The preferred winding order is clockwise. +fn fill_closed_path(feathering: f32, path: &mut [PathPoint], fill_color: Color32, out: &mut Mesh) { + if fill_color == Color32::TRANSPARENT { + return; + } + + let n = path.len() as u32; + if n < 3 { + return; + } + + if 0.0 < feathering { + if cw_signed_area(path) < 0.0 { + // Wrong winding order - fix: + path.reverse(); + for point in &mut *path { + point.normal = -point.normal; + } + } + + out.reserve_triangles(3 * n as usize); + out.reserve_vertices(2 * n as usize); + let idx_inner = out.vertices.len() as u32; + let idx_outer = idx_inner + 1; + + // The fill: + for i in 2..n { + out.add_triangle(idx_inner + 2 * (i - 1), idx_inner, idx_inner + 2 * i); + } + + // The feathering: + let mut i0 = n - 1; + for i1 in 0..n { + let p1 = &path[i1 as usize]; + let dm = 0.5 * feathering * p1.normal; + + let pos_inner = p1.pos - dm; + let pos_outer = p1.pos + dm; + + out.colored_vertex(pos_inner, fill_color); + out.colored_vertex(pos_outer, Color32::TRANSPARENT); + out.add_triangle(idx_inner + i1 * 2, idx_inner + i0 * 2, idx_outer + 2 * i0); + out.add_triangle(idx_outer + i0 * 2, idx_outer + i1 * 2, idx_inner + 2 * i1); + i0 = i1; + } + } else { + out.reserve_triangles(n as usize); + let idx = out.vertices.len() as u32; + out.vertices + .extend(path.iter().map(|p| Vertex::untextured(p.pos, fill_color))); + for i in 2..n { + out.add_triangle(idx, idx + i - 1, idx + i); + } + } +} + +/// Like [`fill_closed_path`] but with texturing. +/// +/// The `uv_from_pos` is called for each vertex position. +fn fill_closed_path_with_uv( + feathering: f32, + path: &mut [PathPoint], + color: Color32, + texture_id: TextureId, + uv_from_pos: impl Fn(Pos2) -> Pos2, + out: &mut Mesh, +) { + if color == Color32::TRANSPARENT { + return; + } + + if out.is_empty() { + out.texture_id = texture_id; + } else { + assert_eq!( + out.texture_id, texture_id, + "Mixing different `texture_id` in the same " + ); + } + + let n = path.len() as u32; + if 0.0 < feathering { + if cw_signed_area(path) < 0.0 { + // Wrong winding order - fix: + path.reverse(); + for point in &mut *path { + point.normal = -point.normal; + } + } + + out.reserve_triangles(3 * n as usize); + out.reserve_vertices(2 * n as usize); + let color_outer = Color32::TRANSPARENT; + let idx_inner = out.vertices.len() as u32; + let idx_outer = idx_inner + 1; + + // The fill: + for i in 2..n { + out.add_triangle(idx_inner + 2 * (i - 1), idx_inner, idx_inner + 2 * i); + } + + // The feathering: + let mut i0 = n - 1; + for i1 in 0..n { + let p1 = &path[i1 as usize]; + let dm = 0.5 * feathering * p1.normal; + + let pos = p1.pos - dm; + out.vertices.push(Vertex { + pos, + uv: uv_from_pos(pos), + color, + }); + + let pos = p1.pos + dm; + out.vertices.push(Vertex { + pos, + uv: uv_from_pos(pos), + color: color_outer, + }); + + out.add_triangle(idx_inner + i1 * 2, idx_inner + i0 * 2, idx_outer + 2 * i0); + out.add_triangle(idx_outer + i0 * 2, idx_outer + i1 * 2, idx_inner + 2 * i1); + i0 = i1; + } + } else { + out.reserve_triangles(n as usize); + let idx = out.vertices.len() as u32; + out.vertices.extend(path.iter().map(|p| Vertex { + pos: p.pos, + uv: uv_from_pos(p.pos), + color, + })); + for i in 2..n { + out.add_triangle(idx, idx + i - 1, idx + i); + } + } +} + +/// Tessellate the given path as a stroke with thickness. +fn stroke_path( + feathering: f32, + path: &mut [PathPoint], + path_type: PathType, + stroke: &PathStroke, + out: &mut Mesh, +) { + let fill = Color32::TRANSPARENT; + stroke_and_fill_path(feathering, path, path_type, stroke, fill, out); +} + +/// Tessellate the given path as a stroke with thickness, with optional fill color. +/// +/// Calling this may reverse the vertices in the path if they are wrong winding order. +/// +/// The preferred winding order is clockwise. +fn stroke_and_fill_path( + feathering: f32, + path: &mut [PathPoint], + path_type: PathType, + stroke: &PathStroke, + color_fill: Color32, + out: &mut Mesh, +) { + let n = path.len() as u32; + + if n < 2 { + return; + } + + if stroke.width == 0.0 { + // Skip the stroke, just fill. + return fill_closed_path(feathering, path, color_fill, out); + } + + if color_fill != Color32::TRANSPARENT && cw_signed_area(path) < 0.0 { + // Wrong winding order - fix: + path.reverse(); + for point in &mut *path { + point.normal = -point.normal; + } + } + + if stroke.color == ColorMode::TRANSPARENT { + // Skip the stroke, just fill. But subtract the width from the path: + match stroke.kind { + StrokeKind::Inside => { + for point in &mut *path { + point.pos -= stroke.width * point.normal; + } + } + StrokeKind::Middle => { + for point in &mut *path { + point.pos -= 0.5 * stroke.width * point.normal; + } + } + StrokeKind::Outside => {} + } + + // Skip the stroke, just fill. + return fill_closed_path(feathering, path, color_fill, out); + } + + let idx = out.vertices.len() as u32; + + // Move the points so that the stroke is on middle of the path. + match stroke.kind { + StrokeKind::Inside => { + for point in &mut *path { + point.pos -= 0.5 * stroke.width * point.normal; + } + } + StrokeKind::Middle => { + // correct + } + StrokeKind::Outside => { + for point in &mut *path { + point.pos += 0.5 * stroke.width * point.normal; + } + } + } + + // Expand the bounding box to include the thickness of the path + let uv_bbox = if matches!(stroke.color, ColorMode::UV(_)) { + Rect::from_points(&path.iter().map(|p| p.pos).collect::>()) + .expand((stroke.width / 2.0) + feathering) + } else { + Rect::NAN + }; + let get_color = |col: &ColorMode, pos: Pos2| match col { + ColorMode::Solid(col) => *col, + ColorMode::UV(fun) => fun(uv_bbox, pos), + }; + + if 0.0 < feathering { + let color_outer = Color32::TRANSPARENT; + let color_middle = &stroke.color; + + // We add a bit of an epsilon here, because when we round to pixels, + // we can get rounding errors (unless pixels_per_point is an integer). + // And it's better to err on the side of the nicer rendering with line caps + // (the thin-line optimization has no line caps). + let thin_line = stroke.width <= 0.9 * feathering; + if thin_line { + // If the stroke is painted smaller than the pixel width (=feathering width), + // then we risk severe aliasing. + // Instead, we paint the stroke as a triangular ridge, two feather-widths wide, + // and lessen the opacity of the middle part instead of making it thinner. + if color_fill != Color32::TRANSPARENT && stroke.width < feathering { + // If this is filled shape, then we need to also compensate so that the + // filled area remains the same as it would have been without the + // artificially wide line. + for point in &mut *path { + point.pos += 0.5 * (feathering - stroke.width) * point.normal; + } + } + + // TODO(emilk): add line caps (if this is an open line). + + let opacity = stroke.width / feathering; + + /* + We paint the line using three edges: outer, middle, fill. + + . o m i outer, middle, fill + . |---| feathering (pixel width) + */ + + out.reserve_triangles(4 * n as usize); + out.reserve_vertices(3 * n as usize); + + let mut i0 = n - 1; + for i1 in 0..n { + let connect_with_previous = path_type == PathType::Closed || i1 > 0; + let p1 = path[i1 as usize]; + let p = p1.pos; + let n = p1.normal; + out.colored_vertex(p + n * feathering, color_outer); + out.colored_vertex(p, mul_color(get_color(color_middle, p), opacity)); + out.colored_vertex(p - n * feathering, color_fill); + + if connect_with_previous { + out.add_triangle(idx + 3 * i0 + 0, idx + 3 * i0 + 1, idx + 3 * i1 + 0); + out.add_triangle(idx + 3 * i0 + 1, idx + 3 * i1 + 0, idx + 3 * i1 + 1); + + out.add_triangle(idx + 3 * i0 + 1, idx + 3 * i0 + 2, idx + 3 * i1 + 1); + out.add_triangle(idx + 3 * i0 + 2, idx + 3 * i1 + 1, idx + 3 * i1 + 2); + } + + i0 = i1; + } + + if color_fill != Color32::TRANSPARENT { + out.reserve_triangles(n as usize - 2); + let idx_fill = idx + 2; + for i in 2..n { + out.add_triangle(idx_fill + 3 * (i - 1), idx_fill, idx_fill + 3 * i); + } + } + } else { + // thick anti-aliased line + + /* + We paint the line using four edges: outer, middle, middle, fill + + . o m p m f outer, middle, point, middle, fill + . |---| feathering (pixel width) + . |--------------| width + . |---------| outer_rad + . |-----| inner_rad + */ + + let inner_rad = 0.5 * (stroke.width - feathering); + let outer_rad = 0.5 * (stroke.width + feathering); + + match path_type { + PathType::Closed => { + out.reserve_triangles(6 * n as usize); + out.reserve_vertices(4 * n as usize); + + let mut i0 = n - 1; + for i1 in 0..n { + let p1 = path[i1 as usize]; + let p = p1.pos; + let n = p1.normal; + out.colored_vertex(p + n * outer_rad, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad, color_fill); + + out.add_triangle(idx + 4 * i0 + 0, idx + 4 * i0 + 1, idx + 4 * i1 + 0); + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i1 + 0, idx + 4 * i1 + 1); + + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i0 + 2, idx + 4 * i1 + 1); + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i0 + 3, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i0 + 3, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + + i0 = i1; + } + + if color_fill != Color32::TRANSPARENT { + out.reserve_triangles(n as usize - 2); + let idx_fill = idx + 3; + for i in 2..n { + out.add_triangle(idx_fill + 4 * (i - 1), idx_fill, idx_fill + 4 * i); + } + } + } + PathType::Open => { + // Anti-alias the ends by extruding the outer edge and adding + // two more triangles to each end: + + // | aa | | aa | + // _________________ ___ + // | \ added / | feathering + // | \ ___p___ / | ___ + // | | | | + // | | opa | | + // | | que | | + // | | | | + + // (in the future it would be great with an option to add a circular end instead) + + // TODO(emilk): we should probably shrink before adding the line caps, + // so that we don't add to the area of the line. + // TODO(emilk): make line caps optional. + + out.reserve_triangles(6 * n as usize + 4); + out.reserve_vertices(4 * n as usize); + + { + let end = path[0]; + let p = end.pos; + let n = end.normal; + let back_extrude = n.rot90() * feathering; + out.colored_vertex(p + n * outer_rad + back_extrude, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad + back_extrude, color_outer); + + out.add_triangle(idx + 0, idx + 1, idx + 2); + out.add_triangle(idx + 0, idx + 2, idx + 3); + } + + let mut i0 = 0; + for i1 in 1..n - 1 { + let point = path[i1 as usize]; + let p = point.pos; + let n = point.normal; + out.colored_vertex(p + n * outer_rad, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad, color_outer); + + out.add_triangle(idx + 4 * i0 + 0, idx + 4 * i0 + 1, idx + 4 * i1 + 0); + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i1 + 0, idx + 4 * i1 + 1); + + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i0 + 2, idx + 4 * i1 + 1); + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i0 + 3, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i0 + 3, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + + i0 = i1; + } + + { + let i1 = n - 1; + let end = path[i1 as usize]; + let p = end.pos; + let n = end.normal; + let back_extrude = -n.rot90() * feathering; + out.colored_vertex(p + n * outer_rad + back_extrude, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad + back_extrude, color_outer); + + out.add_triangle(idx + 4 * i0 + 0, idx + 4 * i0 + 1, idx + 4 * i1 + 0); + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i1 + 0, idx + 4 * i1 + 1); + + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i0 + 2, idx + 4 * i1 + 1); + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i0 + 3, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i0 + 3, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + + // The extension: + out.add_triangle(idx + 4 * i1 + 0, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i1 + 0, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + } + } + } + } + } else { + // not anti-aliased: + out.reserve_triangles(2 * n as usize); + out.reserve_vertices(2 * n as usize); + + let last_index = if path_type == PathType::Closed { + n + } else { + n - 1 + }; + for i in 0..last_index { + out.add_triangle( + idx + (2 * i + 0) % (2 * n), + idx + (2 * i + 1) % (2 * n), + idx + (2 * i + 2) % (2 * n), + ); + out.add_triangle( + idx + (2 * i + 2) % (2 * n), + idx + (2 * i + 1) % (2 * n), + idx + (2 * i + 3) % (2 * n), + ); + } + + let thin_line = stroke.width <= feathering; + if thin_line { + // Fade out thin lines rather than making them thinner + let opacity = stroke.width / feathering; + let radius = feathering / 2.0; + for p in path.iter_mut() { + out.colored_vertex( + p.pos + radius * p.normal, + mul_color(get_color(&stroke.color, p.pos + radius * p.normal), opacity), + ); + out.colored_vertex( + p.pos - radius * p.normal, + mul_color(get_color(&stroke.color, p.pos - radius * p.normal), opacity), + ); + } + } else { + let radius = stroke.width / 2.0; + for p in path.iter_mut() { + out.colored_vertex( + p.pos + radius * p.normal, + get_color(&stroke.color, p.pos + radius * p.normal), + ); + out.colored_vertex( + p.pos - radius * p.normal, + get_color(&stroke.color, p.pos - radius * p.normal), + ); + } + } + + if color_fill != Color32::TRANSPARENT { + // We Need to create new vertices, because the ones we used for the stroke + // has the wrong color. + + // Shrink to ignore the stroke… + for point in &mut *path { + point.pos -= 0.5 * stroke.width * point.normal; + } + // …then fill: + fill_closed_path(feathering, path, color_fill, out); + } + } +} + +fn mul_color(color: Color32, factor: f32) -> Color32 { + // The fast gamma-space multiply also happens to be perceptually better. + // Win-win! + color.gamma_multiply(factor) +} + +// ---------------------------------------------------------------------------- + +/// Converts [`Shape`]s into triangles ([`Mesh`]). +/// +/// For performance reasons it is smart to reuse the same [`Tessellator`]. +#[derive(Clone)] +pub struct Tessellator { + pixels_per_point: f32, + options: TessellationOptions, + font_tex_size: [usize; 2], + + /// See [`crate::TextureAtlas::prepared_discs`]. + prepared_discs: Vec, + + /// size of feathering in points. normally the size of a physical pixel. 0.0 if disabled + feathering: f32, + + /// Only used for culling + clip_rect: Rect, + + scratchpad_points: Vec, + scratchpad_path: Path, +} + +impl Tessellator { + /// Create a new [`Tessellator`]. + /// + /// * `pixels_per_point`: number of physical pixels to each logical point + /// * `options`: tessellation quality + /// * `shapes`: what to tessellate + /// * `font_tex_size`: size of the font texture. Required to normalize glyph uv rectangles when tessellating text. + /// * `prepared_discs`: What [`crate::TextureAtlas::prepared_discs`] returns. Can safely be set to an empty vec. + pub fn new( + pixels_per_point: f32, + options: TessellationOptions, + font_tex_size: [usize; 2], + prepared_discs: Vec, + ) -> Self { + let feathering = if options.feathering { + let pixel_size = 1.0 / pixels_per_point; + options.feathering_size_in_pixels * pixel_size + } else { + 0.0 + }; + Self { + pixels_per_point, + options, + font_tex_size, + prepared_discs, + feathering, + clip_rect: Rect::EVERYTHING, + scratchpad_points: Default::default(), + scratchpad_path: Default::default(), + } + } + + /// Set the [`Rect`] to use for culling. + pub fn set_clip_rect(&mut self, clip_rect: Rect) { + self.clip_rect = clip_rect; + } + + /// Tessellate a clipped shape into a list of primitives. + pub fn tessellate_clipped_shape( + &mut self, + clipped_shape: ClippedShape, + out_primitives: &mut Vec, + ) { + let ClippedShape { clip_rect, shape } = clipped_shape; + + if !clip_rect.is_positive() { + return; // skip empty clip rectangles + } + + if let Shape::Vec(shapes) = shape { + for shape in shapes { + self.tessellate_clipped_shape(ClippedShape { clip_rect, shape }, out_primitives); + } + return; + } + + if let Shape::Callback(callback) = shape { + out_primitives.push(ClippedPrimitive { + clip_rect, + primitive: Primitive::Callback(callback), + }); + return; + } + + let start_new_mesh = match out_primitives.last() { + None => true, + Some(output_clipped_primitive) => { + output_clipped_primitive.clip_rect != clip_rect + || match &output_clipped_primitive.primitive { + Primitive::Mesh(output_mesh) => { + output_mesh.texture_id != shape.texture_id() + } + Primitive::Callback(_) => true, + } + } + }; + + if start_new_mesh { + out_primitives.push(ClippedPrimitive { + clip_rect, + primitive: Primitive::Mesh(Mesh::default()), + }); + } + + #[expect(clippy::unwrap_used)] // it's never empty + let out = out_primitives.last_mut().unwrap(); + + if let Primitive::Mesh(out_mesh) = &mut out.primitive { + self.clip_rect = clip_rect; + self.tessellate_shape(shape, out_mesh); + } else { + unreachable!(); + } + } + + /// Tessellate a single [`Shape`] into a [`Mesh`]. + /// + /// This call can panic the given shape is of [`Shape::Vec`] or [`Shape::Callback`]. + /// For that, use [`Self::tessellate_clipped_shape`] instead. + /// * `shape`: the shape to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_shape(&mut self, shape: Shape, out: &mut Mesh) { + match shape { + Shape::Noop => {} + Shape::Vec(vec) => { + for shape in vec { + self.tessellate_shape(shape, out); + } + } + Shape::Circle(circle) => { + self.tessellate_circle(circle, out); + } + Shape::Ellipse(ellipse) => { + self.tessellate_ellipse(ellipse, out); + } + Shape::Mesh(mesh) => { + profiling::scope!("mesh"); + + if self.options.validate_meshes && !mesh.is_valid() { + debug_assert!(false, "Invalid Mesh in Shape::Mesh"); + return; + } + // note: `append` still checks if the mesh is valid if extra asserts are enabled. + + if self.options.coarse_tessellation_culling + && !self.clip_rect.intersects(mesh.calc_bounds()) + { + return; + } + + out.append_ref(&mesh); + } + Shape::LineSegment { points, stroke } => { + self.tessellate_line_segment(points, stroke, out); + } + Shape::Path(path_shape) => { + self.tessellate_path(&path_shape, out); + } + Shape::Rect(rect_shape) => { + self.tessellate_rect(&rect_shape, out); + } + Shape::Text(text_shape) => { + if self.options.debug_paint_text_rects { + let rect = text_shape.galley.rect.translate(text_shape.pos.to_vec2()); + self.tessellate_rect( + &RectShape::stroke(rect, 2.0, (0.5, Color32::GREEN), StrokeKind::Outside), + out, + ); + } + self.tessellate_text(&text_shape, out); + } + Shape::QuadraticBezier(quadratic_shape) => { + self.tessellate_quadratic_bezier(&quadratic_shape, out); + } + Shape::CubicBezier(cubic_shape) => self.tessellate_cubic_bezier(&cubic_shape, out), + Shape::Callback(_) => { + panic!("Shape::Callback passed to Tessellator"); + } + } + } + + /// Tessellate a single [`CircleShape`] into a [`Mesh`]. + /// + /// * `shape`: the circle to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_circle(&mut self, shape: CircleShape, out: &mut Mesh) { + let CircleShape { + center, + radius, + mut fill, + stroke, + } = shape; + + if radius <= 0.0 { + return; + } + + if self.options.coarse_tessellation_culling + && !self + .clip_rect + .expand(radius + stroke.width) + .contains(center) + { + return; + } + + if self.options.prerasterized_discs && fill != Color32::TRANSPARENT { + let radius_px = radius * self.pixels_per_point; + // strike the right balance between some circles becoming too blurry, and some too sharp. + let cutoff_radius = radius_px * 2.0_f32.powf(0.25); + + // Find the right disc radius for a crisp edge: + // TODO(emilk): perhaps we can do something faster than this linear search. + for disc in &self.prepared_discs { + if cutoff_radius <= disc.r { + let side = radius_px * disc.w / (self.pixels_per_point * disc.r); + let rect = Rect::from_center_size(center, Vec2::splat(side)); + out.add_rect_with_uv(rect, disc.uv, fill); + + if stroke.is_empty() { + return; // we are done + } else { + // we still need to do the stroke + fill = Color32::TRANSPARENT; // don't fill again below + break; + } + } + } + } + + let path_stroke = PathStroke::from(stroke).outside(); + self.scratchpad_path.clear(); + self.scratchpad_path.add_circle(center, radius); + self.scratchpad_path + .fill_and_stroke(self.feathering, fill, &path_stroke, out); + } + + /// Tessellate a single [`EllipseShape`] into a [`Mesh`]. + /// + /// * `shape`: the ellipse to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_ellipse(&mut self, shape: EllipseShape, out: &mut Mesh) { + let EllipseShape { + center, + radius, + fill, + stroke, + angle, + } = shape; + + if radius.x <= 0.0 || radius.y <= 0.0 { + return; + } + + if self.options.coarse_tessellation_culling + && !self + .clip_rect + .expand2(radius + Vec2::splat(stroke.width)) + .contains(center) + { + return; + } + + // Get the max pixel radius + let max_radius = (radius.max_elem() * self.pixels_per_point) as u32; + + // Ensure there is at least 8 points in each quarter of the ellipse + let num_points = u32::max(8, max_radius / 16); + + // Create an ease ratio based the ellipses a and b + let ratio = ((radius.y / radius.x) / 2.0).clamp(0.0, 1.0); + + // Generate points between the 0 to pi/2 + let quarter: Vec = (1..num_points) + .map(|i| { + let percent = i as f32 / num_points as f32; + + // Ease the percent value, concentrating points around tight bends + let eased = 2.0 * (percent - percent.powf(2.0)) * ratio + percent.powf(2.0); + + // Scale the ease to the quarter + let t = eased * std::f32::consts::FRAC_PI_2; + Vec2::new(radius.x * f32::cos(t), radius.y * f32::sin(t)) + }) + .collect(); + + // Build the ellipse from the 4 known vertices filling arcs between + // them by mirroring the points between 0 and pi/2 + let mut points = Vec::new(); + points.push(center + Vec2::new(radius.x, 0.0)); + points.extend(quarter.iter().map(|p| center + *p)); + points.push(center + Vec2::new(0.0, radius.y)); + points.extend(quarter.iter().rev().map(|p| center + Vec2::new(-p.x, p.y))); + points.push(center + Vec2::new(-radius.x, 0.0)); + points.extend(quarter.iter().map(|p| center - *p)); + points.push(center + Vec2::new(0.0, -radius.y)); + points.extend(quarter.iter().rev().map(|p| center + Vec2::new(p.x, -p.y))); + + // Apply rotation if angle is non-zero + if angle != 0.0 { + let rot = emath::Rot2::from_angle(angle); + for point in &mut points { + *point = center + rot * (*point - center); + } + } + + let path_stroke = PathStroke::from(stroke).outside(); + self.scratchpad_path.clear(); + self.scratchpad_path.add_line_loop(&points); + self.scratchpad_path + .fill_and_stroke(self.feathering, fill, &path_stroke, out); + } + + /// Tessellate a single [`Mesh`] into a [`Mesh`]. + /// + /// * `mesh`: the mesh to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_mesh(&self, mesh: &Mesh, out: &mut Mesh) { + if !mesh.is_valid() { + debug_assert!(false, "Invalid Mesh in Shape::Mesh"); + return; + } + + if self.options.coarse_tessellation_culling + && !self.clip_rect.intersects(mesh.calc_bounds()) + { + return; + } + + out.append_ref(mesh); + } + + /// Tessellate a line segment between the two points with the given stroke into a [`Mesh`]. + /// + /// * `shape`: the mesh to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_line_segment( + &mut self, + mut points: [Pos2; 2], + stroke: impl Into, + out: &mut Mesh, + ) { + let stroke = stroke.into(); + if stroke.is_empty() { + return; + } + + if self.options.coarse_tessellation_culling + && !self + .clip_rect + .intersects(Rect::from_two_pos(points[0], points[1]).expand(stroke.width)) + { + return; + } + + if self.options.round_line_segments_to_pixels { + let feathering = self.feathering; + let pixels_per_point = self.pixels_per_point; + + let quarter_pixel = 0.25 * feathering; // Used to avoid fence post problem. + + let [a, b] = &mut points; + if a.x == b.x { + // Vertical line + let mut x = a.x; + stroke.round_center_to_pixel(self.pixels_per_point, &mut x); + a.x = x; + b.x = x; + + // Often the ends of the line are exactly on a pixel boundary, + // but we extend line segments with a cap that is a pixel wide… + // Solution: first shrink the line segment (on each end), + // then round to pixel center! + // We shrink by half-a-pixel n total (a quarter on each end), + // so that on average we avoid the fence-post-problem after rounding. + if a.y < b.y { + a.y = (a.y + quarter_pixel).round_to_pixel_center(pixels_per_point); + b.y = (b.y - quarter_pixel).round_to_pixel_center(pixels_per_point); + } else { + a.y = (a.y - quarter_pixel).round_to_pixel_center(pixels_per_point); + b.y = (b.y + quarter_pixel).round_to_pixel_center(pixels_per_point); + } + } + if a.y == b.y { + // Horizontal line + let mut y = a.y; + stroke.round_center_to_pixel(self.pixels_per_point, &mut y); + a.y = y; + b.y = y; + + // See earlier comment for vertical lines + if a.x < b.x { + a.x = (a.x + quarter_pixel).round_to_pixel_center(pixels_per_point); + b.x = (b.x - quarter_pixel).round_to_pixel_center(pixels_per_point); + } else { + a.x = (a.x - quarter_pixel).round_to_pixel_center(pixels_per_point); + b.x = (b.x + quarter_pixel).round_to_pixel_center(pixels_per_point); + } + } + } + + self.scratchpad_path.clear(); + self.scratchpad_path.add_line_segment(points); + self.scratchpad_path + .stroke_open(self.feathering, &stroke.into(), out); + } + + #[deprecated = "Use `tessellate_line_segment` instead"] + pub fn tessellate_line( + &mut self, + points: [Pos2; 2], + stroke: impl Into, + out: &mut Mesh, + ) { + self.tessellate_line_segment(points, stroke, out); + } + + /// Tessellate a single [`PathShape`] into a [`Mesh`]. + /// + /// * `path_shape`: the path to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_path(&mut self, path_shape: &PathShape, out: &mut Mesh) { + if path_shape.points.len() < 2 { + return; + } + + if self.options.coarse_tessellation_culling + && !path_shape.visual_bounding_rect().intersects(self.clip_rect) + { + return; + } + + profiling::function_scope!(); + + let PathShape { + points, + closed, + fill, + stroke, + } = path_shape; + + self.scratchpad_path.clear(); + + if *closed { + self.scratchpad_path.add_line_loop(points); + + self.scratchpad_path + .fill_and_stroke(self.feathering, *fill, stroke, out); + } else { + debug_assert_eq!( + *fill, + Color32::TRANSPARENT, + "You asked to fill a path that is not closed. That makes no sense." + ); + + self.scratchpad_path.add_open_points(points); + + self.scratchpad_path + .stroke(self.feathering, PathType::Open, stroke, out); + } + } + + /// Tessellate a single [`Rect`] into a [`Mesh`]. + /// + /// * `rect`: the rectangle to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_rect(&mut self, rect_shape: &RectShape, out: &mut Mesh) { + if self.options.coarse_tessellation_culling + && !rect_shape.visual_bounding_rect().intersects(self.clip_rect) + { + return; + } + + let brush = rect_shape.brush.as_ref(); + let RectShape { + mut rect, + corner_radius, + mut fill, + mut stroke, + mut stroke_kind, + round_to_pixels, + mut blur_width, + brush: _, // brush is extracted on its own, because it is not Copy + angle, + } = *rect_shape; + + let mut corner_radius = CornerRadiusF32::from(corner_radius); + let round_to_pixels = round_to_pixels.unwrap_or(self.options.round_rects_to_pixels); + + if stroke.width == 0.0 { + stroke.color = Color32::TRANSPARENT; + } + + // It is common to (sometimes accidentally) create an infinitely sized rectangle. + // Make sure we can handle that: + rect.min = rect.min.at_least(pos2(-1e7, -1e7)); + rect.max = rect.max.at_most(pos2(1e7, 1e7)); + + if !stroke.is_empty() { + // Check if the stroke covers the whole rectangle + let rect_with_stroke = match stroke_kind { + StrokeKind::Inside => rect, + StrokeKind::Middle => rect.expand(stroke.width / 2.0), + StrokeKind::Outside => rect.expand(stroke.width), + }; + + if rect_with_stroke.size().min_elem() <= 2.0 * stroke.width + 0.5 * self.feathering { + // The stroke covers the fill. + // Change this to be a fill-only shape, using the stroke color as the new fill color. + rect = rect_with_stroke; + + // We blend so that if the stroke is semi-transparent, + // the fill still shines through. + fill = stroke.color; + + stroke = Stroke::NONE; + } + } + + if stroke.is_empty() && out.texture_id == TextureId::default() { + // Approximate thin rectangles with line segments. + // This is important so that thin rectangles look good. + if rect.width() <= 2.0 * self.feathering { + return self.tessellate_line_segment( + [rect.center_top(), rect.center_bottom()], + (rect.width(), fill), + out, + ); + } + if rect.height() <= 2.0 * self.feathering { + return self.tessellate_line_segment( + [rect.left_center(), rect.right_center()], + (rect.height(), fill), + out, + ); + } + } + + // Important: round to pixels BEFORE modifying/applying stroke_kind + if round_to_pixels { + // The rounding is aware of the stroke kind. + // It is designed to be clever in trying to divine the intentions of the user. + match stroke_kind { + StrokeKind::Inside => { + // The stroke is inside the rect, so the rect defines the _outside_ of the stroke. + // We round the outside of the stroke on a pixel boundary. + // This will make the outside of the stroke crisp. + // + // Will make each stroke asymmetric if not an even multiple of physical pixels, + // but the left stroke will always be the mirror image of the right stroke, + // and the top stroke will always be the mirror image of the bottom stroke. + // + // This is so that a user can tile rectangles with `StrokeKind::Inside`, + // and get no pixel overlap between them. + rect = rect.round_to_pixels(self.pixels_per_point); + } + StrokeKind::Middle => { + // On this path we optimize for crisp and symmetric strokes. + stroke.round_rect_to_pixel(self.pixels_per_point, &mut rect); + } + StrokeKind::Outside => { + // Put the inside of the stroke on a pixel boundary. + // Makes the inside of the stroke and the filled rect crisp, + // but the outside of the stroke may become feathered (blurry). + // + // Will make each stroke asymmetric if not an even multiple of physical pixels, + // but the left stroke will always be the mirror image of the right stroke, + // and the top stroke will always be the mirror image of the bottom stroke. + rect = rect.round_to_pixels(self.pixels_per_point); + } + } + } + + let old_feathering = self.feathering; + + if self.feathering < blur_width { + // We accomplish the blur by using a larger-than-normal feathering. + // Feathering is usually used to make the edges of a shape softer for anti-aliasing. + + // The tessellator can't handle blurring/feathering larger than the smallest side of the rect. + let eps = 0.1; // avoid numerical problems + blur_width = blur_width + .at_most(rect.size().min_elem() - eps - 2.0 * stroke.width) + .at_least(0.0); + + corner_radius += 0.5 * blur_width; + + self.feathering = self.feathering.max(blur_width); + } + + { + // Modify `rect` so that it represents the OUTER border + // We do this because `path::rounded_rectangle` uses the + // corner radius to pick the fidelity/resolution of the corner. + + let original_cr = corner_radius; + + match stroke_kind { + StrokeKind::Inside => {} + StrokeKind::Middle => { + rect = rect.expand(stroke.width / 2.0); + corner_radius += stroke.width / 2.0; + } + StrokeKind::Outside => { + rect = rect.expand(stroke.width); + corner_radius += stroke.width; + } + } + + stroke_kind = StrokeKind::Inside; + + // A small corner_radius is incompatible with a wide stroke, + // because the small bend will be extruded inwards and cross itself. + // There are two ways to solve this (wile maintaining constant stroke width): + // either we increase the corner_radius, or we set it to zero. + // We choose the former: if the user asks for _any_ corner_radius, they should get it. + + let min_inside_cr = 0.1; // Large enough to avoid numerical issues + let min_outside_cr = stroke.width + min_inside_cr; + + let extra_cr_tweak = 0.4; // Otherwise is doesn't _feels_ enough. + + if original_cr.nw == 0.0 { + corner_radius.nw = 0.0; + } else { + corner_radius.nw += extra_cr_tweak; + corner_radius.nw = corner_radius.nw.at_least(min_outside_cr); + } + if original_cr.ne == 0.0 { + corner_radius.ne = 0.0; + } else { + corner_radius.ne += extra_cr_tweak; + corner_radius.ne = corner_radius.ne.at_least(min_outside_cr); + } + if original_cr.sw == 0.0 { + corner_radius.sw = 0.0; + } else { + corner_radius.sw += extra_cr_tweak; + corner_radius.sw = corner_radius.sw.at_least(min_outside_cr); + } + if original_cr.se == 0.0 { + corner_radius.se = 0.0; + } else { + corner_radius.se += extra_cr_tweak; + corner_radius.se = corner_radius.se.at_least(min_outside_cr); + } + } + + let path = &mut self.scratchpad_path; + path.clear(); + path::rounded_rectangle(&mut self.scratchpad_points, rect, corner_radius); + + // Apply rotation if angle is non-zero + if angle != 0.0 { + let rot = emath::Rot2::from_angle(angle); + let center = rect.center(); + for point in &mut self.scratchpad_points { + *point = center + rot * (*point - center); + } + } + + path.add_line_loop(&self.scratchpad_points); + + let path_stroke = PathStroke::from(stroke).with_kind(stroke_kind); + + if let Some(brush) = brush { + // Textured fill + + let fill_rect = match stroke_kind { + StrokeKind::Inside => rect.shrink(stroke.width), + StrokeKind::Middle => rect.shrink(stroke.width / 2.0), + StrokeKind::Outside => rect, + }; + + if fill_rect.is_positive() { + let crate::Brush { + fill_texture_id, + uv, + } = **brush; + let uv_from_pos = |p: Pos2| { + pos2( + remap(p.x, rect.x_range(), uv.x_range()), + remap(p.y, rect.y_range(), uv.y_range()), + ) + }; + path.fill_with_uv(self.feathering, fill, fill_texture_id, uv_from_pos, out); + } + + if !stroke.is_empty() { + path.stroke_closed(self.feathering, &path_stroke, out); + } + } else { + // Stroke and maybe fill + path.fill_and_stroke(self.feathering, fill, &path_stroke, out); + } + + self.feathering = old_feathering; // restore + } + + /// Tessellate a single [`TextShape`] into a [`Mesh`]. + /// * `text_shape`: the text to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_text(&mut self, text_shape: &TextShape, out: &mut Mesh) { + let TextShape { + pos: galley_pos, + galley, + underline, + override_text_color, + fallback_color, + opacity_factor, + angle, + } = text_shape; + + if galley.is_empty() { + return; + } + + if *opacity_factor <= 0.0 { + return; + } + + if galley.pixels_per_point != self.pixels_per_point { + log::warn!( + "epaint: WARNING: pixels_per_point (dpi scale) have changed between text layout and tessellation. \ + You must recreate your text shapes if pixels_per_point changes." + ); + } + + out.vertices.reserve(galley.num_vertices); + out.indices.reserve(galley.num_indices); + + // The contents of the galley are already snapped to pixel coordinates, + // but we need to make sure the galley ends up on the start of a physical pixel: + let galley_pos = if self.options.round_text_to_pixels { + galley_pos.round_to_pixels(self.pixels_per_point) + } else { + *galley_pos + }; + + let uv_normalizer = vec2( + 1.0 / self.font_tex_size[0] as f32, + 1.0 / self.font_tex_size[1] as f32, + ); + + let rotator = Rot2::from_angle(*angle); + + for row in &galley.rows { + if row.visuals.mesh.is_empty() { + continue; + } + + let final_row_pos = galley_pos + rotator * row.pos.to_vec2(); + + let mut row_rect = row.visuals.mesh_bounds; + if *angle != 0.0 { + row_rect = row_rect.rotate_bb(rotator); + } + row_rect = row_rect.translate(final_row_pos.to_vec2()); + + if self.options.coarse_tessellation_culling && !self.clip_rect.intersects(row_rect) { + // culling individual lines of text is important, since a single `Shape::Text` + // can span hundreds of lines. + continue; + } + + let index_offset = out.vertices.len() as u32; + + out.indices.extend( + row.visuals + .mesh + .indices + .iter() + .map(|index| index + index_offset), + ); + + out.vertices.extend( + row.visuals + .mesh + .vertices + .iter() + .enumerate() + .map(|(i, vertex)| { + let Vertex { pos, uv, mut color } = *vertex; + + if let Some(override_text_color) = override_text_color { + // Only override the glyph color (not background color, strike-through color, etc) + if row.visuals.glyph_vertex_range.contains(&i) { + color = *override_text_color; + } + } else if color == Color32::PLACEHOLDER { + color = *fallback_color; + } + + if *opacity_factor < 1.0 { + color = color.gamma_multiply(*opacity_factor); + } + + debug_assert!(color != Color32::PLACEHOLDER, "A placeholder color made it to the tessellator. You forgot to set a fallback color."); + + let offset = if *angle == 0.0 { + pos.to_vec2() + } else { + rotator * pos.to_vec2() + }; + + Vertex { + pos: final_row_pos + offset, + uv: (uv.to_vec2() * uv_normalizer).to_pos2(), + color, + } + }), + ); + + if *underline != Stroke::NONE { + self.tessellate_line_segment( + [row_rect.left_bottom(), row_rect.right_bottom()], + *underline, + out, + ); + } + } + } + + /// Tessellate a single [`QuadraticBezierShape`] into a [`Mesh`]. + /// + /// * `quadratic_shape`: the shape to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_quadratic_bezier( + &mut self, + quadratic_shape: &QuadraticBezierShape, + out: &mut Mesh, + ) { + let options = &self.options; + let clip_rect = self.clip_rect; + + if options.coarse_tessellation_culling + && !quadratic_shape.visual_bounding_rect().intersects(clip_rect) + { + return; + } + + let points = quadratic_shape.flatten(Some(options.bezier_tolerance)); + + self.tessellate_bezier_complete( + &points, + quadratic_shape.fill, + quadratic_shape.closed, + &quadratic_shape.stroke, + out, + ); + } + + /// Tessellate a single [`CubicBezierShape`] into a [`Mesh`]. + /// + /// * `cubic_shape`: the shape to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_cubic_bezier(&mut self, cubic_shape: &CubicBezierShape, out: &mut Mesh) { + let options = &self.options; + let clip_rect = self.clip_rect; + if options.coarse_tessellation_culling + && !cubic_shape.visual_bounding_rect().intersects(clip_rect) + { + return; + } + + let points_vec = + cubic_shape.flatten_closed(Some(options.bezier_tolerance), Some(options.epsilon)); + + for points in points_vec { + self.tessellate_bezier_complete( + &points, + cubic_shape.fill, + cubic_shape.closed, + &cubic_shape.stroke, + out, + ); + } + } + + fn tessellate_bezier_complete( + &mut self, + points: &[Pos2], + fill: Color32, + closed: bool, + stroke: &PathStroke, + out: &mut Mesh, + ) { + if points.len() < 2 { + return; + } + + self.scratchpad_path.clear(); + if closed { + self.scratchpad_path.add_line_loop(points); + + self.scratchpad_path + .fill_and_stroke(self.feathering, fill, stroke, out); + } else { + debug_assert_eq!( + fill, + Color32::TRANSPARENT, + "You asked to fill a bezier path that is not closed. That makes no sense." + ); + + self.scratchpad_path.add_open_points(points); + + self.scratchpad_path + .stroke(self.feathering, PathType::Open, stroke, out); + } + } +} + +impl Tessellator { + /// Turns [`Shape`]:s into sets of triangles. + /// + /// The given shapes will tessellated in the same order as they are given. + /// They will be batched together by clip rectangle. + /// + /// * `pixels_per_point`: number of physical pixels to each logical point + /// * `options`: tessellation quality + /// * `shapes`: what to tessellate + /// * `font_tex_size`: size of the font texture. Required to normalize glyph uv rectangles when tessellating text. + /// * `prepared_discs`: What [`crate::TextureAtlas::prepared_discs`] returns. Can safely be set to an empty vec. + /// + /// The implementation uses a [`Tessellator`]. + /// + /// ## Returns + /// A list of clip rectangles with matching [`Mesh`]. + #[allow(clippy::allow_attributes, unused_mut)] + pub fn tessellate_shapes(&mut self, mut shapes: Vec) -> Vec { + profiling::function_scope!(); + + #[cfg(feature = "rayon")] + if self.options.parallel_tessellation { + self.parallel_tessellation_of_large_shapes(&mut shapes); + } + + let mut clipped_primitives: Vec = Vec::default(); + + { + profiling::scope!("tessellate"); + for clipped_shape in shapes { + self.tessellate_clipped_shape(clipped_shape, &mut clipped_primitives); + } + } + + if self.options.debug_paint_clip_rects { + clipped_primitives = self.add_clip_rects(clipped_primitives); + } + + if self.options.debug_ignore_clip_rects { + for clipped_primitive in &mut clipped_primitives { + clipped_primitive.clip_rect = Rect::EVERYTHING; + } + } + + clipped_primitives.retain(|p| { + p.clip_rect.is_positive() + && match &p.primitive { + Primitive::Mesh(mesh) => !mesh.is_empty(), + Primitive::Callback(_) => true, + } + }); + + for clipped_primitive in &clipped_primitives { + if let Primitive::Mesh(mesh) = &clipped_primitive.primitive { + debug_assert!(mesh.is_valid(), "Tessellator generated invalid Mesh"); + } + } + + clipped_primitives + } + + /// Find large shapes and throw them on the rayon thread pool, + /// then replace the original shape with their tessellated meshes. + #[cfg(feature = "rayon")] + fn parallel_tessellation_of_large_shapes(&self, shapes: &mut [ClippedShape]) { + profiling::function_scope!(); + + use rayon::prelude::*; + + // We only parallelize large/slow stuff, because each tessellation job + // will allocate a new Mesh, and so it creates a lot of extra memory fragmentation + // and allocations that is only worth it for large shapes. + fn should_parallelize(shape: &Shape) -> bool { + match shape { + Shape::Vec(shapes) => 4 < shapes.len() || shapes.iter().any(should_parallelize), + + Shape::Path(path_shape) => 32 < path_shape.points.len(), + + Shape::QuadraticBezier(_) | Shape::CubicBezier(_) | Shape::Ellipse(_) => true, + + Shape::Noop + | Shape::Text(_) + | Shape::Circle(_) + | Shape::Mesh(_) + | Shape::LineSegment { .. } + | Shape::Rect(_) + | Shape::Callback(_) => false, + } + } + + let tessellated: Vec<(usize, Mesh)> = shapes + .par_iter() + .enumerate() + .filter(|(_, clipped_shape)| should_parallelize(&clipped_shape.shape)) + .map(|(index, clipped_shape)| { + profiling::scope!("tessellate_big_shape"); + // TODO(emilk): reuse tessellator in a thread local + let mut tessellator = (*self).clone(); + let mut mesh = Mesh::default(); + tessellator.tessellate_shape(clipped_shape.shape.clone(), &mut mesh); + (index, mesh) + }) + .collect(); + + profiling::scope!("distribute results", tessellated.len().to_string()); + for (index, mesh) in tessellated { + shapes[index].shape = Shape::Mesh(mesh.into()); + } + } + + fn add_clip_rects( + &mut self, + clipped_primitives: Vec, + ) -> Vec { + self.clip_rect = Rect::EVERYTHING; + let stroke = Stroke::new(2.0, Color32::from_rgb(150, 255, 150)); + + clipped_primitives + .into_iter() + .flat_map(|clipped_primitive| { + let mut clip_rect_mesh = Mesh::default(); + self.tessellate_shape( + Shape::rect_stroke( + clipped_primitive.clip_rect, + 0.0, + stroke, + StrokeKind::Outside, + ), + &mut clip_rect_mesh, + ); + + [ + clipped_primitive, + ClippedPrimitive { + clip_rect: Rect::EVERYTHING, // whatever + primitive: Primitive::Mesh(clip_rect_mesh), + }, + ] + }) + .collect() + } +} + +#[test] +fn test_tessellator() { + use crate::*; + + let mut shapes = Vec::with_capacity(2); + + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let mut mesh = Mesh::with_texture(TextureId::Managed(1)); + mesh.add_rect_with_uv(rect, uv, Color32::WHITE); + shapes.push(Shape::mesh(mesh)); + + let mut mesh = Mesh::with_texture(TextureId::Managed(2)); + mesh.add_rect_with_uv(rect, uv, Color32::WHITE); + shapes.push(Shape::mesh(mesh)); + + let shape = Shape::Vec(shapes); + let clipped_shapes = vec![ClippedShape { + clip_rect: rect, + shape, + }]; + + let font_tex_size = [1024, 1024]; // unused + let prepared_discs = vec![]; // unused + + let primitives = Tessellator::new(1.0, Default::default(), font_tex_size, prepared_discs) + .tessellate_shapes(clipped_shapes); + + assert_eq!(primitives.len(), 2); +} + +#[test] +fn path_bounding_box() { + use crate::*; + + for i in 1..=100 { + let width = i as f32; + + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(10.0, 10.0)); + let expected_rect = rect.expand((width / 2.0) + 1.5); + + let mut mesh = Mesh::default(); + + let mut path = Path::default(); + path.add_open_points(&[ + pos2(0.0, 0.0), + pos2(2.0, 0.0), + pos2(5.0, 5.0), + pos2(0.0, 5.0), + pos2(0.0, 7.0), + pos2(10.0, 10.0), + ]); + + path.stroke( + 1.5, + PathType::Closed, + &PathStroke::new_uv(width, move |r, p| { + assert_eq!(r, expected_rect); + // see https://github.com/emilk/egui/pull/4353#discussion_r1573879940 for why .contains() isn't used here. + // TL;DR rounding errors. + assert!( + r.distance_to_pos(p) <= 0.55, + "passed rect {r:?} didn't contain point {p:?} (distance: {})", + r.distance_to_pos(p) + ); + assert!( + expected_rect.distance_to_pos(p) <= 0.55, + "expected rect {expected_rect:?} didn't contain point {p:?}" + ); + Color32::WHITE + }), + &mut mesh, + ); + } +} diff --git a/vendor/epaint/src/text/cursor.rs b/vendor/epaint/src/text/cursor.rs new file mode 100644 index 0000000..a436ca1 --- /dev/null +++ b/vendor/epaint/src/text/cursor.rs @@ -0,0 +1,87 @@ +//! Different types of text cursors, i.e. ways to point into a [`super::Galley`]. + +/// Character cursor. +/// +/// The default cursor is zero. +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CCursor { + /// Character offset (NOT byte offset!). + pub index: usize, + + /// If this cursors sits right at the border of a wrapped row break (NOT paragraph break) + /// do we prefer the next row? + /// This is *almost* always what you want, *except* for when + /// explicitly clicking the end of a row or pressing the end key. + pub prefer_next_row: bool, +} + +impl CCursor { + #[inline] + pub fn new(index: usize) -> Self { + Self { + index, + prefer_next_row: false, + } + } +} + +/// Two `CCursor`s are considered equal if they refer to the same character boundary, +/// even if one prefers the start of the next row. +impl PartialEq for CCursor { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.index == other.index + } +} + +impl std::ops::Add for CCursor { + type Output = Self; + + fn add(self, rhs: usize) -> Self::Output { + Self { + index: self.index.saturating_add(rhs), + prefer_next_row: self.prefer_next_row, + } + } +} + +impl std::ops::Sub for CCursor { + type Output = Self; + + fn sub(self, rhs: usize) -> Self::Output { + Self { + index: self.index.saturating_sub(rhs), + prefer_next_row: self.prefer_next_row, + } + } +} + +impl std::ops::AddAssign for CCursor { + fn add_assign(&mut self, rhs: usize) { + self.index = self.index.saturating_add(rhs); + } +} + +impl std::ops::SubAssign for CCursor { + fn sub_assign(&mut self, rhs: usize) { + self.index = self.index.saturating_sub(rhs); + } +} + +/// Row/column cursor. +/// +/// This refers to rows and columns in layout terms--text wrapping creates multiple rows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct LayoutCursor { + /// 0 is first row, and so on. + /// Note that a single paragraph can span multiple rows. + /// (a paragraph is text separated by `\n`). + pub row: usize, + + /// Character based (NOT bytes). + /// It is fine if this points to something beyond the end of the current row. + /// When moving up/down it may again be within the next row. + pub column: usize, +} diff --git a/vendor/epaint/src/text/font.rs b/vendor/epaint/src/text/font.rs new file mode 100644 index 0000000..8e18656 --- /dev/null +++ b/vendor/epaint/src/text/font.rs @@ -0,0 +1,876 @@ +#![expect(clippy::mem_forget)] + +use emath::{GuiRounding as _, OrderedFloat, Vec2, vec2}; +use self_cell::self_cell; +use skrifa::{ + MetadataProvider as _, + raw::{TableProvider as _, tables::kern::SubtableKind}, +}; +use std::collections::BTreeMap; +use vello_cpu::{color, kurbo}; + +#[cfg(target_os = "windows")] +use crate::text::windows_directwrite::{DirectWriteFont, GlyphBitmap}; +use crate::{ + TextOptions, TextureAtlas, + text::{ + FontTweak, VariationCoords, + fonts::{Blob, CachedFamily, FontFaceKey}, + }, +}; + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct UvRect { + /// X/Y offset for nice rendering (unit: points). + pub offset: Vec2, + + /// Screen size (in points) of this glyph. + /// Note that the height is different from the font height. + pub size: Vec2, + + /// Top left corner UV in texture. + pub min: [u16; 2], + + /// Bottom right corner (exclusive). + pub max: [u16; 2], +} + +impl UvRect { + pub fn is_nothing(&self) -> bool { + self.min == self.max + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlyphInfo { + /// Used for pair-kerning. + /// + /// Doesn't need to be unique. + /// + /// Is `None` for a special "invisible" glyph. + pub(crate) id: Option, + + /// In [`skrifa`]s "unscaled" coordinate system. + pub advance_width_unscaled: OrderedFloat, +} + +impl GlyphInfo { + /// A valid, but invisible, glyph of zero-width. + pub const INVISIBLE: Self = Self { + id: None, + advance_width_unscaled: OrderedFloat(0.0), + }; +} + +// Subpixel binning, taken from cosmic-text: +// https://github.com/pop-os/cosmic-text/blob/974ddaed96b334f560b606ebe5d2ca2d2f9f23ef/src/glyph_cache.rs + +/// Bin for subpixel positioning of glyphs. +/// +/// For accurate glyph positioning, we want to render each glyph at a subpixel coordinate. However, we also want to +/// cache each glyph's bitmap. As a compromise, we bin each subpixel offset into one of four fractional values. This +/// means one glyph can have up to four subpixel-positioned bitmaps in the cache. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub(super) enum SubpixelBin { + #[default] + Zero, + One, + Two, + Three, +} + +impl SubpixelBin { + /// Bin the given position and return the new integral coordinate. + fn new(pos: f32) -> (i32, Self) { + let trunc = pos as i32; + let fract = pos - trunc as f32; + + #[expect(clippy::collapsible_else_if)] + if pos.is_sign_negative() { + if fract > -0.125 { + (trunc, Self::Zero) + } else if fract > -0.375 { + (trunc - 1, Self::Three) + } else if fract > -0.625 { + (trunc - 1, Self::Two) + } else if fract > -0.875 { + (trunc - 1, Self::One) + } else { + (trunc - 1, Self::Zero) + } + } else { + if fract < 0.125 { + (trunc, Self::Zero) + } else if fract < 0.375 { + (trunc, Self::One) + } else if fract < 0.625 { + (trunc, Self::Two) + } else if fract < 0.875 { + (trunc, Self::Three) + } else { + (trunc + 1, Self::Zero) + } + } + } + + pub fn as_float(&self) -> f32 { + match self { + Self::Zero => 0.0, + Self::One => 0.25, + Self::Two => 0.5, + Self::Three => 0.75, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub struct GlyphAllocation { + /// Used for pair-kerning. + /// + /// Doesn't need to be unique. + /// Use [`skrifa::GlyphId::NOTDEF`] if you just want to have an id, and don't care. + pub(crate) id: skrifa::GlyphId, + + /// Unit: screen pixels. + pub advance_width_px: f32, + + /// UV rectangle for drawing. + pub uv_rect: UvRect, +} + +#[derive(Hash, PartialEq, Eq)] +struct GlyphCacheKey(u64); + +impl nohash_hasher::IsEnabled for GlyphCacheKey {} + +impl GlyphCacheKey { + fn new(glyph_id: skrifa::GlyphId, metrics: &StyledMetrics, bin: SubpixelBin) -> Self { + let StyledMetrics { + pixels_per_point, + px_scale_factor, + .. + } = *metrics; + debug_assert!( + 0.0 < pixels_per_point && pixels_per_point.is_finite(), + "Bad pixels_per_point {pixels_per_point}" + ); + debug_assert!( + 0.0 < px_scale_factor && px_scale_factor.is_finite(), + "Bad px_scale_factor: {px_scale_factor}" + ); + Self(crate::util::hash(( + glyph_id, + pixels_per_point.to_bits(), + px_scale_factor.to_bits(), + bin, + ))) + } +} + +// ---------------------------------------------------------------------------- + +struct DependentFontData<'a> { + skrifa: skrifa::FontRef<'a>, + charmap: skrifa::charmap::Charmap<'a>, + outline_glyphs: skrifa::outline::OutlineGlyphCollection<'a>, + metrics: skrifa::metrics::Metrics, + glyph_metrics: skrifa::metrics::GlyphMetrics<'a>, + hinting_instance: Option, +} + +self_cell! { + struct FontCell { + owner: Blob, + + #[covariant] + dependent: DependentFontData, + } +} + +impl FontCell { + fn px_scale_factor(&self, scale: f32) -> f32 { + let units_per_em = self.borrow_dependent().metrics.units_per_em as f32; + scale / units_per_em + } + + fn allocate_glyph_uncached( + &mut self, + atlas: &mut TextureAtlas, + metrics: &StyledMetrics, + glyph_info: &GlyphInfo, + bin: SubpixelBin, + location: skrifa::instance::LocationRef<'_>, + ) -> Option { + let glyph_id = glyph_info.id?; + + debug_assert!( + glyph_id != skrifa::GlyphId::NOTDEF, + "Can't allocate glyph for id 0" + ); + + let mut path = kurbo::BezPath::new(); + let mut pen = VelloPen { + path: &mut path, + x_offset: bin.as_float() as f64, + }; + + self.with_dependent_mut(|_, font_data| { + let outline = font_data.outline_glyphs.get(glyph_id)?; + + if let Some(hinting_instance) = &mut font_data.hinting_instance { + let size = skrifa::instance::Size::new(metrics.scale); + if hinting_instance.size() != size { + hinting_instance + .reconfigure( + &font_data.outline_glyphs, + size, + location, + skrifa::outline::Target::Smooth { + mode: skrifa::outline::SmoothMode::Normal, + symmetric_rendering: true, + preserve_linear_metrics: true, + }, + ) + .ok()?; + } + let draw_settings = skrifa::outline::DrawSettings::hinted(hinting_instance, false); + outline.draw(draw_settings, &mut pen).ok()?; + } else { + let draw_settings = skrifa::outline::DrawSettings::unhinted( + skrifa::instance::Size::new(metrics.scale), + location, + ); + outline.draw(draw_settings, &mut pen).ok()?; + } + + Some(()) + })?; + + let bounds = path.control_box().expand(); + let width = bounds.width() as u16; + let height = bounds.height() as u16; + + let mut ctx = vello_cpu::RenderContext::new(width, height); + ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0))); + ctx.set_paint(color::OpaqueColor::::WHITE); + ctx.fill_path(&path); + let mut dest = vello_cpu::Pixmap::new(width, height); + ctx.render_to_pixmap(&mut dest); + let uv_rect = if width == 0 || height == 0 { + UvRect::default() + } else { + let glyph_pos = { + let alpha_from_coverage = atlas.options().alpha_from_coverage; + let (glyph_pos, image) = atlas.allocate((width as usize, height as usize)); + let pixels = dest.data_as_u8_slice(); + for y in 0..height as usize { + for x in 0..width as usize { + image[(x + glyph_pos.0, y + glyph_pos.1)] = alpha_from_coverage + .color_from_coverage( + pixels[((y * width as usize) + x) * 4 + 3] as f32 / 255.0, + ); + } + } + glyph_pos + }; + let offset_in_pixels = vec2(bounds.x0 as f32, bounds.y0 as f32); + let offset = + offset_in_pixels / metrics.pixels_per_point + metrics.y_offset_in_points * Vec2::Y; + UvRect { + offset, + size: vec2(width as f32, height as f32) / metrics.pixels_per_point, + min: [glyph_pos.0 as u16, glyph_pos.1 as u16], + max: [ + (glyph_pos.0 + width as usize) as u16, + (glyph_pos.1 + height as usize) as u16, + ], + } + }; + + Some(GlyphAllocation { + id: glyph_id, + advance_width_px: glyph_info.advance_width_unscaled.0 * metrics.px_scale_factor, + uv_rect, + }) + } +} + +struct VelloPen<'a> { + path: &'a mut kurbo::BezPath, + x_offset: f64, +} + +impl skrifa::outline::OutlinePen for VelloPen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + self.path.move_to((x as f64 + self.x_offset, -y as f64)); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to((x as f64 + self.x_offset, -y as f64)); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.path.quad_to( + (cx0 as f64 + self.x_offset, -cy0 as f64), + (x as f64 + self.x_offset, -y as f64), + ); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.path.curve_to( + (cx0 as f64 + self.x_offset, -cy0 as f64), + (cx1 as f64 + self.x_offset, -cy1 as f64), + (x as f64 + self.x_offset, -y as f64), + ); + } + + fn close(&mut self) { + self.path.close_path(); + } +} + +/// A specific font face. +/// The interface uses points as the unit for everything. +pub struct FontFace { + name: String, + font: FontCell, + tweak: FontTweak, + + #[cfg(target_os = "windows")] + directwrite: DirectWriteFont, + + glyph_info_cache: ahash::HashMap, + glyph_alloc_cache: ahash::HashMap, +} + +impl FontFace { + pub fn new( + options: TextOptions, + name: String, + font_data: Blob, + index: u32, + tweak: FontTweak, + ) -> Result> { + #[cfg(target_os = "windows")] + let directwrite = DirectWriteFont::new(std::sync::Arc::clone(&font_data), index); + let font = FontCell::try_new(font_data, |font_data| { + let skrifa_font = + skrifa::FontRef::from_index(AsRef::<[u8]>::as_ref(font_data.as_ref()), index)?; + + let charmap = skrifa_font.charmap(); + let glyphs = skrifa_font.outline_glyphs(); + + // Note: We use default location here during initialization because + // the actual weight will be applied via the stored location during rendering. + // The metrics won't be significantly different at this unscaled size. + let metrics = skrifa_font.metrics( + skrifa::instance::Size::unscaled(), + skrifa::instance::LocationRef::default(), + ); + let glyph_metrics = skrifa_font.glyph_metrics( + skrifa::instance::Size::unscaled(), + skrifa::instance::LocationRef::default(), + ); + + let hinting_enabled = tweak.hinting_override.unwrap_or(options.font_hinting); + let hinting_instance = hinting_enabled + .then(|| { + // It doesn't really matter what we put here for options. Since the size is `unscaled()`, we will + // always reconfigure this hinting instance with the real options when rendering for the first time. + skrifa::outline::HintingInstance::new( + &glyphs, + skrifa::instance::Size::unscaled(), + skrifa::instance::LocationRef::default(), + skrifa::outline::Target::default(), + ) + .ok() + }) + .flatten(); + + Ok::, Box>(DependentFontData { + skrifa: skrifa_font, + charmap, + outline_glyphs: glyphs, + metrics, + glyph_metrics, + hinting_instance, + }) + })?; + + Ok(Self { + name, + font, + tweak, + #[cfg(target_os = "windows")] + directwrite, + glyph_info_cache: Default::default(), + glyph_alloc_cache: Default::default(), + }) + } + + /// Code points that will always be replaced by the replacement character. + /// + /// See also [`invisible_char`]. + fn ignore_character(&self, chr: char) -> bool { + use crate::text::FontDefinitions; + + if !FontDefinitions::builtin_font_names().contains(&self.name.as_str()) { + return false; + } + + matches!( + chr, + // Strip out a religious symbol with secondary nefarious interpretation: + '\u{534d}' | '\u{5350}' | + + // Ignore ubuntu-specific stuff in `Ubuntu-Light.ttf`: + '\u{E0FF}' | '\u{EFFD}' | '\u{F0FF}' | '\u{F200}' + ) + } + + /// An un-ordered iterator over all supported characters. + fn characters(&self) -> impl Iterator + '_ { + self.font + .borrow_dependent() + .charmap + .mappings() + .filter_map(|(chr, _)| char::from_u32(chr).filter(|c| !self.ignore_character(*c))) + } + + /// `\n` will result in `None` + pub(super) fn glyph_info(&mut self, c: char) -> Option { + if let Some(glyph_info) = self.glyph_info_cache.get(&c) { + return Some(*glyph_info); + } + + if self.ignore_character(c) { + return None; // these will result in the replacement character when rendering + } + + if c == '\t' + && let Some(space) = self.glyph_info(' ') + { + let glyph_info = GlyphInfo { + advance_width_unscaled: (crate::text::TAB_SIZE as f32 + * space.advance_width_unscaled.0) + .into(), + ..space + }; + self.glyph_info_cache.insert(c, glyph_info); + return Some(glyph_info); + } + + if c == '\u{2009}' { + // Thin space, often used as thousands deliminator: 1 234 567 890 + // https://www.compart.com/en/unicode/U+2009 + // https://en.wikipedia.org/wiki/Thin_space + + if let Some(space) = self.glyph_info(' ') { + let em = self.font.borrow_dependent().metrics.units_per_em as f32; + let advance_width = f32::min(em / 6.0, space.advance_width_unscaled.0 * 0.5); // TODO(emilk): make configurable + let glyph_info = GlyphInfo { + advance_width_unscaled: advance_width.into(), + ..space + }; + self.glyph_info_cache.insert(c, glyph_info); + return Some(glyph_info); + } + } + + if invisible_char(c) { + let glyph_info = GlyphInfo::INVISIBLE; + self.glyph_info_cache.insert(c, glyph_info); + return Some(glyph_info); + } + + let font_data = self.font.borrow_dependent(); + + // Add new character: + let glyph_id = font_data + .charmap + .map(c) + .filter(|id| *id != skrifa::GlyphId::NOTDEF)?; + + let glyph_info = GlyphInfo { + id: Some(glyph_id), + advance_width_unscaled: font_data + .glyph_metrics + .advance_width(glyph_id) + .unwrap_or_default() + .into(), + }; + self.glyph_info_cache.insert(c, glyph_info); + Some(glyph_info) + } + + #[inline] + pub(super) fn pair_kerning_pixels( + &self, + metrics: &StyledMetrics, + last_glyph_id: skrifa::GlyphId, + glyph_id: skrifa::GlyphId, + ) -> f32 { + let skrifa_font = &self.font.borrow_dependent().skrifa; + let Ok(kern) = skrifa_font.kern() else { + return 0.0; + }; + kern.subtables() + .find_map(|st| match st.ok()?.kind().ok()? { + SubtableKind::Format0(table_ref) => table_ref.kerning(last_glyph_id, glyph_id), + SubtableKind::Format1(_) => None, + SubtableKind::Format2(subtable2) => subtable2.kerning(last_glyph_id, glyph_id), + SubtableKind::Format3(table_ref) => table_ref.kerning(last_glyph_id, glyph_id), + }) + .unwrap_or_default() as f32 + * metrics.px_scale_factor + } + + #[inline] + pub fn pair_kerning( + &self, + metrics: &StyledMetrics, + last_glyph_id: skrifa::GlyphId, + glyph_id: skrifa::GlyphId, + ) -> f32 { + self.pair_kerning_pixels(metrics, last_glyph_id, glyph_id) / metrics.pixels_per_point + } + + #[inline(always)] + pub fn styled_metrics( + &self, + pixels_per_point: f32, + font_size: f32, + coords: &VariationCoords, + ) -> StyledMetrics { + let pt_scale_factor = self.font.px_scale_factor(font_size * self.tweak.scale); + let font_data = self.font.borrow_dependent(); + let ascent = (font_data.metrics.ascent * pt_scale_factor).round_ui(); + let descent = (font_data.metrics.descent * pt_scale_factor).round_ui(); + let line_gap = (font_data.metrics.leading * pt_scale_factor).round_ui(); + + let scale = font_size * self.tweak.scale * pixels_per_point; + let px_scale_factor = self.font.px_scale_factor(scale); + + let y_offset_in_points = ((font_size * self.tweak.scale * self.tweak.y_offset_factor) + + self.tweak.y_offset) + .round_ui(); + + let axes = font_data.skrifa.axes(); + // Override the default coordinates with ones specified via FontTweak, then the ones specified directly via the + // argument (probably from TextFormat). + let settings = self + .tweak + .coords + .as_ref() + .iter() + .chain(coords.as_ref().iter()); + let location = axes.location(settings); + + StyledMetrics { + pixels_per_point, + px_scale_factor, + scale, + y_offset_in_points, + ascent, + row_height: ascent - descent + line_gap, + location, + } + } + + pub fn allocate_glyph( + &mut self, + atlas: &mut TextureAtlas, + metrics: &StyledMetrics, + glyph_info: GlyphInfo, + chr: char, + h_pos: f32, + ) -> (GlyphAllocation, i32) { + let advance_width_px = glyph_info.advance_width_unscaled.0 * metrics.px_scale_factor; + + let Some(glyph_id) = glyph_info.id else { + // Invisible. + return (GlyphAllocation::default(), h_pos as i32); + }; + + // CJK scripts contain a lot of characters and could hog the glyph atlas if we stored 4 subpixel offsets per + // glyph. + let (h_pos_round, bin) = if is_cjk(chr) { + (h_pos.round() as i32, SubpixelBin::Zero) + } else { + SubpixelBin::new(h_pos) + }; + + let entry = match self + .glyph_alloc_cache + .entry(GlyphCacheKey::new(glyph_id, metrics, bin)) + { + std::collections::hash_map::Entry::Occupied(glyph_alloc) => { + let mut glyph_alloc = *glyph_alloc.get(); + glyph_alloc.advance_width_px = advance_width_px; // Hack to get `\t` and thin space to work, since they use the same glyph id as ` ` (space). + return (glyph_alloc, h_pos_round); + } + std::collections::hash_map::Entry::Vacant(entry) => entry, + }; + + #[cfg(target_os = "windows")] + let allocation = self + .directwrite + .rasterize(glyph_id, metrics.scale, bin.as_float()) + .map(|bitmap| { + glyph_allocation_from_directwrite( + atlas, + metrics, + glyph_id, + advance_width_px, + bitmap, + ) + }) + .or_else(|| { + self.font.allocate_glyph_uncached( + atlas, + metrics, + &glyph_info, + bin, + (&metrics.location).into(), + ) + }) + .unwrap_or_default(); + #[cfg(not(target_os = "windows"))] + let allocation = self + .font + .allocate_glyph_uncached(atlas, metrics, &glyph_info, bin, (&metrics.location).into()) + .unwrap_or_default(); + + entry.insert(allocation); + (allocation, h_pos_round) + } +} + +#[cfg(target_os = "windows")] +fn glyph_allocation_from_directwrite( + atlas: &mut TextureAtlas, + metrics: &StyledMetrics, + glyph_id: skrifa::GlyphId, + advance_width_px: f32, + bitmap: GlyphBitmap, +) -> GlyphAllocation { + let GlyphBitmap { + left, + top, + width, + height, + coverage, + } = bitmap; + let uv_rect = if width == 0 || height == 0 { + UvRect::default() + } else { + let alpha_from_coverage = atlas.options().alpha_from_coverage; + let (glyph_pos, image) = atlas.allocate((width, height)); + for y in 0..height { + for x in 0..width { + image[(x + glyph_pos.0, y + glyph_pos.1)] = + alpha_from_coverage.color_from_coverage(coverage[y * width + x] as f32 / 255.0); + } + } + UvRect { + offset: vec2(left as f32, top as f32) / metrics.pixels_per_point + + metrics.y_offset_in_points * Vec2::Y, + size: vec2(width as f32, height as f32) / metrics.pixels_per_point, + min: [glyph_pos.0 as u16, glyph_pos.1 as u16], + max: [(glyph_pos.0 + width) as u16, (glyph_pos.1 + height) as u16], + } + }; + + GlyphAllocation { + id: glyph_id, + advance_width_px, + uv_rect, + } +} + +// TODO(emilk): rename? +/// Wrapper over multiple [`FontFace`] (e.g. a primary + fallbacks for emojis) +pub struct Font<'a> { + pub(super) fonts_by_id: &'a mut nohash_hasher::IntMap, + pub(super) cached_family: &'a mut CachedFamily, + pub(super) atlas: &'a mut TextureAtlas, +} + +impl Font<'_> { + pub fn preload_characters(&mut self, s: &str) { + for c in s.chars() { + self.glyph_info(c); + } + } + + /// All supported characters, and in which font they are available in. + pub fn characters(&mut self) -> &BTreeMap> { + self.cached_family.characters.get_or_insert_with(|| { + let mut characters: BTreeMap> = Default::default(); + for font_id in &self.cached_family.fonts { + let font = self.fonts_by_id.get(font_id).expect("Nonexistent font ID"); + for chr in font.characters() { + characters.entry(chr).or_default().push(font.name.clone()); + } + } + characters + }) + } + + pub fn styled_metrics( + &self, + pixels_per_point: f32, + font_size: f32, + coords: &VariationCoords, + ) -> StyledMetrics { + self.cached_family + .fonts + .first() + .and_then(|key| self.fonts_by_id.get(key)) + .map(|font_face| font_face.styled_metrics(pixels_per_point, font_size, coords)) + .unwrap_or_default() + } + + /// Width of this character in points. + pub fn glyph_width(&mut self, c: char, font_size: f32) -> f32 { + let (key, glyph_info) = self.glyph_info(c); + if let Some(font) = &self.fonts_by_id.get(&key) { + glyph_info.advance_width_unscaled.0 * font.font.px_scale_factor(font_size) + } else { + 0.0 + } + } + + /// Can we display this glyph? + pub fn has_glyph(&mut self, c: char) -> bool { + self.glyph_info(c) != self.cached_family.replacement_glyph // TODO(emilk): this is a false negative if the user asks about the replacement character itself 🤦‍♂️ + } + + /// Can we display all the glyphs in this text? + pub fn has_glyphs(&mut self, s: &str) -> bool { + s.chars().all(|c| self.has_glyph(c)) + } + + /// `\n` will (intentionally) show up as the replacement character. + pub(crate) fn glyph_info(&mut self, c: char) -> (FontFaceKey, GlyphInfo) { + if let Some(font_index_glyph_info) = self.cached_family.glyph_info_cache.get(&c) { + return *font_index_glyph_info; + } + + let font_index_glyph_info = self + .cached_family + .glyph_info_no_cache_or_fallback(c, self.fonts_by_id); + let font_index_glyph_info = + font_index_glyph_info.unwrap_or(self.cached_family.replacement_glyph); + self.cached_family + .glyph_info_cache + .insert(c, font_index_glyph_info); + font_index_glyph_info + } +} + +/// Metrics for a font at a specific screen-space scale. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct StyledMetrics { + /// The DPI part of the screen-space scale. + pub pixels_per_point: f32, + + /// Scale factor, relative to the font's units per em (so, probably much less than 1). + /// + /// Translates "unscaled" units to physical (screen) pixels. + pub px_scale_factor: f32, + + /// Absolute scale in screen pixels, for skrifa. + pub scale: f32, + + /// Vertical offset, in UI points (not screen-space). + pub y_offset_in_points: f32, + + /// This is the distance from the top to the baseline. + /// + /// Unit: points. + pub ascent: f32, + + /// Height of one row of text in points. + /// + /// Returns a value rounded to [`emath::GUI_ROUNDING`]. + pub row_height: f32, + + /// Resolved variation coordinates. + pub location: skrifa::instance::Location, +} + +/// Code points that will always be invisible (zero width). +/// +/// See also [`FontFace::ignore_character`]. +#[inline] +fn invisible_char(c: char) -> bool { + if c == '\r' { + // A character most vile and pernicious. Don't display it. + return true; + } + + // See https://github.com/emilk/egui/issues/336 + + // From https://www.fileformat.info/info/unicode/category/Cf/list.htm + + // TODO(emilk): heed bidi characters + + matches!( + c, + '\u{200B}' // ZERO WIDTH SPACE + | '\u{200C}' // ZERO WIDTH NON-JOINER + | '\u{200D}' // ZERO WIDTH JOINER + | '\u{200E}' // LEFT-TO-RIGHT MARK + | '\u{200F}' // RIGHT-TO-LEFT MARK + | '\u{202A}' // LEFT-TO-RIGHT EMBEDDING + | '\u{202B}' // RIGHT-TO-LEFT EMBEDDING + | '\u{202C}' // POP DIRECTIONAL FORMATTING + | '\u{202D}' // LEFT-TO-RIGHT OVERRIDE + | '\u{202E}' // RIGHT-TO-LEFT OVERRIDE + | '\u{2060}' // WORD JOINER + | '\u{2061}' // FUNCTION APPLICATION + | '\u{2062}' // INVISIBLE TIMES + | '\u{2063}' // INVISIBLE SEPARATOR + | '\u{2064}' // INVISIBLE PLUS + | '\u{2066}' // LEFT-TO-RIGHT ISOLATE + | '\u{2067}' // RIGHT-TO-LEFT ISOLATE + | '\u{2068}' // FIRST STRONG ISOLATE + | '\u{2069}' // POP DIRECTIONAL ISOLATE + | '\u{206A}' // INHIBIT SYMMETRIC SWAPPING + | '\u{206B}' // ACTIVATE SYMMETRIC SWAPPING + | '\u{206C}' // INHIBIT ARABIC FORM SHAPING + | '\u{206D}' // ACTIVATE ARABIC FORM SHAPING + | '\u{206E}' // NATIONAL DIGIT SHAPES + | '\u{206F}' // NOMINAL DIGIT SHAPES + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE + ) +} + +#[inline] +pub(super) fn is_cjk_ideograph(c: char) -> bool { + ('\u{4E00}' <= c && c <= '\u{9FFF}') + || ('\u{3400}' <= c && c <= '\u{4DBF}') + || ('\u{2B740}' <= c && c <= '\u{2B81F}') +} + +#[inline] +pub(super) fn is_kana(c: char) -> bool { + ('\u{3040}' <= c && c <= '\u{309F}') // Hiragana block + || ('\u{30A0}' <= c && c <= '\u{30FF}') // Katakana block +} + +#[inline] +pub(super) fn is_cjk(c: char) -> bool { + // TODO(bigfarts): Add support for Korean Hangul. + is_cjk_ideograph(c) || is_kana(c) +} + +#[inline] +pub(super) fn is_cjk_break_allowed(c: char) -> bool { + // See: https://en.wikipedia.org/wiki/Line_breaking_rules_in_East_Asian_languages#Characters_not_permitted_on_the_start_of_a_line. + !")]}〕〉》」』】〙〗〟'\"⦆»ヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻‐゠–〜?!‼⁇⁈⁉・、:;,。.".contains(c) +} diff --git a/vendor/epaint/src/text/fonts.rs b/vendor/epaint/src/text/fonts.rs new file mode 100644 index 0000000..5099e00 --- /dev/null +++ b/vendor/epaint/src/text/fonts.rs @@ -0,0 +1,1311 @@ +use std::{ + borrow::Cow, + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use crate::{ + TextureAtlas, + text::{ + Galley, LayoutJob, LayoutSection, TextOptions, VariationCoords, + font::{Font, FontFace, GlyphInfo}, + }, +}; +use emath::{NumExt as _, OrderedFloat}; + +#[cfg(feature = "default_fonts")] +use epaint_default_fonts::{EMOJI_ICON, HACK_REGULAR, NOTO_EMOJI_REGULAR, UBUNTU_LIGHT}; + +// ---------------------------------------------------------------------------- + +/// How to select a sized font. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontId { + /// Height in points. + pub size: f32, + + /// What font family to use. + pub family: FontFamily, + // TODO(emilk): weight (bold), italics, … +} + +impl Default for FontId { + #[inline] + fn default() -> Self { + Self { + size: 14.0, + family: FontFamily::Proportional, + } + } +} + +impl FontId { + #[inline] + pub const fn new(size: f32, family: FontFamily) -> Self { + Self { size, family } + } + + #[inline] + pub const fn proportional(size: f32) -> Self { + Self::new(size, FontFamily::Proportional) + } + + #[inline] + pub const fn monospace(size: f32) -> Self { + Self::new(size, FontFamily::Monospace) + } +} + +impl std::hash::Hash for FontId { + #[inline(always)] + fn hash(&self, state: &mut H) { + let Self { size, family } = self; + emath::OrderedFloat(*size).hash(state); + family.hash(state); + } +} + +// ---------------------------------------------------------------------------- + +/// Font of unknown size. +/// +/// Which style of font: [`Monospace`][`FontFamily::Monospace`], [`Proportional`][`FontFamily::Proportional`], +/// or by user-chosen name. +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum FontFamily { + /// A font where some characters are wider than other (e.g. 'w' is wider than 'i'). + /// + /// Proportional fonts are easier to read and should be the preferred choice in most situations. + #[default] + Proportional, + + /// A font where each character is the same width (`w` is the same width as `i`). + /// + /// Useful for code snippets, or when you need to align numbers or text. + Monospace, + + /// One of the names in [`FontDefinitions::families`]. + /// + /// ``` + /// # use epaint::FontFamily; + /// // User-chosen names: + /// FontFamily::Name("arial".into()); + /// FontFamily::Name("serif".into()); + /// ``` + Name(Arc), +} + +impl std::fmt::Display for FontFamily { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Monospace => "Monospace".fmt(f), + Self::Proportional => "Proportional".fmt(f), + Self::Name(name) => (*name).fmt(f), + } + } +} + +// ---------------------------------------------------------------------------- + +/// A `.ttf` or `.otf` file and a font face index. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontData { + /// The content of a `.ttf` or `.otf` file. + pub font: Cow<'static, [u8]>, + + /// Which font face in the file to use. + /// When in doubt, use `0`. + pub index: u32, + + /// Extra scale and vertical tweak to apply to all text of this font. + pub tweak: FontTweak, +} + +impl FontData { + pub fn from_static(font: &'static [u8]) -> Self { + Self { + font: Cow::Borrowed(font), + index: 0, + tweak: Default::default(), + } + } + + pub fn from_owned(font: Vec) -> Self { + Self { + font: Cow::Owned(font), + index: 0, + tweak: Default::default(), + } + } + + pub fn tweak(self, tweak: FontTweak) -> Self { + Self { tweak, ..self } + } +} + +impl AsRef<[u8]> for FontData { + fn as_ref(&self) -> &[u8] { + self.font.as_ref() + } +} + +// ---------------------------------------------------------------------------- + +/// Extra scale and vertical tweak to apply to all text of a certain font. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontTweak { + /// Scale the font's glyphs by this much. + /// this is only a visual effect and does not affect the text layout. + /// + /// Default: `1.0` (no scaling). + pub scale: f32, + + /// Shift font's glyphs downwards by this fraction of the font size (in points). + /// this is only a visual effect and does not affect the text layout. + /// + /// Affects larger font sizes more. + /// + /// A positive value shifts the text downwards. + /// A negative value shifts it upwards. + /// + /// Example value: `-0.2`. + pub y_offset_factor: f32, + + /// Shift font's glyphs downwards by this amount of logical points. + /// this is only a visual effect and does not affect the text layout. + /// + /// Affects all font sizes equally. + /// + /// Example value: `2.0`. + pub y_offset: f32, + + /// Override the global font hinting setting for this specific font. + /// + /// `None` means use the global setting. + pub hinting_override: Option, + + /// Override the font's default variation coordinates. + pub coords: VariationCoords, +} + +impl Default for FontTweak { + fn default() -> Self { + Self { + scale: 1.0, + y_offset_factor: 0.0, + y_offset: 0.0, + hinting_override: None, + coords: VariationCoords::default(), + } + } +} + +// ---------------------------------------------------------------------------- + +pub type Blob = Arc + Send + Sync>; + +fn blob_from_font_data(data: &FontData) -> Blob { + match data.clone().font { + Cow::Borrowed(bytes) => Arc::new(bytes) as Blob, + Cow::Owned(bytes) => Arc::new(bytes) as Blob, + } +} + +/// Describes the font data and the sizes to use. +/// +/// Often you would start with [`FontDefinitions::default()`] and then add/change the contents. +/// +/// This is how you install your own custom fonts: +/// ``` +/// # use {epaint::text::{FontDefinitions, FontFamily, FontData}}; +/// # struct FakeEguiCtx {}; +/// # impl FakeEguiCtx { fn set_fonts(&self, _: FontDefinitions) {} } +/// # let egui_ctx = FakeEguiCtx {}; +/// let mut fonts = FontDefinitions::default(); +/// +/// // Install my own font (maybe supporting non-latin characters): +/// fonts.font_data.insert("my_font".to_owned(), +/// std::sync::Arc::new( +/// // .ttf and .otf supported +/// FontData::from_static(include_bytes!("../../../epaint_default_fonts/fonts/Ubuntu-Light.ttf")) +/// ) +/// ); +/// +/// // Put my font first (highest priority): +/// fonts.families.get_mut(&FontFamily::Proportional).unwrap() +/// .insert(0, "my_font".to_owned()); +/// +/// // Put my font as last fallback for monospace: +/// fonts.families.get_mut(&FontFamily::Monospace).unwrap() +/// .push("my_font".to_owned()); +/// +/// egui_ctx.set_fonts(fonts); +/// ``` +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct FontDefinitions { + /// List of font names and their definitions. + /// + /// `epaint` has built-in-default for these, but you can override them if you like. + pub font_data: BTreeMap>, + + /// Which fonts (names) to use for each [`FontFamily`]. + /// + /// The list should be a list of keys into [`Self::font_data`]. + /// When looking for a character glyph `epaint` will start with + /// the first font and then move to the second, and so on. + /// So the first font is the primary, and then comes a list of fallbacks in order of priority. + pub families: BTreeMap>, +} + +#[derive(Debug, Clone)] +pub struct FontInsert { + /// Font name + pub name: String, + + /// A `.ttf` or `.otf` file and a font face index. + pub data: FontData, + + /// Sets the font family and priority + pub families: Vec, +} + +#[derive(Debug, Clone)] +pub struct InsertFontFamily { + /// Font family + pub family: FontFamily, + + /// Fallback or Primary font + pub priority: FontPriority, +} + +#[derive(Debug, Clone)] +pub enum FontPriority { + /// Prefer this font before all existing ones. + /// + /// If a desired glyph exists in this font, it will be used. + Highest, + + /// Use this font as a fallback, after all existing ones. + /// + /// This font will only be used if the glyph is not found in any of the previously installed fonts. + Lowest, +} + +impl FontInsert { + pub fn new(name: &str, data: FontData, families: Vec) -> Self { + Self { + name: name.to_owned(), + data, + families, + } + } +} + +impl Default for FontDefinitions { + /// Specifies the default fonts if the feature `default_fonts` is enabled, + /// otherwise this is the same as [`Self::empty`]. + #[cfg(not(feature = "default_fonts"))] + fn default() -> Self { + Self::empty() + } + + /// Specifies the default fonts if the feature `default_fonts` is enabled, + /// otherwise this is the same as [`Self::empty`]. + #[cfg(feature = "default_fonts")] + fn default() -> Self { + let mut font_data: BTreeMap> = BTreeMap::new(); + + let mut families = BTreeMap::new(); + + font_data.insert( + "Hack".to_owned(), + Arc::new(FontData::from_static(HACK_REGULAR)), + ); + + // Some good looking emojis. Use as first priority: + font_data.insert( + "NotoEmoji-Regular".to_owned(), + Arc::new(FontData::from_static(NOTO_EMOJI_REGULAR).tweak(FontTweak { + scale: 0.81, // Make smaller + ..Default::default() + })), + ); + + font_data.insert( + "Ubuntu-Light".to_owned(), + Arc::new(FontData::from_static(UBUNTU_LIGHT)), + ); + + // Bigger emojis, and more. : + font_data.insert( + "emoji-icon-font".to_owned(), + Arc::new(FontData::from_static(EMOJI_ICON).tweak(FontTweak { + scale: 0.90, // Make smaller + ..Default::default() + })), + ); + + families.insert( + FontFamily::Monospace, + vec![ + "Hack".to_owned(), + "Ubuntu-Light".to_owned(), // fallback for √ etc + "NotoEmoji-Regular".to_owned(), + "emoji-icon-font".to_owned(), + ], + ); + families.insert( + FontFamily::Proportional, + vec![ + "Ubuntu-Light".to_owned(), + "NotoEmoji-Regular".to_owned(), + "emoji-icon-font".to_owned(), + ], + ); + + Self { + font_data, + families, + } + } +} + +impl FontDefinitions { + /// No fonts. + pub fn empty() -> Self { + let mut families = BTreeMap::new(); + families.insert(FontFamily::Monospace, vec![]); + families.insert(FontFamily::Proportional, vec![]); + + Self { + font_data: Default::default(), + families, + } + } + + /// List of all the builtin font names used by `epaint`. + #[cfg(feature = "default_fonts")] + pub fn builtin_font_names() -> &'static [&'static str] { + &[ + "Ubuntu-Light", + "NotoEmoji-Regular", + "emoji-icon-font", + "Hack", + ] + } + + /// List of all the builtin font names used by `epaint`. + #[cfg(not(feature = "default_fonts"))] + pub fn builtin_font_names() -> &'static [&'static str] { + &[] + } +} + +/// Unique ID for looking up a single font face/file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct FontFaceKey(u64); + +impl FontFaceKey { + pub const INVALID: Self = Self(0); + + fn new() -> Self { + static KEY_COUNTER: AtomicU64 = AtomicU64::new(1); + Self(crate::util::hash( + KEY_COUNTER.fetch_add(1, Ordering::Relaxed), + )) + } +} + +// Safe, because we hash the value in the constructor. +impl nohash_hasher::IsEnabled for FontFaceKey {} + +/// Cached data for working with a font family (e.g. doing character lookups). +#[derive(Debug)] +pub(super) struct CachedFamily { + pub fonts: Vec, + + /// Lazily calculated. + pub characters: Option>>, + + pub replacement_glyph: (FontFaceKey, GlyphInfo), + + pub glyph_info_cache: ahash::HashMap, +} + +impl CachedFamily { + fn new( + fonts: Vec, + fonts_by_id: &mut nohash_hasher::IntMap, + ) -> Self { + if fonts.is_empty() { + return Self { + fonts, + characters: None, + replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE), + glyph_info_cache: Default::default(), + }; + } + + let mut slf = Self { + fonts, + characters: None, + replacement_glyph: (FontFaceKey::INVALID, GlyphInfo::INVISIBLE), + glyph_info_cache: Default::default(), + }; + + const PRIMARY_REPLACEMENT_CHAR: char = '◻'; // white medium square + const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback + + let replacement_glyph = slf + .glyph_info_no_cache_or_fallback(PRIMARY_REPLACEMENT_CHAR, fonts_by_id) + .or_else(|| slf.glyph_info_no_cache_or_fallback(FALLBACK_REPLACEMENT_CHAR, fonts_by_id)) + .unwrap_or_else(|| { + log::warn!( + "Failed to find replacement characters {PRIMARY_REPLACEMENT_CHAR:?} or {FALLBACK_REPLACEMENT_CHAR:?}. Will use empty glyph." + ); + (FontFaceKey::INVALID, GlyphInfo::INVISIBLE) + }); + slf.replacement_glyph = replacement_glyph; + + slf + } + + pub(crate) fn glyph_info_no_cache_or_fallback( + &mut self, + c: char, + fonts_by_id: &mut nohash_hasher::IntMap, + ) -> Option<(FontFaceKey, GlyphInfo)> { + for font_key in &self.fonts { + let font_face = fonts_by_id.get_mut(font_key).expect("Nonexistent font ID"); + if let Some(glyph_info) = font_face.glyph_info(c) { + self.glyph_info_cache.insert(c, (*font_key, glyph_info)); + return Some((*font_key, glyph_info)); + } + } + None + } +} + +// ---------------------------------------------------------------------------- + +/// The collection of fonts used by `epaint`. +/// +/// Required in order to paint text. Create one and reuse. Cheap to clone. +/// +/// Each [`Fonts`] comes with a font atlas textures that needs to be used when painting. +/// +/// If you are using `egui`, use `egui::Context::set_fonts` and `egui::Context::fonts`. +/// +/// You need to call [`Self::begin_pass`] and [`Self::font_image_delta`] once every frame. +pub struct Fonts { + pub fonts: FontsImpl, + galley_cache: GalleyCache, +} + +impl Fonts { + /// Create a new [`Fonts`] for text layout. + /// This call is expensive, so only create one [`Fonts`] and then reuse it. + pub fn new(options: TextOptions, definitions: FontDefinitions) -> Self { + Self { + fonts: FontsImpl::new(options, definitions), + galley_cache: Default::default(), + } + } + + /// Call at the start of each frame with the latest known [`TextOptions`]. + /// + /// Call after painting the previous frame, but before using [`Fonts`] for the new frame. + /// + /// This function will react to changes in [`TextOptions`], + /// as well as notice when the font atlas is getting full, and handle that. + pub fn begin_pass(&mut self, options: TextOptions) { + let text_options_changed = self.fonts.options() != &options; + let font_atlas_almost_full = self.fonts.atlas.fill_ratio() > 0.8; + let needs_recreate = text_options_changed || font_atlas_almost_full; + + if needs_recreate { + let definitions = self.fonts.definitions.clone(); + + *self = Self { + fonts: FontsImpl::new(options, definitions), + galley_cache: Default::default(), + }; + } + + self.galley_cache.flush_cache(); + } + + /// Call at the end of each frame (before painting) to get the change to the font texture since last call. + pub fn font_image_delta(&mut self) -> Option { + self.fonts.atlas.take_delta() + } + + #[inline] + pub fn options(&self) -> &TextOptions { + self.texture_atlas().options() + } + + #[inline] + pub fn definitions(&self) -> &FontDefinitions { + &self.fonts.definitions + } + + /// The font atlas. + /// Pass this to [`crate::Tessellator`]. + pub fn texture_atlas(&self) -> &TextureAtlas { + &self.fonts.atlas + } + + /// The full font atlas image. + #[inline] + pub fn image(&self) -> crate::ColorImage { + self.fonts.atlas.image().clone() + } + + /// Current size of the font image. + /// Pass this to [`crate::Tessellator`]. + pub fn font_image_size(&self) -> [usize; 2] { + self.fonts.atlas.size() + } + + /// Can we display this glyph? + pub fn has_glyph(&mut self, font_id: &FontId, c: char) -> bool { + self.fonts.font(&font_id.family).has_glyph(c) + } + + /// Can we display all the glyphs in this text? + pub fn has_glyphs(&mut self, font_id: &FontId, s: &str) -> bool { + self.fonts.font(&font_id.family).has_glyphs(s) + } + + pub fn num_galleys_in_cache(&self) -> usize { + self.galley_cache.num_galleys_in_cache() + } + + /// How full is the font atlas? + /// + /// This increases as new fonts and/or glyphs are used, + /// but can also decrease in a call to [`Self::begin_pass`]. + pub fn font_atlas_fill_ratio(&self) -> f32 { + self.fonts.atlas.fill_ratio() + } + + /// Returns a [`FontsView`] with the given `pixels_per_point` that can be used to do text layout. + pub fn with_pixels_per_point(&mut self, pixels_per_point: f32) -> FontsView<'_> { + FontsView { + fonts: &mut self.fonts, + galley_cache: &mut self.galley_cache, + pixels_per_point, + } + } +} + +// ---------------------------------------------------------------------------- + +/// The context's collection of fonts, with this context's `pixels_per_point`. This is what you use to do text layout. +pub struct FontsView<'a> { + pub fonts: &'a mut FontsImpl, + galley_cache: &'a mut GalleyCache, + pixels_per_point: f32, +} + +impl FontsView<'_> { + #[inline] + pub fn options(&self) -> &TextOptions { + self.fonts.options() + } + + #[inline] + pub fn definitions(&self) -> &FontDefinitions { + &self.fonts.definitions + } + + /// The full font atlas image. + #[inline] + pub fn image(&self) -> crate::ColorImage { + self.fonts.atlas.image().clone() + } + + /// Current size of the font image. + /// Pass this to [`crate::Tessellator`]. + pub fn font_image_size(&self) -> [usize; 2] { + self.fonts.atlas.size() + } + + /// Width of this character in points. + /// + /// If the font doesn't exist, this will return `0.0`. + pub fn glyph_width(&mut self, font_id: &FontId, c: char) -> f32 { + self.fonts + .font(&font_id.family) + .glyph_width(c, font_id.size) + } + + /// Can we display this glyph? + pub fn has_glyph(&mut self, font_id: &FontId, c: char) -> bool { + self.fonts.font(&font_id.family).has_glyph(c) + } + + /// Can we display all the glyphs in this text? + pub fn has_glyphs(&mut self, font_id: &FontId, s: &str) -> bool { + self.fonts.font(&font_id.family).has_glyphs(s) + } + + /// Height of one row of text in points. + /// + /// Returns a value rounded to [`emath::GUI_ROUNDING`]. + #[inline] + pub fn row_height(&mut self, font_id: &FontId) -> f32 { + self.fonts + .font(&font_id.family) + .styled_metrics( + self.pixels_per_point, + font_id.size, + // TODO(valadaptive): use font variation coords when calculating row height + &VariationCoords::default(), + ) + .row_height + } + + /// List of all known font families. + pub fn families(&self) -> Vec { + self.fonts.definitions.families.keys().cloned().collect() + } + + /// Layout some text. + /// + /// This is the most advanced layout function. + /// See also [`Self::layout`], [`Self::layout_no_wrap`] and + /// [`Self::layout_delayed_color`]. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout_job(&mut self, job: LayoutJob) -> Arc { + let allow_split_paragraphs = true; // Optimization for editing text with many paragraphs. + self.galley_cache.layout( + self.fonts, + self.pixels_per_point, + job, + allow_split_paragraphs, + ) + } + + pub fn num_galleys_in_cache(&self) -> usize { + self.galley_cache.num_galleys_in_cache() + } + + /// How full is the font atlas? + /// + /// This increases as new fonts and/or glyphs are used, + /// but can also decrease in a call to [`Fonts::begin_pass`]. + pub fn font_atlas_fill_ratio(&self) -> f32 { + self.fonts.atlas.fill_ratio() + } + + /// Will wrap text at the given width and line break at `\n`. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout( + &mut self, + text: String, + font_id: FontId, + color: crate::Color32, + wrap_width: f32, + ) -> Arc { + let job = LayoutJob::simple(text, font_id, color, wrap_width); + self.layout_job(job) + } + + /// Will line break at `\n`. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout_no_wrap( + &mut self, + text: String, + font_id: FontId, + color: crate::Color32, + ) -> Arc { + let job = LayoutJob::simple(text, font_id, color, f32::INFINITY); + self.layout_job(job) + } + + /// Like [`Self::layout`], made for when you want to pick a color for the text later. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout_delayed_color( + &mut self, + text: String, + font_id: FontId, + wrap_width: f32, + ) -> Arc { + self.layout(text, font_id, crate::Color32::PLACEHOLDER, wrap_width) + } +} + +// ---------------------------------------------------------------------------- + +/// The collection of fonts used by `epaint`. +/// +/// Required in order to paint text. +pub struct FontsImpl { + definitions: FontDefinitions, + atlas: TextureAtlas, + fonts_by_id: nohash_hasher::IntMap, + fonts_by_name: ahash::HashMap, + family_cache: ahash::HashMap, +} + +impl FontsImpl { + /// Create a new [`FontsImpl`] for text layout. + /// This call is expensive, so only create one [`FontsImpl`] and then reuse it. + pub fn new(options: TextOptions, definitions: FontDefinitions) -> Self { + let texture_width = options.max_texture_side.at_most(16 * 1024); + let initial_height = 32; // Keep initial font atlas small, so it is fast to upload to GPU. This will expand as needed anyways. + let atlas = TextureAtlas::new([texture_width, initial_height], options); + + let mut fonts_by_id: nohash_hasher::IntMap = Default::default(); + let mut fonts_by_name: ahash::HashMap = Default::default(); + for (name, font_data) in &definitions.font_data { + let blob = blob_from_font_data(font_data); + let font_face = FontFace::new( + options, + name.clone(), + blob, + font_data.index, + font_data.tweak.clone(), + ) + .unwrap_or_else(|err| panic!("Error parsing {name:?} TTF/OTF font file: {err}")); + let key = FontFaceKey::new(); + fonts_by_id.insert(key, font_face); + fonts_by_name.insert(name.clone(), key); + } + + Self { + definitions, + atlas, + fonts_by_id, + fonts_by_name, + family_cache: Default::default(), + } + } + + pub fn options(&self) -> &TextOptions { + self.atlas.options() + } + + /// Get the right font implementation from [`FontFamily`]. + pub fn font(&mut self, family: &FontFamily) -> Font<'_> { + let cached_family = self.family_cache.entry(family.clone()).or_insert_with(|| { + let fonts = &self.definitions.families.get(family); + let fonts = + fonts.unwrap_or_else(|| panic!("FontFamily::{family:?} is not bound to any fonts")); + + let fonts: Vec = fonts + .iter() + .map(|font_name| { + *self + .fonts_by_name + .get(font_name) + .unwrap_or_else(|| panic!("No font data found for {font_name:?}")) + }) + .collect(); + + CachedFamily::new(fonts, &mut self.fonts_by_id) + }); + Font { + fonts_by_id: &mut self.fonts_by_id, + cached_family, + atlas: &mut self.atlas, + } + } +} + +// ---------------------------------------------------------------------------- + +struct CachedGalley { + /// When it was last used + last_used: u32, + + /// Hashes of all other entries this one depends on for quick re-layout. + /// Their `last_used`s should be updated alongside this one to make sure they're + /// not evicted. + children: Option>, + + galley: Arc, +} + +#[derive(Default)] +struct GalleyCache { + /// Frame counter used to do garbage collection on the cache + generation: u32, + cache: nohash_hasher::IntMap, +} + +impl GalleyCache { + fn layout_internal( + &mut self, + fonts: &mut FontsImpl, + mut job: LayoutJob, + pixels_per_point: f32, + allow_split_paragraphs: bool, + ) -> (u64, Arc) { + if job.wrap.max_width.is_finite() { + // Protect against rounding errors in egui layout code. + + // Say the user asks to wrap at width 200.0. + // The text layout wraps, and reports that the final width was 196.0 points. + // This then trickles up the `Ui` chain and gets stored as the width for a tooltip (say). + // On the next frame, this is then set as the max width for the tooltip, + // and we end up calling the text layout code again, this time with a wrap width of 196.0. + // Except, somewhere in the `Ui` chain with added margins etc, a rounding error was introduced, + // so that we actually set a wrap-width of 195.9997 instead. + // Now the text that fit perfrectly at 196.0 needs to wrap one word earlier, + // and so the text re-wraps and reports a new width of 185.0 points. + // And then the cycle continues. + + // So we limit max_width to integers. + + // Related issues: + // * https://github.com/emilk/egui/issues/4927 + // * https://github.com/emilk/egui/issues/4928 + // * https://github.com/emilk/egui/issues/5084 + // * https://github.com/emilk/egui/issues/5163 + + job.wrap.max_width = job.wrap.max_width.round(); + } + + let hash = crate::util::hash((&job, OrderedFloat(pixels_per_point))); // TODO(emilk): even faster hasher? + + let galley = match self.cache.entry(hash) { + std::collections::hash_map::Entry::Occupied(entry) => { + // The job was found in cache - no need to re-layout. + let cached = entry.into_mut(); + cached.last_used = self.generation; + + let galley = Arc::clone(&cached.galley); + if let Some(children) = &cached.children { + // The point of `allow_split_paragraphs` is to split large jobs into paragraph, + // and then cache each paragraph individually. + // That way, if we edit a single paragraph, only that paragraph will be re-layouted. + // For that to work we need to keep all the child/paragraph + // galleys alive while the parent galley is alive: + for child_hash in Arc::clone(children).iter() { + if let Some(cached_child) = self.cache.get_mut(child_hash) { + cached_child.last_used = self.generation; + } + } + } + + galley + } + std::collections::hash_map::Entry::Vacant(entry) => { + let job = Arc::new(job); + if allow_split_paragraphs && should_cache_each_paragraph_individually(&job) { + let (child_galleys, child_hashes) = + self.layout_each_paragraph_individually(fonts, &job, pixels_per_point); + debug_assert_eq!( + child_hashes.len(), + child_galleys.len(), + "Bug in `layout_each_paragraph_individually`" + ); + let galley = Arc::new(Galley::concat(job, &child_galleys, pixels_per_point)); + + self.cache.insert( + hash, + CachedGalley { + last_used: self.generation, + children: Some(child_hashes.into()), + galley: Arc::clone(&galley), + }, + ); + galley + } else { + let galley = super::layout(fonts, pixels_per_point, job); + let galley = Arc::new(galley); + entry.insert(CachedGalley { + last_used: self.generation, + children: None, + galley: Arc::clone(&galley), + }); + galley + } + } + }; + + (hash, galley) + } + + fn layout( + &mut self, + fonts: &mut FontsImpl, + pixels_per_point: f32, + job: LayoutJob, + allow_split_paragraphs: bool, + ) -> Arc { + self.layout_internal(fonts, job, pixels_per_point, allow_split_paragraphs) + .1 + } + + /// Split on `\n` and lay out (and cache) each paragraph individually. + fn layout_each_paragraph_individually( + &mut self, + fonts: &mut FontsImpl, + job: &LayoutJob, + pixels_per_point: f32, + ) -> (Vec>, Vec) { + profiling::function_scope!(); + + let mut current_section = 0; + let mut start = 0; + let mut max_rows_remaining = job.wrap.max_rows; + let mut child_galleys = Vec::new(); + let mut child_hashes = Vec::new(); + + while start < job.text.len() { + let is_first_paragraph = start == 0; + // `end` will not include the `\n` since we don't want to create an empty row in our + // split galley + let mut end = job.text[start..] + .find('\n') + .map_or(job.text.len(), |i| start + i); + if end == job.text.len() - 1 && job.text.ends_with('\n') { + end += 1; // If the text ends with a newline, we include it in the last paragraph. + } + + let mut paragraph_job = LayoutJob { + text: job.text[start..end].to_owned(), + wrap: crate::text::TextWrapping { + max_rows: max_rows_remaining, + ..job.wrap + }, + sections: Vec::new(), + break_on_newline: job.break_on_newline, + halign: job.halign, + justify: job.justify, + first_row_min_height: if is_first_paragraph { + job.first_row_min_height + } else { + 0.0 + }, + round_output_to_gui: job.round_output_to_gui, + }; + + // Add overlapping sections: + for section in &job.sections[current_section..job.sections.len()] { + let LayoutSection { + leading_space, + byte_range: section_range, + format, + } = section; + + // `start` and `end` are the byte range of the current paragraph. + // How does the current section overlap with the paragraph range? + + if section_range.end <= start { + // The section is behind us + current_section += 1; + } else if end < section_range.start { + break; // Haven't reached this one yet. + } else { + // Section range overlaps with paragraph range + debug_assert!( + section_range.start <= section_range.end, + "Bad byte_range: {section_range:?}" + ); + let new_range = section_range.start.saturating_sub(start) + ..(section_range.end.at_most(end)).saturating_sub(start); + debug_assert!( + new_range.start <= new_range.end, + "Bad new section range: {new_range:?}" + ); + paragraph_job.sections.push(LayoutSection { + leading_space: if start <= section_range.start { + *leading_space + } else { + 0.0 + }, + byte_range: new_range, + format: format.clone(), + }); + } + } + + // TODO(emilk): we could lay out each paragraph in parallel to get a nice speedup on multicore machines. + let (hash, galley) = + self.layout_internal(fonts, paragraph_job, pixels_per_point, false); + child_hashes.push(hash); + + // This will prevent us from invalidating cache entries unnecessarily: + if max_rows_remaining != usize::MAX { + max_rows_remaining -= galley.rows.len(); + } + + let elided = galley.elided; + child_galleys.push(galley); + if elided { + break; + } + + start = end + 1; + } + + (child_galleys, child_hashes) + } + + pub fn num_galleys_in_cache(&self) -> usize { + self.cache.len() + } + + /// Must be called once per frame to clear the [`Galley`] cache. + pub fn flush_cache(&mut self) { + let current_generation = self.generation; + self.cache.retain(|_key, cached| { + cached.last_used == current_generation // only keep those that were used this frame + }); + self.generation = self.generation.wrapping_add(1); + } +} + +/// If true, lay out and cache each paragraph (sections separated by newlines) individually. +/// +/// This makes it much faster to re-layout the full text when only a portion of it has changed since last frame, i.e. when editing somewhere in a file with thousands of lines/paragraphs. +fn should_cache_each_paragraph_individually(job: &LayoutJob) -> bool { + // We currently don't support this elided text, i.e. when `max_rows` is set. + // Most often, elided text is elided to one row, + // and so will always be fast to lay out. + job.break_on_newline && job.wrap.max_rows == usize::MAX && job.text.contains('\n') +} + +#[cfg(feature = "default_fonts")] +#[cfg(test)] +mod tests { + use core::f32; + + use super::*; + use crate::text::{TextWrapping, layout}; + use crate::{Stroke, text::TextFormat}; + use ecolor::Color32; + use emath::Align; + + fn jobs() -> Vec { + vec![ + LayoutJob::simple( + String::default(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + LayoutJob::simple( + "ends with newlines\n\n".to_owned(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + LayoutJob::simple( + "Simple test.".to_owned(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + { + let mut job = LayoutJob::simple( + "hi".to_owned(), + FontId::default(), + Color32::WHITE, + f32::INFINITY, + ); + job.append("\n", 0.0, TextFormat::default()); + job.append("\n", 0.0, TextFormat::default()); + job.append("world", 0.0, TextFormat::default()); + job.wrap.max_rows = 2; + job + }, + { + let mut job = LayoutJob::simple( + "Test text with a lot of words\n and a newline.".to_owned(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + 40.0, + ); + job.first_row_min_height = 30.0; + job + }, + LayoutJob::simple( + "This some text that may be long.\nDet kanske också finns lite ÅÄÖ här.".to_owned(), + FontId::new(14.0, FontFamily::Proportional), + Color32::WHITE, + 50.0, + ), + { + let mut job = LayoutJob { + first_row_min_height: 20.0, + ..Default::default() + }; + job.append( + "1st paragraph has underline and strikethrough, and has some non-ASCII characters:\n ÅÄÖ.", + 0.0, + TextFormat { + font_id: FontId::new(15.0, FontFamily::Monospace), + underline: Stroke::new(1.0, Color32::RED), + strikethrough: Stroke::new(1.0, Color32::GREEN), + ..Default::default() + }, + ); + job.append( + "2nd paragraph has some leading space.\n", + 16.0, + TextFormat { + font_id: FontId::new(14.0, FontFamily::Proportional), + ..Default::default() + }, + ); + job.append( + "3rd paragraph is kind of boring, but has italics.\nAnd a newline", + 0.0, + TextFormat { + font_id: FontId::new(10.0, FontFamily::Proportional), + italics: true, + ..Default::default() + }, + ); + + job + }, + { + // Regression test for + let mut job = LayoutJob::default(); + job.append("\n", 0.0, TextFormat::default()); + job.append("", 0.0, TextFormat::default()); + job + }, + ] + } + + #[expect(clippy::print_stdout)] + #[test] + fn test_split_paragraphs() { + for pixels_per_point in [1.0, 2.0_f32.sqrt(), 2.0] { + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + for halign in [Align::Min, Align::Center, Align::Max] { + for justify in [false, true] { + for mut job in jobs() { + job.halign = halign; + job.justify = justify; + + let whole = GalleyCache::default().layout( + &mut fonts, + pixels_per_point, + job.clone(), + false, + ); + + let split = GalleyCache::default().layout( + &mut fonts, + pixels_per_point, + job.clone(), + true, + ); + + for (i, row) in whole.rows.iter().enumerate() { + println!( + "Whole row {i}: section_index_at_start={}, first glyph section_index: {:?}", + row.row.section_index_at_start, + row.row.glyphs.first().map(|g| g.section_index) + ); + } + for (i, row) in split.rows.iter().enumerate() { + println!( + "Split row {i}: section_index_at_start={}, first glyph section_index: {:?}", + row.row.section_index_at_start, + row.row.glyphs.first().map(|g| g.section_index) + ); + } + + // Don't compare for equaliity; but format with a specific precision and make sure we hit that. + // NOTE: we use a rather low precision, because as long as we're within a pixel I think it's good enough. + similar_asserts::assert_eq!( + format!("{:#.1?}", split), + format!("{:#.1?}", whole), + "pixels_per_point: {pixels_per_point:.2}, input text: '{}'", + job.text + ); + } + } + } + } + } + + #[test] + fn test_intrinsic_size() { + let pixels_per_point = [1.0, 1.3, 2.0, 0.867]; + let max_widths = [40.0, 80.0, 133.0, 200.0]; + let rounded_output_to_gui = [false, true]; + + for pixels_per_point in pixels_per_point { + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + for &max_width in &max_widths { + for round_output_to_gui in rounded_output_to_gui { + for mut job in jobs() { + job.wrap = TextWrapping::wrap_at_width(max_width); + + job.round_output_to_gui = round_output_to_gui; + + let galley_wrapped = + layout(&mut fonts, pixels_per_point, job.clone().into()); + + job.wrap = TextWrapping::no_max_width(); + + let text = job.text.clone(); + let galley_unwrapped = layout(&mut fonts, pixels_per_point, job.into()); + + let intrinsic_size = galley_wrapped.intrinsic_size(); + let unwrapped_size = galley_unwrapped.size(); + + let difference = (intrinsic_size - unwrapped_size).length().abs(); + similar_asserts::assert_eq!( + format!("{intrinsic_size:.4?}"), + format!("{unwrapped_size:.4?}"), + "Wrapped intrinsic size should almost match unwrapped size. Intrinsic: {intrinsic_size:.8?} vs unwrapped: {unwrapped_size:.8?} + Difference: {difference:.8?} + wrapped rows: {}, unwrapped rows: {} + pixels_per_point: {pixels_per_point}, text: {text:?}, max_width: {max_width}, round_output_to_gui: {round_output_to_gui}", + galley_wrapped.rows.len(), + galley_unwrapped.rows.len() + ); + similar_asserts::assert_eq!( + format!("{intrinsic_size:.4?}"), + format!("{unwrapped_size:.4?}"), + "Unwrapped galley intrinsic size should exactly match its size. \ + {:.8?} vs {:8?}", + galley_unwrapped.intrinsic_size(), + galley_unwrapped.size(), + ); + } + } + } + } + } + + #[test] + fn test_fallback_glyph_width() { + let mut fonts = Fonts::new(TextOptions::default(), FontDefinitions::empty()); + let mut view = fonts.with_pixels_per_point(1.0); + + let width = view.glyph_width(&FontId::new(12.0, FontFamily::Proportional), ' '); + assert_eq!(width, 0.0); + } +} diff --git a/vendor/epaint/src/text/mod.rs b/vendor/epaint/src/text/mod.rs new file mode 100644 index 0000000..44cf4ec --- /dev/null +++ b/vendor/epaint/src/text/mod.rs @@ -0,0 +1,68 @@ +//! Everything related to text, fonts, text layout, cursors etc. + +pub mod cursor; +mod font; +mod fonts; +mod text_layout; +mod text_layout_types; +#[cfg(target_os = "windows")] +mod windows_directwrite; + +/// One `\t` character is this many spaces wide. +pub const TAB_SIZE: usize = 4; + +pub use { + fonts::{ + FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, Fonts, + FontsImpl, FontsView, InsertFontFamily, + }, + text_layout::*, + text_layout_types::*, +}; + +/// Name of the platform glyph rasterizer used by this build. +/// +/// This is exposed for downstream packaging tests. Text layout and painting +/// remain owned by `epaint` on every platform. +#[doc(hidden)] +pub const fn font_rasterizer_name() -> &'static str { + #[cfg(target_os = "windows")] + { + "DirectWrite grayscale" + } + #[cfg(not(target_os = "windows"))] + { + "skrifa/vello" + } +} + +/// Suggested character to use to replace those in password text fields. +pub const PASSWORD_REPLACEMENT_CHAR: char = '•'; + +/// Controls how we render text +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextOptions { + /// Maximum size of the font texture. + pub max_texture_side: usize, + + /// Controls how to convert glyph coverage to alpha. + pub alpha_from_coverage: crate::AlphaFromCoverage, + + /// Whether to enable font hinting + /// + /// (round some font coordinates to pixels for sharper text). + /// + /// Default is `true`. + pub font_hinting: bool, +} + +impl Default for TextOptions { + fn default() -> Self { + Self { + max_texture_side: 2048, // Small but portable + alpha_from_coverage: crate::AlphaFromCoverage::default(), + font_hinting: true, + } + } +} diff --git a/vendor/epaint/src/text/text_layout.rs b/vendor/epaint/src/text/text_layout.rs new file mode 100644 index 0000000..d98ea60 --- /dev/null +++ b/vendor/epaint/src/text/text_layout.rs @@ -0,0 +1,1281 @@ +#![expect(clippy::unwrap_used)] // TODO(emilk): remove unwraps + +use std::sync::Arc; + +use emath::{Align, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2, pos2, vec2}; + +use crate::{ + Color32, Mesh, Stroke, Vertex, + stroke::PathStroke, + text::{ + font::{StyledMetrics, is_cjk, is_cjk_break_allowed}, + fonts::FontFaceKey, + }, +}; + +use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals}; + +// ---------------------------------------------------------------------------- + +/// Represents GUI scale and convenience methods for rounding to pixels. +#[derive(Clone, Copy)] +struct PointScale { + pub pixels_per_point: f32, +} + +impl PointScale { + #[inline(always)] + pub fn new(pixels_per_point: f32) -> Self { + Self { pixels_per_point } + } + + #[inline(always)] + pub fn pixels_per_point(&self) -> f32 { + self.pixels_per_point + } + + #[inline(always)] + pub fn round_to_pixel(&self, point: f32) -> f32 { + (point * self.pixels_per_point).round() / self.pixels_per_point + } + + #[inline(always)] + pub fn floor_to_pixel(&self, point: f32) -> f32 { + (point * self.pixels_per_point).floor() / self.pixels_per_point + } +} + +// ---------------------------------------------------------------------------- + +/// Temporary storage before line-wrapping. +#[derive(Clone)] +struct Paragraph { + /// Start of the next glyph to be added. In screen-space / physical pixels. + pub cursor_x_px: f32, + + /// This is included in case there are no glyphs + pub section_index_at_start: u32, + + pub glyphs: Vec, + + /// In case of an empty paragraph ("\n"), use this as height. + pub empty_paragraph_height: f32, +} + +impl Paragraph { + pub fn from_section_index(section_index_at_start: u32) -> Self { + Self { + cursor_x_px: 0.0, + section_index_at_start, + glyphs: vec![], + empty_paragraph_height: 0.0, + } + } +} + +/// Layout text into a [`Galley`]. +/// +/// In most cases you should use [`crate::FontsView::layout_job`] instead +/// since that memoizes the input, making subsequent layouting of the same text much faster. +pub fn layout(fonts: &mut FontsImpl, pixels_per_point: f32, job: Arc) -> Galley { + profiling::function_scope!(); + + if job.wrap.max_rows == 0 { + // Early-out: no text + return Galley { + job, + rows: Default::default(), + rect: Rect::ZERO, + mesh_bounds: Rect::NOTHING, + num_vertices: 0, + num_indices: 0, + pixels_per_point, + elided: true, + intrinsic_size: Vec2::ZERO, + }; + } + + // For most of this we ignore the y coordinate: + + let mut paragraphs = vec![Paragraph::from_section_index(0)]; + for (section_index, section) in job.sections.iter().enumerate() { + layout_section( + fonts, + pixels_per_point, + &job, + section_index as u32, + section, + &mut paragraphs, + ); + } + + let point_scale = PointScale::new(pixels_per_point); + + let intrinsic_size = calculate_intrinsic_size(point_scale, &job, ¶graphs); + + let mut elided = false; + let mut rows = rows_from_paragraphs(paragraphs, &job, pixels_per_point, &mut elided); + if elided && let Some(last_placed) = rows.last_mut() { + let last_row = Arc::make_mut(&mut last_placed.row); + replace_last_glyph_with_overflow_character(fonts, pixels_per_point, &job, last_row); + if let Some(last) = last_row.glyphs.last() { + last_row.size.x = last.max_x(); + } + } + + let justify = job.justify && job.wrap.max_width.is_finite(); + + if justify || job.halign != Align::LEFT { + let num_rows = rows.len(); + for (i, placed_row) in rows.iter_mut().enumerate() { + let is_last_row = i + 1 == num_rows; + let justify_row = justify && !placed_row.ends_with_newline && !is_last_row; + halign_and_justify_row( + point_scale, + placed_row, + job.halign, + job.wrap.max_width, + justify_row, + ); + } + } + + // Calculate the Y positions and tessellate the text: + galley_from_rows(point_scale, job, rows, elided, intrinsic_size) +} + +// Ignores the Y coordinate. +fn layout_section( + fonts: &mut FontsImpl, + pixels_per_point: f32, + job: &LayoutJob, + section_index: u32, + section: &LayoutSection, + out_paragraphs: &mut Vec, +) { + let LayoutSection { + leading_space, + byte_range, + format, + } = section; + let mut font = fonts.font(&format.font_id.family); + let font_size = format.font_id.size; + let font_metrics = font.styled_metrics(pixels_per_point, font_size, &format.coords); + let line_height = section + .format + .line_height + .unwrap_or(font_metrics.row_height); + let extra_letter_spacing = section.format.extra_letter_spacing; + + let mut paragraph = out_paragraphs.last_mut().unwrap(); + if paragraph.glyphs.is_empty() { + paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? + } + + paragraph.cursor_x_px += leading_space * pixels_per_point; + + let mut last_glyph_id = None; + + // Optimization: only recompute `ScaledMetrics` when the concrete `FontImpl` changes. + let mut current_font = FontFaceKey::INVALID; + let mut current_font_face_metrics = StyledMetrics::default(); + + for chr in job.text[byte_range.clone()].chars() { + if job.break_on_newline && chr == '\n' { + out_paragraphs.push(Paragraph::from_section_index(section_index)); + paragraph = out_paragraphs.last_mut().unwrap(); + paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? + } else { + let (font_id, glyph_info) = font.glyph_info(chr); + let mut font_face = font.fonts_by_id.get_mut(&font_id); + if current_font != font_id { + current_font = font_id; + current_font_face_metrics = font_face + .as_ref() + .map(|font_face| { + font_face.styled_metrics(pixels_per_point, font_size, &format.coords) + }) + .unwrap_or_default(); + } + + if let (Some(font_face), Some(last_glyph_id), Some(glyph_id)) = + (&font_face, last_glyph_id, glyph_info.id) + { + paragraph.cursor_x_px += font_face.pair_kerning_pixels( + ¤t_font_face_metrics, + last_glyph_id, + glyph_id, + ); + + // Only apply extra_letter_spacing to glyphs after the first one: + paragraph.cursor_x_px += extra_letter_spacing * pixels_per_point; + } + + let (glyph_alloc, physical_x) = if let Some(font_face) = font_face.as_mut() { + font_face.allocate_glyph( + font.atlas, + ¤t_font_face_metrics, + glyph_info, + chr, + paragraph.cursor_x_px, + ) + } else { + Default::default() + }; + + paragraph.glyphs.push(Glyph { + chr, + pos: pos2(physical_x as f32 / pixels_per_point, f32::NAN), + advance_width: glyph_alloc.advance_width_px / pixels_per_point, + line_height, + font_face_height: current_font_face_metrics.row_height, + font_face_ascent: current_font_face_metrics.ascent, + font_height: font_metrics.row_height, + font_ascent: font_metrics.ascent, + uv_rect: glyph_alloc.uv_rect, + section_index, + first_vertex: 0, // filled in later + }); + + paragraph.cursor_x_px += glyph_alloc.advance_width_px; + last_glyph_id = Some(glyph_alloc.id); + } + } +} + +/// Calculate the intrinsic size of the text. +/// +/// The result is eventually passed to `Response::intrinsic_size`. +/// This works by calculating the size of each `Paragraph` (instead of each `Row`). +fn calculate_intrinsic_size( + point_scale: PointScale, + job: &LayoutJob, + paragraphs: &[Paragraph], +) -> Vec2 { + let mut intrinsic_size = Vec2::ZERO; + for (idx, paragraph) in paragraphs.iter().enumerate() { + // Use the precise cursor position instead of `last_glyph.max_x()`, + // because glyph positions are pixel-snapped but the cursor tracks + // the exact subpixel advance. This ensures that when two galleys are + // placed side-by-side, the gap matches what it would be within a + // single galley. + let width = paragraph.cursor_x_px / point_scale.pixels_per_point; + intrinsic_size.x = f32::max(intrinsic_size.x, width); + + let mut height = paragraph + .glyphs + .iter() + .map(|g| g.line_height) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(paragraph.empty_paragraph_height); + if idx == 0 { + height = f32::max(height, job.first_row_min_height); + } + intrinsic_size.y += point_scale.round_to_pixel(height); + } + intrinsic_size +} + +// Ignores the Y coordinate. +fn rows_from_paragraphs( + paragraphs: Vec, + job: &LayoutJob, + pixels_per_point: f32, + elided: &mut bool, +) -> Vec { + let num_paragraphs = paragraphs.len(); + + let mut rows = vec![]; + + for (i, paragraph) in paragraphs.into_iter().enumerate() { + if job.wrap.max_rows <= rows.len() { + *elided = true; + break; + } + + let is_last_paragraph = (i + 1) == num_paragraphs; + + if paragraph.glyphs.is_empty() { + rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: vec![], + visuals: Default::default(), + size: vec2(0.0, paragraph.empty_paragraph_height), + }), + ends_with_newline: !is_last_paragraph, + }); + } else { + // Use precise cursor position for width instead of pixel-snapped + // `last_glyph.max_x()`, so that side-by-side galleys have the same + // spacing as characters within a single galley. + let paragraph_width = paragraph.cursor_x_px / pixels_per_point; + if paragraph_width <= job.effective_wrap_width() { + // Early-out optimization: the whole paragraph fits on one row. + rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: paragraph.glyphs, + visuals: Default::default(), + size: vec2(paragraph_width, 0.0), + }), + ends_with_newline: !is_last_paragraph, + }); + } else { + line_break(¶graph, job, &mut rows, elided); + let placed_row = rows.last_mut().unwrap(); + placed_row.ends_with_newline = !is_last_paragraph; + } + } + } + + rows +} + +fn line_break( + paragraph: &Paragraph, + job: &LayoutJob, + out_rows: &mut Vec, + elided: &mut bool, +) { + let wrap_width = job.effective_wrap_width(); + + // Keeps track of good places to insert row break if we exceed `wrap_width`. + let mut row_break_candidates = RowBreakCandidates::default(); + + let mut first_row_indentation = paragraph.glyphs[0].pos.x; + let mut row_start_x = 0.0; + let mut row_start_idx = 0; + + for i in 0..paragraph.glyphs.len() { + if job.wrap.max_rows <= out_rows.len() { + *elided = true; + break; + } + + let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x; + + if wrap_width < potential_row_width { + // Row break: + + if first_row_indentation > 0.0 + && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) + { + // Allow the first row to be completely empty, because we know there will be more space on the next row: + // TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height. + out_rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: vec![], + visuals: Default::default(), + size: Vec2::ZERO, + }), + ends_with_newline: false, + }); + row_start_x += first_row_indentation; + first_row_indentation = 0.0; + } else if let Some(last_kept_index) = row_break_candidates.get(job.wrap.break_anywhere) + { + let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] + .iter() + .copied() + .map(|mut glyph| { + glyph.pos.x -= row_start_x; + glyph + }) + .collect(); + + let section_index_at_start = glyphs[0].section_index; + let paragraph_max_x = glyphs.last().unwrap().max_x(); + + out_rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start, + glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x, 0.0), + }), + ends_with_newline: false, + }); + + // Start a new row: + row_start_idx = last_kept_index + 1; + row_start_x = paragraph.glyphs[row_start_idx].pos.x; + row_break_candidates.forget_before_idx(row_start_idx); + } else { + // Found no place to break, so we have to overrun wrap_width. + } + } + + row_break_candidates.add(i, ¶graph.glyphs[i..]); + } + + if row_start_idx < paragraph.glyphs.len() { + // Final row of text: + + if job.wrap.max_rows <= out_rows.len() { + *elided = true; // can't fit another row + } else { + let paragraph_min_x = paragraph.glyphs[row_start_idx].pos.x - row_start_x; + let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x() - row_start_x; + + let glyphs: Vec = paragraph.glyphs[row_start_idx..] + .iter() + .copied() + .map(|mut glyph| { + glyph.pos.x -= row_start_x + paragraph_min_x; + glyph + }) + .collect(); + + let section_index_at_start = glyphs[0].section_index; + + out_rows.push(PlacedRow { + pos: pos2(paragraph_min_x, 0.0), + row: Arc::new(Row { + section_index_at_start, + glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x - paragraph_min_x, 0.0), + }), + ends_with_newline: false, + }); + } + } +} + +/// Trims the last glyphs in the row and replaces it with an overflow character (e.g. `…`). +/// +/// Called before we have any Y coordinates. +fn replace_last_glyph_with_overflow_character( + fonts: &mut FontsImpl, + pixels_per_point: f32, + job: &LayoutJob, + row: &mut Row, +) { + let Some(overflow_character) = job.wrap.overflow_character else { + return; + }; + + let mut section_index = row + .glyphs + .last() + .map(|g| g.section_index) + .unwrap_or(row.section_index_at_start); + loop { + let section = &job.sections[section_index as usize]; + let extra_letter_spacing = section.format.extra_letter_spacing; + let mut font = fonts.font(§ion.format.font_id.family); + let font_size = section.format.font_id.size; + + let (font_id, glyph_info) = font.glyph_info(overflow_character); + let mut font_face = font.fonts_by_id.get_mut(&font_id); + let font_face_metrics = font_face + .as_mut() + .map(|f| f.styled_metrics(pixels_per_point, font_size, §ion.format.coords)) + .unwrap_or_default(); + + let overflow_glyph_x = if let Some(prev_glyph) = row.glyphs.last() { + // Kern the overflow character properly + let pair_kerning = font_face + .as_mut() + .map(|font_face| { + if let (Some(prev_glyph_id), Some(overflow_glyph_id)) = ( + font_face.glyph_info(prev_glyph.chr).and_then(|g| g.id), + font_face.glyph_info(overflow_character).and_then(|g| g.id), + ) { + font_face.pair_kerning(&font_face_metrics, prev_glyph_id, overflow_glyph_id) + } else { + 0.0 + } + }) + .unwrap_or_default(); + + prev_glyph.max_x() + extra_letter_spacing + pair_kerning + } else { + 0.0 // TODO(emilk): heed paragraph leading_space 😬 + }; + + let replacement_glyph_width = font_face + .as_mut() + .and_then(|f| f.glyph_info(overflow_character)) + .map(|i| { + i.advance_width_unscaled.0 * font_face_metrics.px_scale_factor / pixels_per_point + }) + .unwrap_or_default(); + + // Check if we're within width budget: + if overflow_glyph_x + replacement_glyph_width <= job.effective_wrap_width() + || row.glyphs.is_empty() + { + // we are done + + let (replacement_glyph_alloc, physical_x) = font_face + .as_mut() + .map(|f| { + f.allocate_glyph( + font.atlas, + &font_face_metrics, + glyph_info, + overflow_character, + overflow_glyph_x * pixels_per_point, + ) + }) + .unwrap_or_default(); + + let font_metrics = + font.styled_metrics(pixels_per_point, font_size, §ion.format.coords); + let line_height = section + .format + .line_height + .unwrap_or(font_metrics.row_height); + + row.glyphs.push(Glyph { + chr: overflow_character, + pos: pos2(physical_x as f32 / pixels_per_point, f32::NAN), + advance_width: replacement_glyph_alloc.advance_width_px / pixels_per_point, + line_height, + font_face_height: font_face_metrics.row_height, + font_face_ascent: font_face_metrics.ascent, + font_height: font_metrics.row_height, + font_ascent: font_metrics.ascent, + uv_rect: replacement_glyph_alloc.uv_rect, + section_index, + first_vertex: 0, // filled in later + }); + return; + } + + // We didn't fit - pop the last glyph and try again. + if let Some(last_glyph) = row.glyphs.pop() { + section_index = last_glyph.section_index; + } else { + section_index = row.section_index_at_start; + } + } +} + +/// Horizontally aligned the text on a row. +/// +/// Ignores the Y coordinate. +fn halign_and_justify_row( + point_scale: PointScale, + placed_row: &mut PlacedRow, + halign: Align, + wrap_width: f32, + justify: bool, +) { + #![expect(clippy::useless_let_if_seq)] // False positive + + let row = Arc::make_mut(&mut placed_row.row); + + if row.glyphs.is_empty() { + return; + } + + let num_leading_spaces = row + .glyphs + .iter() + .take_while(|glyph| glyph.chr.is_whitespace()) + .count(); + + let glyph_range = if num_leading_spaces == row.glyphs.len() { + // There is only whitespace + (0, row.glyphs.len()) + } else { + let num_trailing_spaces = row + .glyphs + .iter() + .rev() + .take_while(|glyph| glyph.chr.is_whitespace()) + .count(); + + (num_leading_spaces, row.glyphs.len() - num_trailing_spaces) + }; + let num_glyphs_in_range = glyph_range.1 - glyph_range.0; + assert!(num_glyphs_in_range > 0, "Should have at least one glyph"); + + let original_min_x = row.glyphs[glyph_range.0].logical_rect().min.x; + let original_max_x = row.glyphs[glyph_range.1 - 1].logical_rect().max.x; + let original_width = original_max_x - original_min_x; + + let target_width = if justify && num_glyphs_in_range > 1 { + wrap_width + } else { + original_width + }; + + let (target_min_x, target_max_x) = match halign { + Align::LEFT => (0.0, target_width), + Align::Center => (-target_width / 2.0, target_width / 2.0), + Align::RIGHT => (-target_width, 0.0), + }; + + let num_spaces_in_range = row.glyphs[glyph_range.0..glyph_range.1] + .iter() + .filter(|glyph| glyph.chr.is_whitespace()) + .count(); + + let mut extra_x_per_glyph = if num_glyphs_in_range == 1 { + 0.0 + } else { + (target_width - original_width) / (num_glyphs_in_range as f32 - 1.0) + }; + extra_x_per_glyph = extra_x_per_glyph.at_least(0.0); // Don't contract + + let mut extra_x_per_space = 0.0; + if 0 < num_spaces_in_range && num_spaces_in_range < num_glyphs_in_range { + // Add an integral number of pixels between each glyph, + // and add the balance to the spaces: + + extra_x_per_glyph = point_scale.floor_to_pixel(extra_x_per_glyph); + + extra_x_per_space = (target_width + - original_width + - extra_x_per_glyph * (num_glyphs_in_range as f32 - 1.0)) + / (num_spaces_in_range as f32); + } + + placed_row.pos.x = point_scale.round_to_pixel(target_min_x); + let mut translate_x = -original_min_x - extra_x_per_glyph * glyph_range.0 as f32; + + for glyph in &mut row.glyphs { + glyph.pos.x += translate_x; + glyph.pos.x = point_scale.round_to_pixel(glyph.pos.x); + translate_x += extra_x_per_glyph; + if glyph.chr.is_whitespace() { + translate_x += extra_x_per_space; + } + } + + // Note we ignore the leading/trailing whitespace here! + row.size.x = target_max_x - target_min_x; +} + +/// Calculate the Y positions and tessellate the text. +fn galley_from_rows( + point_scale: PointScale, + job: Arc, + mut rows: Vec, + elided: bool, + intrinsic_size: Vec2, +) -> Galley { + let mut first_row_min_height = job.first_row_min_height; + let mut cursor_y = 0.0; + + for placed_row in &mut rows { + let mut max_row_height = first_row_min_height.at_least(placed_row.height()); + let row = Arc::make_mut(&mut placed_row.row); + + first_row_min_height = 0.0; + for glyph in &row.glyphs { + max_row_height = max_row_height.at_least(glyph.line_height); + } + max_row_height = point_scale.round_to_pixel(max_row_height); + + // Now position each glyph vertically: + for glyph in &mut row.glyphs { + let format = &job.sections[glyph.section_index as usize].format; + + glyph.pos.y = glyph.font_face_ascent + + // Apply valign to the different in height of the entire row, and the height of this `Font`: + + format.valign.to_factor() * (max_row_height - glyph.line_height) + + // When mixing different `FontImpl` (e.g. latin and emojis), + // we always center the difference: + + 0.5 * (glyph.font_height - glyph.font_face_height); + + glyph.pos.y = point_scale.round_to_pixel(glyph.pos.y); + } + + placed_row.pos.y = cursor_y; + row.size.y = max_row_height; + + cursor_y += max_row_height; + cursor_y = point_scale.round_to_pixel(cursor_y); // TODO(emilk): it would be better to do the calculations in pixels instead. + } + + let format_summary = format_summary(&job); + + let mut rect = Rect::ZERO; + let mut mesh_bounds = Rect::NOTHING; + let mut num_vertices = 0; + let mut num_indices = 0; + + for placed_row in &mut rows { + rect |= placed_row.rect(); + + let row = Arc::make_mut(&mut placed_row.row); + row.visuals = tessellate_row(point_scale, &job, &format_summary, row); + + mesh_bounds |= row.visuals.mesh_bounds.translate(placed_row.pos.to_vec2()); + num_vertices += row.visuals.mesh.vertices.len(); + num_indices += row.visuals.mesh.indices.len(); + + row.section_index_at_start = u32::MAX; // No longer in use. + for glyph in &mut row.glyphs { + glyph.section_index = u32::MAX; // No longer in use. + } + } + + let mut galley = Galley { + job, + rows, + elided, + rect, + mesh_bounds, + num_vertices, + num_indices, + pixels_per_point: point_scale.pixels_per_point, + intrinsic_size, + }; + + if galley.job.round_output_to_gui { + galley.round_output_to_gui(); + } + + galley +} + +#[derive(Default)] +struct FormatSummary { + any_background: bool, + any_underline: bool, + any_strikethrough: bool, +} + +fn format_summary(job: &LayoutJob) -> FormatSummary { + let mut format_summary = FormatSummary::default(); + for section in &job.sections { + format_summary.any_background |= section.format.background != Color32::TRANSPARENT; + format_summary.any_underline |= section.format.underline != Stroke::NONE; + format_summary.any_strikethrough |= section.format.strikethrough != Stroke::NONE; + } + format_summary +} + +fn tessellate_row( + point_scale: PointScale, + job: &LayoutJob, + format_summary: &FormatSummary, + row: &mut Row, +) -> RowVisuals { + if row.glyphs.is_empty() { + return Default::default(); + } + + let mut mesh = Mesh::default(); + + mesh.reserve_triangles(row.glyphs.len() * 2); + mesh.reserve_vertices(row.glyphs.len() * 4); + + if format_summary.any_background { + add_row_backgrounds(point_scale, job, row, &mut mesh); + } + + let glyph_index_start = mesh.indices.len(); + let glyph_vertex_start = mesh.vertices.len(); + tessellate_glyphs(point_scale, job, row, &mut mesh); + let glyph_vertex_end = mesh.vertices.len(); + + if format_summary.any_underline { + add_row_hline(point_scale, row, &mut mesh, |glyph| { + let format = &job.sections[glyph.section_index as usize].format; + let stroke = format.underline; + let y = glyph.logical_rect().bottom(); + (stroke, y) + }); + } + + if format_summary.any_strikethrough { + add_row_hline(point_scale, row, &mut mesh, |glyph| { + let format = &job.sections[glyph.section_index as usize].format; + let stroke = format.strikethrough; + let y = glyph.logical_rect().center().y; + (stroke, y) + }); + } + + let mesh_bounds = mesh.calc_bounds(); + + RowVisuals { + mesh, + mesh_bounds, + glyph_index_start, + glyph_vertex_range: glyph_vertex_start..glyph_vertex_end, + } +} + +/// Create background for glyphs that have them. +/// Creates as few rectangular regions as possible. +fn add_row_backgrounds(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh: &mut Mesh) { + if row.glyphs.is_empty() { + return; + } + + let mut end_run = |start: Option<(Color32, Rect, f32)>, stop_x: f32| { + if let Some((color, start_rect, expand)) = start { + let rect = Rect::from_min_max(start_rect.left_top(), pos2(stop_x, start_rect.bottom())); + let rect = rect.expand(expand); + let rect = rect.round_to_pixels(point_scale.pixels_per_point()); + mesh.add_colored_rect(rect, color); + } + }; + + let mut run_start = None; + let mut last_rect = Rect::NAN; + + for glyph in &row.glyphs { + let format = &job.sections[glyph.section_index as usize].format; + let color = format.background; + let rect = glyph.logical_rect(); + + if color == Color32::TRANSPARENT { + end_run(run_start.take(), last_rect.right()); + } else if let Some((existing_color, start, expand)) = run_start { + if existing_color == color + && start.top() == rect.top() + && start.bottom() == rect.bottom() + && format.expand_bg == expand + { + // continue the same background rectangle + } else { + end_run(run_start.take(), last_rect.right()); + run_start = Some((color, rect, format.expand_bg)); + } + } else { + run_start = Some((color, rect, format.expand_bg)); + } + + last_rect = rect; + } + + end_run(run_start.take(), last_rect.right()); +} + +fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &mut Row, mesh: &mut Mesh) { + for glyph in &mut row.glyphs { + glyph.first_vertex = mesh.vertices.len() as u32; + let uv_rect = glyph.uv_rect; + if !uv_rect.is_nothing() { + let mut left_top = glyph.pos + uv_rect.offset; + left_top.x = point_scale.round_to_pixel(left_top.x); + left_top.y = point_scale.round_to_pixel(left_top.y); + + let rect = Rect::from_min_max(left_top, left_top + uv_rect.size); + let uv = Rect::from_min_max( + pos2(uv_rect.min[0] as f32, uv_rect.min[1] as f32), + pos2(uv_rect.max[0] as f32, uv_rect.max[1] as f32), + ); + + let format = &job.sections[glyph.section_index as usize].format; + + let color = format.color; + + if format.italics { + let idx = mesh.vertices.len() as u32; + mesh.add_triangle(idx, idx + 1, idx + 2); + mesh.add_triangle(idx + 2, idx + 1, idx + 3); + + let top_offset = rect.height() * 0.25 * Vec2::X; + + mesh.vertices.push(Vertex { + pos: rect.left_top() + top_offset, + uv: uv.left_top(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.right_top() + top_offset, + uv: uv.right_top(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.left_bottom(), + uv: uv.left_bottom(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.right_bottom(), + uv: uv.right_bottom(), + color, + }); + } else { + mesh.add_rect_with_uv(rect, uv, color); + } + } + } +} + +/// Add a horizontal line over a row of glyphs with a stroke and y decided by a callback. +fn add_row_hline( + point_scale: PointScale, + row: &Row, + mesh: &mut Mesh, + stroke_and_y: impl Fn(&Glyph) -> (Stroke, f32), +) { + let mut path = crate::tessellator::Path::default(); // reusing path to avoid re-allocations. + + let mut end_line = |start: Option<(Stroke, Pos2)>, stop_x: f32| { + if let Some((stroke, start)) = start { + let stop = pos2(stop_x, start.y); + path.clear(); + path.add_line_segment([start, stop]); + let feathering = 1.0 / point_scale.pixels_per_point(); + path.stroke_open(feathering, &PathStroke::from(stroke), mesh); + } + }; + + let mut line_start = None; + let mut last_right_x = f32::NAN; + + for glyph in &row.glyphs { + let (stroke, mut y) = stroke_and_y(glyph); + stroke.round_center_to_pixel(point_scale.pixels_per_point, &mut y); + + if stroke.is_empty() { + end_line(line_start.take(), last_right_x); + } else if let Some((existing_stroke, start)) = line_start { + if existing_stroke == stroke && start.y == y { + // continue the same line + } else { + end_line(line_start.take(), last_right_x); + line_start = Some((stroke, pos2(glyph.pos.x, y))); + } + } else { + line_start = Some((stroke, pos2(glyph.pos.x, y))); + } + + last_right_x = glyph.max_x(); + } + + end_line(line_start.take(), last_right_x); +} + +// ---------------------------------------------------------------------------- + +/// Keeps track of good places to break a long row of text. +/// Will focus primarily on spaces, secondarily on things like `-` +#[derive(Clone, Copy, Default)] +struct RowBreakCandidates { + /// Breaking at ` ` or other whitespace + /// is always the primary candidate. + space: Option, + + /// Logograms (single character representing a whole word) or kana (Japanese hiragana and katakana) are good candidates for line break. + cjk: Option, + + /// Breaking anywhere before a CJK character is acceptable too. + pre_cjk: Option, + + /// Breaking at a dash is a super- + /// good idea. + dash: Option, + + /// This is nicer for things like URLs, e.g. www. + /// example.com. + punctuation: Option, + + /// Breaking after just random character is some + /// times necessary. + any: Option, +} + +impl RowBreakCandidates { + fn add(&mut self, index: usize, glyphs: &[Glyph]) { + let chr = glyphs[0].chr; + const NON_BREAKING_SPACE: char = '\u{A0}'; + if chr.is_whitespace() && chr != NON_BREAKING_SPACE { + self.space = Some(index); + } else if is_cjk(chr) && (glyphs.len() == 1 || is_cjk_break_allowed(glyphs[1].chr)) { + self.cjk = Some(index); + } else if chr == '-' { + self.dash = Some(index); + } else if chr.is_ascii_punctuation() { + self.punctuation = Some(index); + } else if glyphs.len() > 1 && is_cjk(glyphs[1].chr) { + self.pre_cjk = Some(index); + } + self.any = Some(index); + } + + fn word_boundary(&self) -> Option { + [self.space, self.cjk, self.pre_cjk] + .into_iter() + .max() + .flatten() + } + + fn has_good_candidate(&self, break_anywhere: bool) -> bool { + if break_anywhere { + self.any.is_some() + } else { + self.word_boundary().is_some() + } + } + + fn get(&self, break_anywhere: bool) -> Option { + if break_anywhere { + self.any + } else { + self.word_boundary() + .or(self.dash) + .or(self.punctuation) + .or(self.any) + } + } + + fn forget_before_idx(&mut self, index: usize) { + let Self { + space, + cjk, + pre_cjk, + dash, + punctuation, + any, + } = self; + if space.is_some_and(|s| s < index) { + *space = None; + } + if cjk.is_some_and(|s| s < index) { + *cjk = None; + } + if pre_cjk.is_some_and(|s| s < index) { + *pre_cjk = None; + } + if dash.is_some_and(|s| s < index) { + *dash = None; + } + if punctuation.is_some_and(|s| s < index) { + *punctuation = None; + } + if any.is_some_and(|s| s < index) { + *any = None; + } + } +} + +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + + use super::{super::*, *}; + + #[test] + fn test_zero_max_width() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default()); + layout_job.wrap.max_width = 0.0; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert_eq!(galley.rows.len(), 1); + } + + #[test] + fn test_truncate_with_newline() { + // No matter where we wrap, we should be appending the newline character. + + let pixels_per_point = 1.0; + + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let text_format = TextFormat { + font_id: FontId::monospace(12.0), + ..Default::default() + }; + + for text in ["Hello\nworld", "\nfoo"] { + for break_anywhere in [false, true] { + for max_width in [0.0, 5.0, 10.0, 20.0, f32::INFINITY] { + let mut layout_job = + LayoutJob::single_section(text.into(), text_format.clone()); + layout_job.wrap.max_width = max_width; + layout_job.wrap.max_rows = 1; + layout_job.wrap.break_anywhere = break_anywhere; + + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + + assert!(galley.elided); + assert_eq!(galley.rows.len(), 1); + let row_text = galley.rows[0].text(); + assert!( + row_text.ends_with('…'), + "Expected row to end with `…`, got {row_text:?} when line-breaking the text {text:?} with max_width {max_width} and break_anywhere {break_anywhere}.", + ); + } + } + } + + { + let mut layout_job = LayoutJob::single_section("Hello\nworld".into(), text_format); + layout_job.wrap.max_width = 50.0; + layout_job.wrap.max_rows = 1; + layout_job.wrap.break_anywhere = false; + + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + + assert!(galley.elided); + assert_eq!(galley.rows.len(), 1); + let row_text = galley.rows[0].text(); + assert_eq!(row_text, "Hello…"); + } + } + + #[test] + fn test_cjk() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section( + "日本語とEnglishの混在した文章".into(), + TextFormat::default(), + ); + layout_job.wrap.max_width = 90.0; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["日本語と", "Englishの混在", "した文章"] + ); + } + + #[test] + fn test_pre_cjk() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section( + "日本語とEnglishの混在した文章".into(), + TextFormat::default(), + ); + layout_job.wrap.max_width = 110.0; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["日本語とEnglish", "の混在した文章"] + ); + } + + #[test] + fn test_truncate_width() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = + LayoutJob::single_section("# DNA\nMore text".into(), TextFormat::default()); + layout_job.wrap.max_width = f32::INFINITY; + layout_job.wrap.max_rows = 1; + layout_job.round_output_to_gui = false; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert!(galley.elided); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["# DNA…"] + ); + let row = &galley.rows[0]; + assert_eq!(row.pos, Pos2::ZERO); + assert_eq!(row.rect().max.x, row.glyphs.last().unwrap().max_x()); + } + + #[test] + fn test_truncate_with_pixels_per_point() { + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + for pixels_per_point in [ + 0.33, 0.5, 0.67, 1.0, 1.25, 1.33, 1.5, 1.75, 2.0, 3.0, 4.0, 5.0, + ] { + for ch in ['W', 'A', 'n', 't', 'i'] { + let target_width = 50.0; + let text = (0..20).map(|_| ch).collect::(); + + let mut job = LayoutJob::single_section(text, TextFormat::default()); + job.wrap.max_width = target_width; + job.wrap.max_rows = 1; + let elided_galley = layout(&mut fonts, pixels_per_point, job.into()); + assert!(elided_galley.elided); + + let test_galley = layout( + &mut fonts, + pixels_per_point, + Arc::new(LayoutJob::single_section( + (0..elided_galley.rows[0].char_count_excluding_newline()) + .map(|_| ch) + .chain(std::iter::once('…')) + .collect::(), + TextFormat::default(), + )), + ); + + assert!(elided_galley.size().x >= 0.0); + assert!(elided_galley.size().x <= target_width); + assert!(test_galley.size().x > target_width); + } + } + } + + #[test] + fn test_empty_row() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let font_id = FontId::default(); + let font_height = fonts + .font(&font_id.family) + .styled_metrics(pixels_per_point, font_id.size, &VariationCoords::default()) + .row_height; + + let job = LayoutJob::simple(String::new(), font_id, Color32::WHITE, f32::INFINITY); + + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1, "Expected one row"); + assert_eq!( + galley.rows[0].row.glyphs.len(), + 0, + "Expected no glyphs in the empty row" + ); + assert_eq!( + galley.size(), + Vec2::new(0.0, font_height.round()), + "Unexpected galley size" + ); + assert_eq!( + galley.intrinsic_size(), + Vec2::new(0.0, font_height.round()), + "Unexpected intrinsic size" + ); + } + + #[test] + fn test_end_with_newline() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let font_id = FontId::default(); + let font_height = fonts + .font(&font_id.family) + .styled_metrics(pixels_per_point, font_id.size, &VariationCoords::default()) + .row_height; + + let job = LayoutJob::simple("Hi!\n".to_owned(), font_id, Color32::WHITE, f32::INFINITY); + + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 2, "Expected two rows"); + assert_eq!( + galley.rows[1].row.glyphs.len(), + 0, + "Expected no glyphs in the empty row" + ); + assert_eq!( + galley.size().round(), + Vec2::new(17.0, font_height.round() * 2.0), + "Unexpected galley size" + ); + assert_eq!( + galley.intrinsic_size().round(), + Vec2::new(17.0, font_height.round() * 2.0), + "Unexpected intrinsic size" + ); + } +} diff --git a/vendor/epaint/src/text/text_layout_types.rs b/vendor/epaint/src/text/text_layout_types.rs new file mode 100644 index 0000000..5c6c50a --- /dev/null +++ b/vendor/epaint/src/text/text_layout_types.rs @@ -0,0 +1,1342 @@ +use std::sync::Arc; +use std::{ops::Range, str::FromStr as _}; + +use super::{ + cursor::{CCursor, LayoutCursor}, + font::UvRect, +}; +use crate::{Color32, FontId, Mesh, Stroke, text::FontsView}; +use emath::{Align, GuiRounding as _, NumExt as _, OrderedFloat, Pos2, Rect, Vec2, pos2, vec2}; +pub use font_types::Tag; +use smallvec::SmallVec; + +/// Describes the task of laying out text. +/// +/// This supports mixing different fonts, color and formats (underline etc). +/// +/// Pass this to [`crate::FontsView::layout_job`] or [`crate::text::layout`]. +/// +/// ## Example: +/// ``` +/// use epaint::{Color32, text::{LayoutJob, TextFormat}, FontFamily, FontId}; +/// +/// let mut job = LayoutJob::default(); +/// job.append( +/// "Hello ", +/// 0.0, +/// TextFormat { +/// font_id: FontId::new(14.0, FontFamily::Proportional), +/// color: Color32::WHITE, +/// ..Default::default() +/// }, +/// ); +/// job.append( +/// "World!", +/// 0.0, +/// TextFormat { +/// font_id: FontId::new(14.0, FontFamily::Monospace), +/// color: Color32::BLACK, +/// ..Default::default() +/// }, +/// ); +/// ``` +/// +/// As you can see, constructing a [`LayoutJob`] is currently a lot of work. +/// It would be nice to have a helper macro for it! +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct LayoutJob { + /// The complete text of this job, referenced by [`LayoutSection`]. + pub text: String, + + /// The different section, which can have different fonts, colors, etc. + pub sections: Vec, + + /// Controls the text wrapping and elision. + pub wrap: TextWrapping, + + /// The first row must be at least this high. + /// This is in case we lay out text that is the continuation + /// of some earlier text (sharing the same row), + /// in which case this will be the height of the earlier text. + /// In other cases, set this to `0.0`. + pub first_row_min_height: f32, + + /// If `true`, all `\n` characters will result in a new _paragraph_, + /// starting on a new row. + /// + /// If `false`, all `\n` characters will be ignored + /// and show up as the replacement character. + /// + /// Default: `true`. + pub break_on_newline: bool, + + /// How to horizontally align the text (`Align::LEFT`, `Align::Center`, `Align::RIGHT`). + pub halign: Align, + + /// Justify text so that word-wrapped rows fill the whole [`TextWrapping::max_width`]. + pub justify: bool, + + /// Round output sizes using [`emath::GuiRounding`], to avoid rounding errors in layout code. + pub round_output_to_gui: bool, +} + +impl Default for LayoutJob { + #[inline] + fn default() -> Self { + Self { + text: Default::default(), + sections: Default::default(), + wrap: Default::default(), + first_row_min_height: 0.0, + break_on_newline: true, + halign: Align::LEFT, + justify: false, + round_output_to_gui: true, + } + } +} + +impl LayoutJob { + /// Break on `\n` and at the given wrap width. + #[inline] + pub fn simple(text: String, font_id: FontId, color: Color32, wrap_width: f32) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: 0..text.len(), + format: TextFormat::simple(font_id, color), + }], + text, + wrap: TextWrapping { + max_width: wrap_width, + ..Default::default() + }, + break_on_newline: true, + ..Default::default() + } + } + + /// Break on `\n` + #[inline] + pub fn simple_format(text: String, format: TextFormat) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: 0..text.len(), + format, + }], + text, + break_on_newline: true, + ..Default::default() + } + } + + /// Does not break on `\n`, but shows the replacement character instead. + #[inline] + pub fn simple_singleline(text: String, font_id: FontId, color: Color32) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: 0..text.len(), + format: TextFormat::simple(font_id, color), + }], + text, + wrap: Default::default(), + break_on_newline: false, + ..Default::default() + } + } + + #[inline] + pub fn single_section(text: String, format: TextFormat) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: 0..text.len(), + format, + }], + text, + wrap: Default::default(), + break_on_newline: true, + ..Default::default() + } + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.sections.is_empty() + } + + /// Helper for adding a new section when building a [`LayoutJob`]. + pub fn append(&mut self, text: &str, leading_space: f32, format: TextFormat) { + let start = self.text.len(); + self.text += text; + let byte_range = start..self.text.len(); + self.sections.push(LayoutSection { + leading_space, + byte_range, + format, + }); + } + + /// The height of the tallest font used in the job. + /// + /// Returns a value rounded to [`emath::GUI_ROUNDING`]. + pub fn font_height(&self, fonts: &mut FontsView<'_>) -> f32 { + let mut max_height = 0.0_f32; + for section in &self.sections { + max_height = max_height.max(fonts.row_height(§ion.format.font_id)); + } + max_height + } + + /// The wrap with, with a small margin in some cases. + pub fn effective_wrap_width(&self) -> f32 { + if self.round_output_to_gui { + // On a previous pass we may have rounded down by at most 0.5 and reported that as a width. + // egui may then set that width as the max width for subsequent frames, and it is important + // that we then don't wrap earlier. + self.wrap.max_width + 0.5 + } else { + self.wrap.max_width + } + } +} + +impl std::hash::Hash for LayoutJob { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + text, + sections, + wrap, + first_row_min_height, + break_on_newline, + halign, + justify, + round_output_to_gui, + } = self; + + text.hash(state); + sections.hash(state); + wrap.hash(state); + emath::OrderedFloat(*first_row_min_height).hash(state); + break_on_newline.hash(state); + halign.hash(state); + justify.hash(state); + round_output_to_gui.hash(state); + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct LayoutSection { + /// Can be used for first row indentation. + pub leading_space: f32, + + /// Range into the galley text + pub byte_range: Range, + + pub format: TextFormat, +} + +impl std::hash::Hash for LayoutSection { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + leading_space, + byte_range, + format, + } = self; + OrderedFloat(*leading_space).hash(state); + byte_range.hash(state); + format.hash(state); + } +} + +// ---------------------------------------------------------------------------- + +/// Helper trait for all types that can be parsed as a [`font_types::Tag`]. +pub trait IntoTag { + fn into_tag(self) -> font_types::Tag; +} + +impl IntoTag for font_types::Tag { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + self + } +} + +impl IntoTag for u32 { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::from_u32(self) + } +} + +impl IntoTag for [u8; 4] { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::new_checked(&self).expect("Invalid variation axis tag") + } +} + +impl IntoTag for &[u8; 4] { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::new_checked(self).expect("Invalid variation axis tag") + } +} + +impl IntoTag for &str { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::from_str(self).expect("Invalid variation axis tag") + } +} + +/// List of font variation coordinates by axis tag. If more than one coordinate for a given axis is provided, the last +/// one added is used. +#[derive(Clone, Debug, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct VariationCoords(SmallVec<[(font_types::Tag, f32); 2]>); + +impl VariationCoords { + /// Create a list of variation coordinates from a sequence of (tag, value) pairs. + /// + /// ## Example: + /// ``` + /// use epaint::text::VariationCoords; + /// + /// let coords = VariationCoords::new([ + /// (b"wght", 500.0), + /// (b"wdth", 75.0), + /// ]); + /// ``` + pub fn new(values: impl IntoIterator) -> Self { + Self(values.into_iter().map(|(t, c)| (t.into_tag(), c)).collect()) + } + + /// Add a variation coordinate to the list. + #[inline(always)] + pub fn push(&mut self, tag: impl IntoTag, coord: f32) { + self.0.push((tag.into_tag(), coord)); + } + + /// Remove the coordinate at the given index. + pub fn remove(&mut self, index: usize) { + self.0.remove(index); + } + + pub fn clear(&mut self) { + self.0.clear(); + } +} + +impl AsRef<[(font_types::Tag, f32)]> for VariationCoords { + #[inline(always)] + fn as_ref(&self) -> &[(font_types::Tag, f32)] { + &self.0 + } +} + +impl AsMut<[(font_types::Tag, f32)]> for VariationCoords { + fn as_mut(&mut self) -> &mut [(font_types::Tag, f32)] { + &mut self.0 + } +} + +impl std::hash::Hash for VariationCoords { + fn hash(&self, state: &mut H) { + self.0.len().hash(state); + for (tag, coord) in &self.0 { + tag.hash(state); + OrderedFloat(*coord).hash(state); + } + } +} + +/// Formatting option for a section of text. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextFormat { + pub font_id: FontId, + + /// Extra spacing between letters, in points. + /// + /// Default: 0.0. + pub extra_letter_spacing: f32, + + /// Explicit line height of the text in points. + /// + /// This is the distance between the bottom row of two subsequent lines of text. + /// + /// If `None` (the default), the line height is determined by the font. + /// + /// For even text it is recommended you round this to an even number of _pixels_. + pub line_height: Option, + + /// Text color + pub color: Color32, + + pub background: Color32, + + /// Amount to expand background fill by. + /// + /// Default: 1.0 + pub expand_bg: f32, + + pub coords: VariationCoords, + + pub italics: bool, + + pub underline: Stroke, + + pub strikethrough: Stroke, + + /// If you use a small font and [`Align::TOP`] you + /// can get the effect of raised text. + /// + /// If you use a small font and [`Align::BOTTOM`] + /// you get the effect of a subscript. + /// + /// If you use [`Align::Center`], you get text that is centered + /// around a common center-line, which is nice when mixining emojis + /// and normal text in e.g. a button. + pub valign: Align, +} + +impl Default for TextFormat { + #[inline] + fn default() -> Self { + Self { + font_id: FontId::default(), + extra_letter_spacing: 0.0, + line_height: None, + color: Color32::GRAY, + background: Color32::TRANSPARENT, + expand_bg: 1.0, + coords: VariationCoords::default(), + italics: false, + underline: Stroke::NONE, + strikethrough: Stroke::NONE, + valign: Align::BOTTOM, + } + } +} + +impl std::hash::Hash for TextFormat { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + font_id, + extra_letter_spacing, + line_height, + color, + background, + expand_bg, + coords, + italics, + underline, + strikethrough, + valign, + } = self; + font_id.hash(state); + emath::OrderedFloat(*extra_letter_spacing).hash(state); + if let Some(line_height) = *line_height { + emath::OrderedFloat(line_height).hash(state); + } + color.hash(state); + background.hash(state); + emath::OrderedFloat(*expand_bg).hash(state); + coords.hash(state); + italics.hash(state); + underline.hash(state); + strikethrough.hash(state); + valign.hash(state); + } +} + +impl TextFormat { + #[inline] + pub fn simple(font_id: FontId, color: Color32) -> Self { + Self { + font_id, + color, + ..Default::default() + } + } +} + +// ---------------------------------------------------------------------------- + +/// How to wrap and elide text. +/// +/// This enum is used in high-level APIs where providing a [`TextWrapping`] is too verbose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextWrapMode { + /// The text should expand the `Ui` size when reaching its boundary. + Extend, + + /// The text should wrap to the next line when reaching the `Ui` boundary. + Wrap, + + /// The text should be elided using "…" when reaching the `Ui` boundary. + /// + /// Note that using [`TextWrapping`] and [`LayoutJob`] offers more control over the elision. + Truncate, +} + +/// Controls the text wrapping and elision of a [`LayoutJob`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextWrapping { + /// Wrap text so that no row is wider than this. + /// + /// If you would rather truncate text that doesn't fit, set [`Self::max_rows`] to `1`. + /// + /// Set `max_width` to [`f32::INFINITY`] to turn off wrapping and elision. + /// + /// Note that `\n` always produces a new row + /// if [`LayoutJob::break_on_newline`] is `true`. + pub max_width: f32, + + /// Maximum amount of rows the text galley should have. + /// + /// If this limit is reached, text will be truncated + /// and [`Self::overflow_character`] appended to the final row. + /// You can detect this by checking [`Galley::elided`]. + /// + /// If set to `0`, no text will be outputted. + /// + /// If set to `1`, a single row will be outputted, + /// eliding the text after [`Self::max_width`] is reached. + /// When you set `max_rows = 1`, it is recommended you also set [`Self::break_anywhere`] to `true`. + /// + /// Default value: `usize::MAX`. + pub max_rows: usize, + + /// If `true`: Allow breaking between any characters. + /// If `false` (default): prefer breaking between words, etc. + /// + /// NOTE: Due to limitations in the current implementation, + /// when truncating text using [`Self::max_rows`] the text may be truncated + /// in the middle of a word even if [`Self::break_anywhere`] is `false`. + /// Therefore it is recommended to set [`Self::break_anywhere`] to `true` + /// whenever [`Self::max_rows`] is set to `1`. + pub break_anywhere: bool, + + /// Character to use to represent elided text. + /// + /// The default is `…`. + /// + /// If not set, no character will be used (but the text will still be elided). + pub overflow_character: Option, +} + +impl std::hash::Hash for TextWrapping { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + max_width, + max_rows, + break_anywhere, + overflow_character, + } = self; + emath::OrderedFloat(*max_width).hash(state); + max_rows.hash(state); + break_anywhere.hash(state); + overflow_character.hash(state); + } +} + +impl Default for TextWrapping { + fn default() -> Self { + Self { + max_width: f32::INFINITY, + max_rows: usize::MAX, + break_anywhere: false, + overflow_character: Some('…'), + } + } +} + +impl TextWrapping { + /// Create a [`TextWrapping`] from a [`TextWrapMode`] and an available width. + pub fn from_wrap_mode_and_width(mode: TextWrapMode, max_width: f32) -> Self { + match mode { + TextWrapMode::Extend => Self::no_max_width(), + TextWrapMode::Wrap => Self::wrap_at_width(max_width), + TextWrapMode::Truncate => Self::truncate_at_width(max_width), + } + } + + /// A row can be as long as it need to be. + pub fn no_max_width() -> Self { + Self { + max_width: f32::INFINITY, + ..Default::default() + } + } + + /// A row can be at most `max_width` wide but can wrap in any number of lines. + pub fn wrap_at_width(max_width: f32) -> Self { + Self { + max_width, + ..Default::default() + } + } + + /// Elide text that doesn't fit within the given width, replaced with `…`. + pub fn truncate_at_width(max_width: f32) -> Self { + Self { + max_width, + max_rows: 1, + break_anywhere: true, + ..Default::default() + } + } +} + +// ---------------------------------------------------------------------------- + +/// Text that has been laid out, ready for painting. +/// +/// You can create a [`Galley`] using [`crate::FontsView::layout_job`]; +/// +/// Needs to be recreated if the underlying font atlas texture changes, which +/// happens under the following conditions: +/// - `pixels_per_point` or `max_texture_size` change. These parameters are set +/// in [`crate::text::Fonts::begin_pass`]. When using `egui` they are set +/// from `egui::InputState` and can change at any time. +/// - The atlas has become full. This can happen any time a new glyph is added +/// to the atlas, which in turn can happen any time new text is laid out. +/// +/// The name comes from typography, where a "galley" is a metal tray +/// containing a column of set type, usually the size of a page of text. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Galley { + /// The job that this galley is the result of. + /// Contains the original string and style sections. + pub job: Arc, + + /// Rows of text, from top to bottom, and their offsets. + /// + /// The number of characters in all rows sum up to `job.text.chars().count()` + /// unless [`Self::elided`] is `true`. + /// + /// Note that a paragraph (a piece of text separated with `\n`) + /// can be split up into multiple rows. + pub rows: Vec, + + /// Set to true the text was truncated due to [`TextWrapping::max_rows`]. + pub elided: bool, + + /// Bounding rect. + /// + /// `rect.top()` is always 0.0. + /// + /// With [`LayoutJob::halign`]: + /// * [`Align::LEFT`]: `rect.left() == 0.0` + /// * [`Align::Center`]: `rect.center() == 0.0` + /// * [`Align::RIGHT`]: `rect.right() == 0.0` + pub rect: Rect, + + /// Tight bounding box around all the meshes in all the rows. + /// Can be used for culling. + pub mesh_bounds: Rect, + + /// Total number of vertices in all the row meshes. + pub num_vertices: usize, + + /// Total number of indices in all the row meshes. + pub num_indices: usize, + + /// The number of physical pixels for each logical point. + /// Since this affects the layout, we keep track of it + /// so that we can warn if this has changed once we get to + /// tessellation. + pub pixels_per_point: f32, + + pub(crate) intrinsic_size: Vec2, +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PlacedRow { + /// The position of this [`Row`] relative to the galley. + /// + /// This is rounded to the closest _pixel_ in order to produce crisp, pixel-perfect text. + pub pos: Pos2, + + /// The underlying unpositioned [`Row`]. + pub row: Arc, + + /// If true, this [`PlacedRow`] came from a paragraph ending with a `\n`. + /// The `\n` itself is omitted from row's [`Row::glyphs`]. + /// A `\n` in the input text always creates a new [`PlacedRow`] below it, + /// so that text that ends with `\n` has an empty [`PlacedRow`] last. + /// This also implies that the last [`PlacedRow`] in a [`Galley`] always has `ends_with_newline == false`. + pub ends_with_newline: bool, +} + +impl PlacedRow { + /// Logical bounding rectangle on font heights etc. + /// + /// This ignores / includes the `LayoutSection::leading_space`. + pub fn rect(&self) -> Rect { + Rect::from_min_size(self.pos, self.row.size) + } + + /// Same as [`Self::rect`] but excluding the `LayoutSection::leading_space`. + pub fn rect_without_leading_space(&self) -> Rect { + let x = self.pos.x + self.glyphs.first().map_or(0.0, |g| g.pos.x); + let right = self.pos.x + self.size.x; + Rect::from_min_max( + Pos2::new(x, self.pos.y), + Pos2::new(right, self.pos.y + self.size.y), + ) + } +} + +impl std::ops::Deref for PlacedRow { + type Target = Row; + + fn deref(&self) -> &Self::Target { + &self.row + } +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Row { + /// This is included in case there are no glyphs. + /// + /// Only used during layout, then set to an invalid value in order to + /// enable the paragraph-concat optimization path without having to + /// adjust `section_index` when concatting. + pub(crate) section_index_at_start: u32, + + /// One for each `char`. + pub glyphs: Vec, + + /// Logical size based on font heights etc. + /// Includes leading and trailing whitespace. + pub size: Vec2, + + /// The mesh, ready to be rendered. + pub visuals: RowVisuals, +} + +/// The tessellated output of a row. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RowVisuals { + /// The tessellated text, using non-normalized (texel) UV coordinates. + /// That is, you need to divide the uv coordinates by the texture size. + pub mesh: Mesh, + + /// Bounds of the mesh, and can be used for culling. + /// Does NOT include leading or trailing whitespace glyphs!! + pub mesh_bounds: Rect, + + /// The number of triangle indices added before the first glyph triangle. + /// + /// This can be used to insert more triangles after the background but before the glyphs, + /// i.e. for text selection visualization. + pub glyph_index_start: usize, + + /// The range of vertices in the mesh that contain glyphs (as opposed to background, underlines, strikethorugh, etc). + /// + /// The glyph vertices comes after backgrounds (if any), but before any underlines and strikethrough. + pub glyph_vertex_range: Range, +} + +impl Default for RowVisuals { + fn default() -> Self { + Self { + mesh: Default::default(), + mesh_bounds: Rect::NOTHING, + glyph_index_start: 0, + glyph_vertex_range: 0..0, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Glyph { + /// The character this glyph represents. + pub chr: char, + + /// Baseline position, relative to the row. + /// Logical position: pos.y is the same for all chars of the same [`TextFormat`]. + pub pos: Pos2, + + /// Logical width of the glyph. + pub advance_width: f32, + + /// Height of this row of text. + /// + /// Usually same as [`Self::font_height`], + /// unless explicitly overridden by [`TextFormat::line_height`]. + pub line_height: f32, + + /// The ascent of this font. + pub font_ascent: f32, + + /// The row/line height of this font. + pub font_height: f32, + + /// The ascent of the sub-font within the font (`FontFace`). + pub font_face_ascent: f32, + + /// The row/line height of the sub-font within the font (`FontFace`). + pub font_face_height: f32, + + /// Position and size of the glyph in the font texture, in texels. + pub uv_rect: UvRect, + + /// Index into [`LayoutJob::sections`]. Decides color etc. + /// + /// Only used during layout, then set to an invalid value in order to + /// enable the paragraph-concat optimization path without having to + /// adjust `section_index` when concatting. + pub(crate) section_index: u32, + + /// Which is our first vertex in [`RowVisuals::mesh`]. + pub first_vertex: u32, +} + +impl Glyph { + #[inline] + pub fn size(&self) -> Vec2 { + Vec2::new(self.advance_width, self.line_height) + } + + #[inline] + pub fn max_x(&self) -> f32 { + self.pos.x + self.advance_width + } + + /// Same y range for all characters with the same [`TextFormat`]. + #[inline] + pub fn logical_rect(&self) -> Rect { + Rect::from_min_size(self.pos - vec2(0.0, self.font_ascent), self.size()) + } +} + +// ---------------------------------------------------------------------------- + +impl Row { + /// The text on this row, excluding the implicit `\n` if any. + pub fn text(&self) -> String { + self.glyphs.iter().map(|g| g.chr).collect() + } + + /// Excludes the implicit `\n` after the [`Row`], if any. + #[inline] + pub fn char_count_excluding_newline(&self) -> usize { + self.glyphs.len() + } + + /// Closest char at the desired x coordinate in row-relative coordinates. + /// Returns something in the range `[0, char_count_excluding_newline()]`. + pub fn char_at(&self, desired_x: f32) -> usize { + for (i, glyph) in self.glyphs.iter().enumerate() { + if desired_x < glyph.logical_rect().center().x { + return i; + } + } + self.char_count_excluding_newline() + } + + pub fn x_offset(&self, column: usize) -> f32 { + if let Some(glyph) = self.glyphs.get(column) { + glyph.pos.x + } else { + self.size.x + } + } + + #[inline] + pub fn height(&self) -> f32 { + self.size.y + } +} + +impl PlacedRow { + #[inline] + pub fn min_y(&self) -> f32 { + self.rect().top() + } + + #[inline] + pub fn max_y(&self) -> f32 { + self.rect().bottom() + } + + /// Includes the implicit `\n` after the [`PlacedRow`], if any. + #[inline] + pub fn char_count_including_newline(&self) -> usize { + self.row.glyphs.len() + (self.ends_with_newline as usize) + } +} + +impl Galley { + #[inline] + pub fn is_empty(&self) -> bool { + self.job.is_empty() + } + + /// The full, non-elided text of the input job. + #[inline] + pub fn text(&self) -> &str { + &self.job.text + } + + #[inline] + pub fn size(&self) -> Vec2 { + self.rect.size() + } + + /// This is the size that a non-wrapped, non-truncated, non-justified version of the text + /// would have. + /// + /// Useful for advanced layouting. + #[inline] + pub fn intrinsic_size(&self) -> Vec2 { + // We do the rounding here instead of in `round_output_to_gui` so that rounding + // errors don't accumulate when concatenating multiple galleys. + if self.job.round_output_to_gui { + self.intrinsic_size.round_ui() + } else { + self.intrinsic_size + } + } + + pub(crate) fn round_output_to_gui(&mut self) { + for placed_row in &mut self.rows { + // Optimization: only call `make_mut` if necessary (can cause a deep clone) + let rounded_size = placed_row.row.size.round_ui(); + if placed_row.row.size != rounded_size { + Arc::make_mut(&mut placed_row.row).size = rounded_size; + } + } + + let rect = &mut self.rect; + + let did_exceed_wrap_width_by_a_lot = rect.width() > self.job.wrap.max_width + 1.0; + + *rect = rect.round_ui(); + + if did_exceed_wrap_width_by_a_lot { + // If the user picked a too aggressive wrap width (e.g. more narrow than any individual glyph), + // we should let the user know by reporting that our width is wider than the wrap width. + } else { + // Make sure we don't report being wider than the wrap width the user picked: + rect.max.x = rect + .max + .x + .at_most(rect.min.x + self.job.wrap.max_width) + .floor_ui(); + } + } + + /// Append each galley under the previous one. + pub fn concat(job: Arc, galleys: &[Arc], pixels_per_point: f32) -> Self { + profiling::function_scope!(); + + let mut merged_galley = Self { + job, + rows: Vec::new(), + elided: false, + rect: Rect::ZERO, + mesh_bounds: Rect::NOTHING, + num_vertices: 0, + num_indices: 0, + pixels_per_point, + intrinsic_size: Vec2::ZERO, + }; + + for (i, galley) in galleys.iter().enumerate() { + let current_y_offset = merged_galley.rect.height(); + let is_last_galley = i + 1 == galleys.len(); + + merged_galley + .rows + .extend(galley.rows.iter().enumerate().map(|(row_idx, placed_row)| { + let new_pos = placed_row.pos + current_y_offset * Vec2::Y; + let new_pos = new_pos.round_to_pixels(pixels_per_point); + merged_galley.mesh_bounds |= + placed_row.visuals.mesh_bounds.translate(new_pos.to_vec2()); + merged_galley.rect |= Rect::from_min_size(new_pos, placed_row.size); + + let mut ends_with_newline = placed_row.ends_with_newline; + let is_last_row_in_galley = row_idx + 1 == galley.rows.len(); + // Since we remove the `\n` when splitting rows, we need to add it back here + ends_with_newline |= !is_last_galley && is_last_row_in_galley; + super::PlacedRow { + pos: new_pos, + row: Arc::clone(&placed_row.row), + ends_with_newline, + } + })); + + merged_galley.num_vertices += galley.num_vertices; + merged_galley.num_indices += galley.num_indices; + // Note that if `galley.elided` is true this will be the last `Galley` in + // the vector and the loop will end. + merged_galley.elided |= galley.elided; + merged_galley.intrinsic_size.x = + f32::max(merged_galley.intrinsic_size.x, galley.intrinsic_size.x); + merged_galley.intrinsic_size.y += galley.intrinsic_size.y; + } + + if merged_galley.job.round_output_to_gui { + merged_galley.round_output_to_gui(); + } + + merged_galley + } +} + +impl AsRef for Galley { + #[inline] + fn as_ref(&self) -> &str { + self.text() + } +} + +impl std::borrow::Borrow for Galley { + #[inline] + fn borrow(&self) -> &str { + self.text() + } +} + +impl std::ops::Deref for Galley { + type Target = str; + #[inline] + fn deref(&self) -> &str { + self.text() + } +} + +// ---------------------------------------------------------------------------- + +/// ## Physical positions +impl Galley { + /// Zero-width rect past the last character. + fn end_pos(&self) -> Rect { + if let Some(row) = self.rows.last() { + let x = row.rect().right(); + Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) + } else { + // Empty galley + Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0)) + } + } + + /// Returns a 0-width Rect. + pub fn pos_from_layout_cursor(&self, layout_cursor: &LayoutCursor) -> Rect { + let Some(row) = self.rows.get(layout_cursor.row) else { + return self.end_pos(); + }; + + let x = row.x_offset(layout_cursor.column) + row.pos.x; + Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) + } + + /// Returns a 0-width Rect. + pub fn pos_from_cursor(&self, cursor: CCursor) -> Rect { + self.pos_from_layout_cursor(&self.layout_from_cursor(cursor)) + } + + /// Cursor at the given position within the galley. + /// + /// A cursor above the galley is considered + /// same as a cursor at the start, + /// and a cursor below the galley is considered + /// same as a cursor at the end. + /// This allows implementing text-selection by dragging above/below the galley. + pub fn cursor_from_pos(&self, pos: Vec2) -> CCursor { + // Vertical margin around galley improves text selection UX + const VMARGIN: f32 = 5.0; + + if let Some(first_row) = self.rows.first() + && pos.y < first_row.min_y() - VMARGIN + { + return self.begin(); + } + if let Some(last_row) = self.rows.last() + && last_row.max_y() + VMARGIN < pos.y + { + return self.end(); + } + + let mut best_y_dist = f32::INFINITY; + let mut cursor = CCursor::default(); + + let mut ccursor_index = 0; + + for row in &self.rows { + let min_y = row.min_y(); + let max_y = row.max_y(); + + let is_pos_within_row = min_y <= pos.y && pos.y <= max_y; + let y_dist = (min_y - pos.y).abs().min((max_y - pos.y).abs()); + if is_pos_within_row || y_dist < best_y_dist { + best_y_dist = y_dist; + // char_at is `Row` not `PlacedRow` relative which means we have to subtract the pos. + let column = row.char_at(pos.x - row.pos.x); + let prefer_next_row = column < row.char_count_excluding_newline(); + cursor = CCursor { + index: ccursor_index + column, + prefer_next_row, + }; + + if is_pos_within_row { + return cursor; + } + } + ccursor_index += row.char_count_including_newline(); + } + + cursor + } +} + +/// ## Cursor positions +impl Galley { + /// Cursor to the first character. + /// + /// This is the same as [`CCursor::default`]. + #[inline] + #[expect(clippy::unused_self)] + pub fn begin(&self) -> CCursor { + CCursor::default() + } + + /// Cursor to one-past last character. + pub fn end(&self) -> CCursor { + if self.rows.is_empty() { + return Default::default(); + } + let mut ccursor = CCursor { + index: 0, + prefer_next_row: true, + }; + for row in &self.rows { + let row_char_count = row.char_count_including_newline(); + ccursor.index += row_char_count; + } + ccursor + } +} + +/// ## Cursor conversions +impl Galley { + // The returned cursor is clamped. + pub fn layout_from_cursor(&self, cursor: CCursor) -> LayoutCursor { + let prefer_next_row = cursor.prefer_next_row; + let mut ccursor_it = CCursor { + index: 0, + prefer_next_row, + }; + + for (row_nr, row) in self.rows.iter().enumerate() { + let row_char_count = row.char_count_excluding_newline(); + + if ccursor_it.index <= cursor.index && cursor.index <= ccursor_it.index + row_char_count + { + let column = cursor.index - ccursor_it.index; + + let select_next_row_instead = prefer_next_row + && !row.ends_with_newline + && column >= row.char_count_excluding_newline(); + if !select_next_row_instead { + return LayoutCursor { + row: row_nr, + column, + }; + } + } + ccursor_it.index += row.char_count_including_newline(); + } + debug_assert!(ccursor_it == self.end(), "Cursor out of bounds"); + + if let Some(last_row) = self.rows.last() { + LayoutCursor { + row: self.rows.len() - 1, + column: last_row.char_count_including_newline(), + } + } else { + Default::default() + } + } + + fn cursor_from_layout(&self, layout_cursor: LayoutCursor) -> CCursor { + if layout_cursor.row >= self.rows.len() { + return self.end(); + } + + let prefer_next_row = + layout_cursor.column < self.rows[layout_cursor.row].char_count_excluding_newline(); + let mut cursor_it = CCursor { + index: 0, + prefer_next_row, + }; + + for (row_nr, row) in self.rows.iter().enumerate() { + if row_nr == layout_cursor.row { + cursor_it.index += layout_cursor + .column + .at_most(row.char_count_excluding_newline()); + + return cursor_it; + } + cursor_it.index += row.char_count_including_newline(); + } + cursor_it + } +} + +/// ## Cursor positions +impl Galley { + #[expect(clippy::unused_self)] + pub fn cursor_left_one_character(&self, cursor: &CCursor) -> CCursor { + if cursor.index == 0 { + Default::default() + } else { + CCursor { + index: cursor.index - 1, + prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end. + } + } + } + + pub fn cursor_right_one_character(&self, cursor: &CCursor) -> CCursor { + CCursor { + index: (cursor.index + 1).min(self.end().index), + prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end. + } + } + + pub fn clamp_cursor(&self, cursor: &CCursor) -> CCursor { + self.cursor_from_layout(self.layout_from_cursor(*cursor)) + } + + pub fn cursor_up_one_row( + &self, + cursor: &CCursor, + h_pos: Option, + ) -> (CCursor, Option) { + let layout_cursor = self.layout_from_cursor(*cursor); + let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x); + if layout_cursor.row == 0 { + (CCursor::default(), None) + } else { + let new_row = layout_cursor.row - 1; + + let new_layout_cursor = { + // keep same X coord + // char_at is Row-relative, so subtract the row's position + let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x); + LayoutCursor { + row: new_row, + column, + } + }; + (self.cursor_from_layout(new_layout_cursor), Some(h_pos)) + } + } + + pub fn cursor_down_one_row( + &self, + cursor: &CCursor, + h_pos: Option, + ) -> (CCursor, Option) { + let layout_cursor = self.layout_from_cursor(*cursor); + let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x); + if layout_cursor.row + 1 < self.rows.len() { + let new_row = layout_cursor.row + 1; + + let new_layout_cursor = { + // keep same X coord + // char_at is Row-relative, so subtract the row's position + let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x); + LayoutCursor { + row: new_row, + column, + } + }; + + (self.cursor_from_layout(new_layout_cursor), Some(h_pos)) + } else { + (self.end(), None) + } + } + + pub fn cursor_begin_of_row(&self, cursor: &CCursor) -> CCursor { + let layout_cursor = self.layout_from_cursor(*cursor); + self.cursor_from_layout(LayoutCursor { + row: layout_cursor.row, + column: 0, + }) + } + + pub fn cursor_end_of_row(&self, cursor: &CCursor) -> CCursor { + let layout_cursor = self.layout_from_cursor(*cursor); + self.cursor_from_layout(LayoutCursor { + row: layout_cursor.row, + column: self.rows[layout_cursor.row].char_count_excluding_newline(), + }) + } + + pub fn cursor_begin_of_paragraph(&self, cursor: &CCursor) -> CCursor { + let mut layout_cursor = self.layout_from_cursor(*cursor); + layout_cursor.column = 0; + + loop { + let prev_row = layout_cursor + .row + .checked_sub(1) + .and_then(|row| self.rows.get(row)); + + let Some(prev_row) = prev_row else { + // This is the first row + break; + }; + + if prev_row.ends_with_newline { + break; + } + + layout_cursor.row -= 1; + } + + self.cursor_from_layout(layout_cursor) + } + + pub fn cursor_end_of_paragraph(&self, cursor: &CCursor) -> CCursor { + let mut layout_cursor = self.layout_from_cursor(*cursor); + loop { + let row = &self.rows[layout_cursor.row]; + if row.ends_with_newline || layout_cursor.row == self.rows.len() - 1 { + layout_cursor.column = row.char_count_excluding_newline(); + break; + } + + layout_cursor.row += 1; + } + + self.cursor_from_layout(layout_cursor) + } +} diff --git a/vendor/epaint/src/text/windows_directwrite.rs b/vendor/epaint/src/text/windows_directwrite.rs new file mode 100644 index 0000000..11db0e8 --- /dev/null +++ b/vendor/epaint/src/text/windows_directwrite.rs @@ -0,0 +1,341 @@ +#![allow(unsafe_code)] + +use std::{ + cell::RefCell, + fmt, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; + +use dwrote::{ + DWRITE_FONT_SIMULATIONS_NONE, DWRITE_GLYPH_RUN, DWRITE_MEASURING_MODE_NATURAL, + DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC, DWRITE_TEXTURE_ALIASED_1x1, FontFile, + GlyphRunAnalysis, +}; +use winapi::{ + Interface as _, + shared::winerror::E_POINTER, + um::{ + dwrite::{ + DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory, + IDWriteGlyphRunAnalysis, + }, + dwrite_1::DWRITE_TEXT_ANTIALIAS_MODE_GRAYSCALE, + dwrite_2::{DWRITE_GRID_FIT_MODE_ENABLED, IDWriteFactory2}, + unknwnbase::IUnknown, + }, +}; +use wio::com::ComPtr; + +use super::fonts::Blob; + +const MAX_CACHED_FONT_FACES: usize = 32; +const MAX_GLYPH_SIDE: i32 = 2048; + +static NEXT_FONT_ID: AtomicU64 = AtomicU64::new(1); + +thread_local! { + static FONT_FACES: RefCell> = const { RefCell::new(Vec::new()) }; + static DIRECTWRITE_FACTORY: Result, dwrote::HRESULT> = create_directwrite_factory(); +} + +struct CachedFontFace { + id: u64, + face: dwrote::FontFace, +} + +pub(super) struct DirectWriteFont { + id: u64, + bytes: Blob, + face_index: u32, + failure_reported: AtomicBool, +} + +impl DirectWriteFont { + pub(super) fn new(bytes: Blob, face_index: u32) -> Self { + Self { + id: NEXT_FONT_ID.fetch_add(1, Ordering::Relaxed), + bytes, + face_index, + failure_reported: AtomicBool::new(false), + } + } + + pub(super) fn rasterize( + &self, + glyph_id: skrifa::GlyphId, + em_size_pixels: f32, + baseline_x_pixels: f32, + ) -> Option { + match self.try_rasterize(glyph_id, em_size_pixels, baseline_x_pixels) { + Ok(bitmap) => Some(bitmap), + Err(error) => { + if !self.failure_reported.swap(true, Ordering::Relaxed) { + log::warn!( + "DirectWrite glyph rasterization failed for this font; falling back to the portable rasterizer: {error}" + ); + } + None + } + } + } + + fn try_rasterize( + &self, + glyph_id: skrifa::GlyphId, + em_size_pixels: f32, + baseline_x_pixels: f32, + ) -> Result { + if !(em_size_pixels.is_finite() && em_size_pixels > 0.0) { + return Err(RasterizationError::InvalidEmSize(em_size_pixels)); + } + let glyph_index = glyph_id.to_u32(); + if glyph_index > u32::from(u16::MAX) { + return Err(RasterizationError::GlyphIndex(glyph_index)); + } + let glyph_index = glyph_index as u16; + + with_font_face(self, |face| { + let glyph_run = DWRITE_GLYPH_RUN { + // SAFETY: `face` owns a valid DirectWrite COM font-face pointer and + // remains alive until the glyph-run analysis has been created. + fontFace: unsafe { face.as_ptr() }, + fontEmSize: em_size_pixels, + glyphCount: 1, + glyphIndices: &glyph_index, + glyphAdvances: std::ptr::null(), + glyphOffsets: std::ptr::null(), + isSideways: 0, + bidiLevel: 0, + }; + let analysis = create_grayscale_analysis(&glyph_run, baseline_x_pixels)?; + let bounds = analysis + .get_alpha_texture_bounds(DWRITE_TEXTURE_ALIASED_1x1) + .map_err(|code| RasterizationError::DirectWrite("GetAlphaTextureBounds", code))?; + let width = bounds + .right + .checked_sub(bounds.left) + .ok_or(RasterizationError::InvalidBounds)?; + let height = bounds + .bottom + .checked_sub(bounds.top) + .ok_or(RasterizationError::InvalidBounds)?; + if width < 0 + || height < 0 + || width > MAX_GLYPH_SIDE + || height > MAX_GLYPH_SIDE + || width.checked_mul(height).is_none() + { + return Err(RasterizationError::InvalidBounds); + } + if width == 0 || height == 0 { + return Ok(GlyphBitmap { + left: bounds.left, + top: bounds.top, + width: 0, + height: 0, + coverage: Vec::new(), + }); + } + + let coverage = analysis + .create_alpha_texture(DWRITE_TEXTURE_ALIASED_1x1, bounds) + .map_err(|code| RasterizationError::DirectWrite("CreateAlphaTexture", code))?; + let width = width as usize; + let height = height as usize; + let expected_len = width + .checked_mul(height) + .ok_or(RasterizationError::InvalidBounds)?; + if coverage.len() != expected_len { + return Err(RasterizationError::UnexpectedCoverage { + expected: expected_len, + actual: coverage.len(), + }); + } + + Ok(GlyphBitmap { + left: bounds.left, + top: bounds.top, + width, + height, + coverage, + }) + }) + } +} + +fn create_directwrite_factory() -> Result, dwrote::HRESULT> { + let mut unknown = std::ptr::null_mut::(); + // SAFETY: DirectWrite receives the IID for `IDWriteFactory` and a valid, + // writable out-pointer. A successful call owns one COM reference. + let result = unsafe { + DWriteCreateFactory( + DWRITE_FACTORY_TYPE_SHARED, + &IDWriteFactory::uuidof(), + &mut unknown, + ) + }; + if result < 0 { + return Err(result); + } + if unknown.is_null() { + return Err(E_POINTER); + } + // SAFETY: the successful factory call returned an owned `IDWriteFactory` + // COM pointer through `unknown`; `ComPtr` assumes that reference exactly once. + let factory = unsafe { ComPtr::::from_raw(unknown.cast()) }; + factory.cast::() +} + +fn create_grayscale_analysis( + glyph_run: &DWRITE_GLYPH_RUN, + baseline_x_pixels: f32, +) -> Result { + DIRECTWRITE_FACTORY.with(|factory| { + let factory = factory.as_ref().map_err(|code| { + RasterizationError::DirectWrite("QueryInterface(IDWriteFactory2)", *code) + })?; + let mut analysis = std::ptr::null_mut::(); + // SAFETY: `factory` is an owned DirectWrite factory; `glyph_run` points + // to one live glyph index and a live font face for this call; the optional + // arrays and transform are null as permitted by DirectWrite; `analysis` + // is a valid writable out-pointer. + let result = unsafe { + factory.CreateGlyphRunAnalysis( + glyph_run, + std::ptr::null(), + DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC, + DWRITE_MEASURING_MODE_NATURAL, + DWRITE_GRID_FIT_MODE_ENABLED, + DWRITE_TEXT_ANTIALIAS_MODE_GRAYSCALE, + baseline_x_pixels, + 0.0, + &mut analysis, + ) + }; + if result < 0 { + return Err(RasterizationError::DirectWrite( + "IDWriteFactory2::CreateGlyphRunAnalysis", + result, + )); + } + if analysis.is_null() { + return Err(RasterizationError::DirectWrite( + "IDWriteFactory2::CreateGlyphRunAnalysis", + E_POINTER, + )); + } + // SAFETY: the successful DirectWrite call returned one owned + // `IDWriteGlyphRunAnalysis` COM reference through `analysis`. + let analysis = unsafe { ComPtr::from_raw(analysis) }; + Ok(GlyphRunAnalysis::take(analysis)) + }) +} + +pub(super) struct GlyphBitmap { + pub(super) left: i32, + pub(super) top: i32, + pub(super) width: usize, + pub(super) height: usize, + pub(super) coverage: Vec, +} + +fn with_font_face( + font: &DirectWriteFont, + use_face: impl FnOnce(&dwrote::FontFace) -> Result, +) -> Result { + FONT_FACES.with(|cache| { + let mut cache = cache.borrow_mut(); + let position = cache.iter().position(|entry| entry.id == font.id); + let position = if let Some(position) = position { + position + } else { + let file = FontFile::new_from_buffer(Arc::clone(&font.bytes)) + .ok_or(RasterizationError::FontFile)?; + let face = file + .create_face(font.face_index, DWRITE_FONT_SIMULATIONS_NONE) + .map_err(|code| RasterizationError::DirectWrite("CreateFontFace", code))?; + if cache.len() == MAX_CACHED_FONT_FACES { + cache.remove(0); + } + cache.push(CachedFontFace { id: font.id, face }); + cache.len() - 1 + }; + use_face(&cache[position].face) + }) +} + +#[derive(Debug)] +enum RasterizationError { + DirectWrite(&'static str, dwrote::HRESULT), + FontFile, + GlyphIndex(u32), + InvalidBounds, + InvalidEmSize(f32), + UnexpectedCoverage { expected: usize, actual: usize }, +} + +impl fmt::Display for RasterizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DirectWrite(operation, code) => { + write!( + formatter, + "{operation} returned HRESULT 0x{:08X}", + *code as u32 + ) + } + Self::FontFile => formatter.write_str("DirectWrite rejected the in-memory font"), + Self::GlyphIndex(index) => write!(formatter, "glyph index {index} exceeds u16"), + Self::InvalidBounds => formatter.write_str("DirectWrite returned invalid glyph bounds"), + Self::InvalidEmSize(size) => write!(formatter, "invalid glyph em size {size}"), + Self::UnexpectedCoverage { expected, actual } => write!( + formatter, + "DirectWrite returned {actual} coverage bytes; expected {expected}" + ), + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use skrifa::MetadataProvider as _; + + use super::*; + + #[test] + fn segoe_ui_produces_bounded_grayscale_coverage() { + let windows_directory = std::env::var_os("WINDIR").unwrap_or_else(|| "C:\\Windows".into()); + let bytes = std::fs::read( + PathBuf::from(windows_directory) + .join("Fonts") + .join("segoeui.ttf"), + ) + .expect("Windows must provide Segoe UI"); + let font_ref = skrifa::FontRef::new(&bytes).expect("Segoe UI must be a valid font"); + let glyph_id = font_ref + .charmap() + .map('A') + .expect("Segoe UI must contain Latin capital A"); + let font = DirectWriteFont::new(Arc::new(bytes), 0); + + let bitmap = font + .try_rasterize(glyph_id, 20.25, 0.25) + .expect("DirectWrite must rasterize Segoe UI"); + + assert!(bitmap.width > 0); + assert!(bitmap.height > 0); + assert_eq!(bitmap.coverage.len(), bitmap.width * bitmap.height); + assert!(bitmap.coverage.iter().any(|alpha| *alpha > 0)); + assert!( + bitmap + .coverage + .iter() + .any(|alpha| (1..u8::MAX).contains(alpha)) + ); + } +} diff --git a/vendor/epaint/src/texture_atlas.rs b/vendor/epaint/src/texture_atlas.rs new file mode 100644 index 0000000..9a77c14 --- /dev/null +++ b/vendor/epaint/src/texture_atlas.rs @@ -0,0 +1,278 @@ +use ecolor::Color32; +use emath::{Rect, remap_clamp}; + +use crate::{ColorImage, ImageDelta, TextOptions}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Rectu { + /// inclusive + min_x: usize, + + /// inclusive + min_y: usize, + + /// exclusive + max_x: usize, + + /// exclusive + max_y: usize, +} + +impl Rectu { + const NOTHING: Self = Self { + min_x: usize::MAX, + min_y: usize::MAX, + max_x: 0, + max_y: 0, + }; + const EVERYTHING: Self = Self { + min_x: 0, + min_y: 0, + max_x: usize::MAX, + max_y: usize::MAX, + }; +} + +#[derive(Copy, Clone, Debug)] +struct PrerasterizedDisc { + r: f32, + uv: Rectu, +} + +/// A pre-rasterized disc (filled circle), somewhere in the texture atlas. +#[derive(Copy, Clone, Debug)] +pub struct PreparedDisc { + /// The radius of this disc in texels. + pub r: f32, + + /// Width in texels. + pub w: f32, + + /// Where in the texture atlas the disc is. + /// Normalized in 0-1 range. + pub uv: Rect, +} + +/// Contains font data in an atlas, where each character occupied a small rectangle. +/// +/// More characters can be added, possibly expanding the texture. +#[derive(Clone)] +pub struct TextureAtlas { + image: ColorImage, + + /// What part of the image that is dirty + dirty: Rectu, + + /// Used for when allocating new rectangles. + cursor: (usize, usize), + + row_height: usize, + + /// Set when someone requested more space than was available. + overflowed: bool, + + /// pre-rasterized discs of radii `2^i`, where `i` is the index. + discs: Vec, + + /// Controls how to convert glyph coverage to alpha. + options: TextOptions, +} + +impl TextureAtlas { + pub fn new(size: [usize; 2], options: TextOptions) -> Self { + assert!(size[0] >= 1024, "Tiny texture atlas"); + let mut atlas = Self { + image: ColorImage::filled(size, Color32::TRANSPARENT), + dirty: Rectu::EVERYTHING, + cursor: (0, 0), + row_height: 0, + overflowed: false, + discs: vec![], // will be filled in below + options, + }; + + // Make the top left pixel fully white for `WHITE_UV`, i.e. painting something with solid color: + let (pos, image) = atlas.allocate((1, 1)); + assert_eq!( + pos, + (0, 0), + "Expected the first allocation to be at (0, 0), but was at {pos:?}" + ); + image[pos] = Color32::WHITE; + + // Allocate a series of anti-aliased discs used to render small filled circles: + // TODO(emilk): these circles can be packed A LOT better. + // In fact, the whole texture atlas could be packed a lot better. + // for r in [1, 2, 4, 8, 16, 32, 64] { + // let w = 2 * r + 3; + // let hw = w as i32 / 2; + const LARGEST_CIRCLE_RADIUS: f32 = 8.0; // keep small so that the initial texture atlas is small + for i in 0.. { + let r = 2.0_f32.powf(i as f32 / 2.0 - 1.0); + if r > LARGEST_CIRCLE_RADIUS { + break; + } + let hw = (r + 0.5).ceil() as i32; + let w = (2 * hw + 1) as usize; + let ((x, y), image) = atlas.allocate((w, w)); + for dx in -hw..=hw { + for dy in -hw..=hw { + let distance_to_center = ((dx * dx + dy * dy) as f32).sqrt(); + let coverage = + remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0); + image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = + options.alpha_from_coverage.color_from_coverage(coverage); + } + } + atlas.discs.push(PrerasterizedDisc { + r, + uv: Rectu { + min_x: x, + min_y: y, + max_x: x + w, + max_y: y + w, + }, + }); + } + + atlas + } + + pub fn options(&self) -> &TextOptions { + &self.options + } + + pub fn size(&self) -> [usize; 2] { + self.image.size + } + + /// Returns the locations and sizes of pre-rasterized discs (filled circles) in this atlas. + pub fn prepared_discs(&self) -> Vec { + let size = self.size(); + let inv_w = 1.0 / size[0] as f32; + let inv_h = 1.0 / size[1] as f32; + self.discs + .iter() + .map(|disc| { + let r = disc.r; + let Rectu { + min_x, + min_y, + max_x, + max_y, + } = disc.uv; + let w = max_x - min_x; + let uv = Rect::from_min_max( + emath::pos2(min_x as f32 * inv_w, min_y as f32 * inv_h), + emath::pos2(max_x as f32 * inv_w, max_y as f32 * inv_h), + ); + PreparedDisc { r, w: w as f32, uv } + }) + .collect() + } + + fn max_height(&self) -> usize { + // the initial width is set to the max size + self.image.height().max(self.image.width()) + } + + /// When this get high, it might be time to clear and start over! + pub fn fill_ratio(&self) -> f32 { + if self.overflowed { + 1.0 + } else { + (self.cursor.1 + self.row_height) as f32 / self.max_height() as f32 + } + } + + /// The texture options suitable for a font texture + #[inline] + pub fn texture_options() -> crate::textures::TextureOptions { + crate::textures::TextureOptions::LINEAR + } + + /// The full font atlas image. + #[inline] + pub fn image(&self) -> &ColorImage { + &self.image + } + + /// Call to get the change to the image since last call. + pub fn take_delta(&mut self) -> Option { + let texture_options = Self::texture_options(); + + let dirty = std::mem::replace(&mut self.dirty, Rectu::NOTHING); + if dirty == Rectu::NOTHING { + None + } else if dirty == Rectu::EVERYTHING { + Some(ImageDelta::full(self.image.clone(), texture_options)) + } else { + let pos = [dirty.min_x, dirty.min_y]; + let size = [dirty.max_x - dirty.min_x, dirty.max_y - dirty.min_y]; + let region = self.image.region_by_pixels(pos, size); + Some(ImageDelta::partial(pos, region, texture_options)) + } + } + + /// Returns the coordinates of where the rect ended up, + /// and invalidates the region. + pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut ColorImage) { + /// On some low-precision GPUs (my old iPad) characters get muddled up + /// if we don't add some empty pixels between the characters. + /// On modern high-precision GPUs this is not needed. + const PADDING: usize = 1; + + assert!( + w <= self.image.width(), + "Tried to allocate a {} wide glyph in a {} wide texture atlas", + w, + self.image.width() + ); + if self.cursor.0 + w > self.image.width() { + // New row: + self.cursor.0 = 0; + self.cursor.1 += self.row_height + PADDING; + self.row_height = 0; + } + + self.row_height = self.row_height.max(h); + + let required_height = self.cursor.1 + self.row_height; + + if required_height > self.max_height() { + // This is a bad place to be - we need to start reusing space :/ + + log::warn!("epaint texture atlas overflowed!"); + + self.cursor = (0, self.image.height() / 3); // Restart a bit down - the top of the atlas has too many important things in it + self.overflowed = true; // this will signal the user that we need to recreate the texture atlas next frame. + } else if resize_to_min_height(&mut self.image, required_height) { + self.dirty = Rectu::EVERYTHING; + } + + let pos = self.cursor; + self.cursor.0 += w + PADDING; + + self.dirty.min_x = self.dirty.min_x.min(pos.0); + self.dirty.min_y = self.dirty.min_y.min(pos.1); + self.dirty.max_x = self.dirty.max_x.max(pos.0 + w); + self.dirty.max_y = self.dirty.max_y.max(pos.1 + h); + + (pos, &mut self.image) + } +} + +fn resize_to_min_height(image: &mut ColorImage, required_height: usize) -> bool { + while required_height >= image.height() { + image.size[1] *= 2; // double the height + } + + if image.width() * image.height() > image.pixels.len() { + image + .pixels + .resize(image.width() * image.height(), Color32::TRANSPARENT); + true + } else { + false + } +} diff --git a/vendor/epaint/src/texture_handle.rs b/vendor/epaint/src/texture_handle.rs new file mode 100644 index 0000000..bbbf490 --- /dev/null +++ b/vendor/epaint/src/texture_handle.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; + +use crate::{ + ImageData, ImageDelta, TextureId, TextureManager, emath::NumExt as _, mutex::RwLock, + textures::TextureOptions, +}; + +/// Used to paint images. +/// +/// An _image_ is pixels stored in RAM, and represented using [`ImageData`]. +/// Before you can paint it however, you need to convert it to a _texture_. +/// +/// If you are using egui, use `egui::Context::load_texture`. +/// +/// The [`TextureHandle`] can be cloned cheaply. +/// When the last [`TextureHandle`] for specific texture is dropped, the texture is freed. +/// +/// See also [`TextureManager`]. +#[must_use] +pub struct TextureHandle { + tex_mngr: Arc>, + id: TextureId, +} + +impl Drop for TextureHandle { + fn drop(&mut self) { + self.tex_mngr.write().free(self.id); + } +} + +impl Clone for TextureHandle { + fn clone(&self) -> Self { + self.tex_mngr.write().retain(self.id); + Self { + tex_mngr: Arc::clone(&self.tex_mngr), + id: self.id, + } + } +} + +impl PartialEq for TextureHandle { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for TextureHandle {} + +impl std::hash::Hash for TextureHandle { + #[inline] + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +impl TextureHandle { + /// If you are using egui, use `egui::Context::load_texture` instead. + pub fn new(tex_mngr: Arc>, id: TextureId) -> Self { + Self { tex_mngr, id } + } + + #[inline] + pub fn id(&self) -> TextureId { + self.id + } + + /// Assign a new image to an existing texture. + #[expect(clippy::needless_pass_by_ref_mut)] // Intentionally hide interiority of mutability + pub fn set(&mut self, image: impl Into, options: TextureOptions) { + self.tex_mngr + .write() + .set(self.id, ImageDelta::full(image.into(), options)); + } + + /// Assign a new image to a subregion of the whole texture. + #[expect(clippy::needless_pass_by_ref_mut)] // Intentionally hide interiority of mutability + pub fn set_partial( + &mut self, + pos: [usize; 2], + image: impl Into, + options: TextureOptions, + ) { + self.tex_mngr + .write() + .set(self.id, ImageDelta::partial(pos, image.into(), options)); + } + + /// width x height + pub fn size(&self) -> [usize; 2] { + self.tex_mngr + .read() + .meta(self.id) + .map_or([0, 0], |tex| tex.size) + } + + /// width x height + pub fn size_vec2(&self) -> crate::Vec2 { + let [w, h] = self.size(); + crate::Vec2::new(w as f32, h as f32) + } + + /// `width x height x bytes_per_pixel` + pub fn byte_size(&self) -> usize { + self.tex_mngr + .read() + .meta(self.id) + .map_or(0, |tex| tex.bytes_used()) + } + + /// width / height + pub fn aspect_ratio(&self) -> f32 { + let [w, h] = self.size(); + w as f32 / h.at_least(1) as f32 + } + + /// Debug-name. + pub fn name(&self) -> String { + self.tex_mngr + .read() + .meta(self.id) + .map_or_else(|| "".to_owned(), |tex| tex.name.clone()) + } +} + +impl From<&TextureHandle> for TextureId { + #[inline(always)] + fn from(handle: &TextureHandle) -> Self { + handle.id() + } +} + +impl From<&mut TextureHandle> for TextureId { + #[inline(always)] + fn from(handle: &mut TextureHandle) -> Self { + handle.id() + } +} diff --git a/vendor/epaint/src/textures.rs b/vendor/epaint/src/textures.rs new file mode 100644 index 0000000..0944a90 --- /dev/null +++ b/vendor/epaint/src/textures.rs @@ -0,0 +1,332 @@ +use crate::{ImageData, ImageDelta, TextureId}; + +// ---------------------------------------------------------------------------- + +/// Low-level manager for allocating textures. +/// +/// Communicates with the painting subsystem using [`Self::take_delta`]. +#[derive(Default)] +pub struct TextureManager { + /// We allocate texture id:s linearly. + next_id: u64, + + /// Information about currently allocated textures. + metas: ahash::HashMap, + + delta: TexturesDelta, +} + +impl TextureManager { + /// Allocate a new texture. + /// + /// The given name can be useful for later debugging. + /// + /// The returned [`TextureId`] will be [`TextureId::Managed`], with an index + /// starting from zero and increasing with each call to [`Self::alloc`]. + /// + /// The first texture you allocate will be `TextureId::Managed(0) == TextureId::default()` and + /// MUST have a white pixel at (0,0) ([`crate::WHITE_UV`]). + /// + /// The texture is given a retain-count of `1`, requiring one call to [`Self::free`] to free it. + pub fn alloc(&mut self, name: String, image: ImageData, options: TextureOptions) -> TextureId { + let id = TextureId::Managed(self.next_id); + self.next_id += 1; + + self.metas.entry(id).or_insert_with(|| TextureMeta { + name, + size: image.size(), + bytes_per_pixel: image.bytes_per_pixel(), + retain_count: 1, + options, + }); + + self.delta.set.push((id, ImageDelta::full(image, options))); + id + } + + /// Assign a new image to an existing texture, + /// or update a region of it. + pub fn set(&mut self, id: TextureId, delta: ImageDelta) { + if let Some(meta) = self.metas.get_mut(&id) { + if let Some(pos) = delta.pos { + debug_assert!( + pos[0] + delta.image.width() <= meta.size[0] + && pos[1] + delta.image.height() <= meta.size[1], + "Partial texture update is outside the bounds of texture {id:?}", + ); + } else { + // whole update + meta.size = delta.image.size(); + meta.bytes_per_pixel = delta.image.bytes_per_pixel(); + // since we update the whole image, we can discard all old enqueued deltas + self.delta.set.retain(|(x, _)| x != &id); + } + self.delta.set.push((id, delta)); + } else { + debug_assert!(false, "Tried setting texture {id:?} which is not allocated"); + } + } + + /// Free an existing texture. + pub fn free(&mut self, id: TextureId) { + if let std::collections::hash_map::Entry::Occupied(mut entry) = self.metas.entry(id) { + let meta = entry.get_mut(); + meta.retain_count -= 1; + if meta.retain_count == 0 { + entry.remove(); + self.delta.free.push(id); + } + } else { + debug_assert!(false, "Tried freeing texture {id:?} which is not allocated"); + } + } + + /// Increase the retain-count of the given texture. + /// + /// For each time you call [`Self::retain`] you must call [`Self::free`] on additional time. + pub fn retain(&mut self, id: TextureId) { + if let Some(meta) = self.metas.get_mut(&id) { + meta.retain_count += 1; + } else { + debug_assert!( + false, + "Tried retaining texture {id:?} which is not allocated", + ); + } + } + + /// Take and reset changes since last frame. + /// + /// These should be applied to the painting subsystem each frame. + pub fn take_delta(&mut self) -> TexturesDelta { + std::mem::take(&mut self.delta) + } + + /// Get meta-data about a specific texture. + pub fn meta(&self, id: TextureId) -> Option<&TextureMeta> { + self.metas.get(&id) + } + + /// Get meta-data about all allocated textures in some arbitrary order. + pub fn allocated(&self) -> impl ExactSizeIterator { + self.metas.iter() + } + + /// Total number of allocated textures. + pub fn num_allocated(&self) -> usize { + self.metas.len() + } +} + +/// Meta-data about an allocated texture. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextureMeta { + /// A human-readable name useful for debugging. + pub name: String, + + /// width x height + pub size: [usize; 2], + + /// 4 or 1 + pub bytes_per_pixel: usize, + + /// Free when this reaches zero. + pub retain_count: usize, + + /// The texture filtering mode to use when rendering. + pub options: TextureOptions, +} + +impl TextureMeta { + /// Size in bytes. + /// width x height x [`Self::bytes_per_pixel`]. + pub fn bytes_used(&self) -> usize { + self.size[0] * self.size[1] * self.bytes_per_pixel + } +} + +// ---------------------------------------------------------------------------- + +/// How the texture texels are filtered. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextureOptions { + /// How to filter when magnifying (when texels are larger than pixels). + pub magnification: TextureFilter, + + /// How to filter when minifying (when texels are smaller than pixels). + pub minification: TextureFilter, + + /// How to wrap the texture when the texture coordinates are outside the [0, 1] range. + pub wrap_mode: TextureWrapMode, + + /// How to filter between texture mipmaps. + /// + /// Mipmaps ensures textures look smooth even when the texture is very small and pixels are much + /// larger than individual texels. + /// + /// # Notes + /// + /// - This may not be available on all backends (currently only `egui_glow`). + pub mipmap_mode: Option, +} + +impl TextureOptions { + /// Linear magnification and minification. + pub const LINEAR: Self = Self { + magnification: TextureFilter::Linear, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + }; + + /// Nearest magnification and minification. + pub const NEAREST: Self = Self { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Nearest, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + }; + + /// Linear magnification and minification, but with the texture repeated. + pub const LINEAR_REPEAT: Self = Self { + magnification: TextureFilter::Linear, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::Repeat, + mipmap_mode: None, + }; + + /// Linear magnification and minification, but with the texture mirrored and repeated. + pub const LINEAR_MIRRORED_REPEAT: Self = Self { + magnification: TextureFilter::Linear, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::MirroredRepeat, + mipmap_mode: None, + }; + + /// Nearest magnification and minification, but with the texture repeated. + pub const NEAREST_REPEAT: Self = Self { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Nearest, + wrap_mode: TextureWrapMode::Repeat, + mipmap_mode: None, + }; + + /// Nearest magnification and minification, but with the texture mirrored and repeated. + pub const NEAREST_MIRRORED_REPEAT: Self = Self { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Nearest, + wrap_mode: TextureWrapMode::MirroredRepeat, + mipmap_mode: None, + }; + + pub const fn with_mipmap_mode(self, mipmap_mode: Option) -> Self { + Self { + mipmap_mode, + ..self + } + } +} + +impl Default for TextureOptions { + /// The default is linear for both magnification and minification. + fn default() -> Self { + Self::LINEAR + } +} + +/// How the texture texels are filtered. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextureFilter { + /// Show the nearest pixel value. + /// + /// When zooming in you will get sharp, square pixels/texels. + /// When zooming out you will get a very crisp (and aliased) look. + Nearest, + + /// Linearly interpolate the nearest neighbors, creating a smoother look when zooming in and out. + Linear, +} + +/// Defines how textures are wrapped around objects when texture coordinates fall outside the [0, 1] range. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextureWrapMode { + /// Stretches the edge pixels to fill beyond the texture's bounds. + /// + /// This is what you want to use for a normal image in a GUI. + #[default] + ClampToEdge, + + /// Tiles the texture across the surface, repeating it horizontally and vertically. + Repeat, + + /// Mirrors the texture with each repetition, creating symmetrical tiling. + MirroredRepeat, +} + +// ---------------------------------------------------------------------------- + +/// What has been allocated and freed during the last period. +/// +/// These are commands given to the integration painter. +#[derive(Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[must_use = "The painter must take care of this"] +pub struct TexturesDelta { + /// New or changed textures. Apply before painting. + pub set: Vec<(TextureId, ImageDelta)>, + + /// Textures to free after painting. + pub free: Vec, +} + +impl TexturesDelta { + pub fn is_empty(&self) -> bool { + self.set.is_empty() && self.free.is_empty() + } + + pub fn append(&mut self, mut newer: Self) { + self.set.extend(newer.set); + self.free.append(&mut newer.free); + } + + pub fn clear(&mut self) { + self.set.clear(); + self.free.clear(); + } +} + +impl std::fmt::Debug for TexturesDelta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write as _; + + let mut debug_struct = f.debug_struct("TexturesDelta"); + if !self.set.is_empty() { + let mut string = String::new(); + for (tex_id, delta) in &self.set { + let size = delta.image.size(); + if let Some(pos) = delta.pos { + write!( + string, + "{:?} partial ([{} {}] - [{} {}]), ", + tex_id, + pos[0], + pos[1], + pos[0] + size[0], + pos[1] + size[1] + ) + .ok(); + } else { + write!(string, "{:?} full {}x{}, ", tex_id, size[0], size[1]).ok(); + } + } + debug_struct.field("set", &string); + } + if !self.free.is_empty() { + debug_struct.field("free", &self.free); + } + debug_struct.finish() + } +} diff --git a/vendor/epaint/src/util/mod.rs b/vendor/epaint/src/util/mod.rs new file mode 100644 index 0000000..4715766 --- /dev/null +++ b/vendor/epaint/src/util/mod.rs @@ -0,0 +1,12 @@ +/// Hash the given value with a predictable hasher. +#[inline] +pub fn hash(value: impl std::hash::Hash) -> u64 { + ahash::RandomState::with_seeds(1, 2, 3, 4).hash_one(value) +} + +/// Hash the given value with the given hasher. +#[inline] +pub fn hash_with(value: impl std::hash::Hash, mut hasher: impl std::hash::Hasher) -> u64 { + value.hash(&mut hasher); + hasher.finish() +} diff --git a/vendor/epaint/src/viewport.rs b/vendor/epaint/src/viewport.rs new file mode 100644 index 0000000..01011b5 --- /dev/null +++ b/vendor/epaint/src/viewport.rs @@ -0,0 +1,54 @@ +use crate::Rect; + +/// Size of the viewport in whole, physical pixels. +pub struct ViewportInPixels { + /// Physical pixel offset for left side of the viewport. + pub left_px: i32, + + /// Physical pixel offset for top side of the viewport. + pub top_px: i32, + + /// Physical pixel offset for bottom side of the viewport. + /// + /// This is what `glViewport`, `glScissor` etc expects for the y axis. + pub from_bottom_px: i32, + + /// Viewport width in physical pixels. + pub width_px: i32, + + /// Viewport height in physical pixels. + pub height_px: i32, +} + +impl ViewportInPixels { + /// Convert from ui points. + pub fn from_points(rect: &Rect, pixels_per_point: f32, screen_size_px: [u32; 2]) -> Self { + // Fractional pixel values for viewports are generally valid, but may cause sampling issues + // and rounding errors might cause us to get out of bounds. + + // Round: + let left_px = (pixels_per_point * rect.min.x).round() as i32; // inclusive + let top_px = (pixels_per_point * rect.min.y).round() as i32; // inclusive + let right_px = (pixels_per_point * rect.max.x).round() as i32; // exclusive + let bottom_px = (pixels_per_point * rect.max.y).round() as i32; // exclusive + + // Clamp to screen: + let screen_width = screen_size_px[0] as i32; + let screen_height = screen_size_px[1] as i32; + let left_px = left_px.clamp(0, screen_width); + let right_px = right_px.clamp(left_px, screen_width); + let top_px = top_px.clamp(0, screen_height); + let bottom_px = bottom_px.clamp(top_px, screen_height); + + let width_px = right_px - left_px; + let height_px = bottom_px - top_px; + + Self { + left_px, + top_px, + from_bottom_px: screen_height - height_px - top_px, + width_px, + height_px, + } + } +} From e0bc43aca76ae3d1aa05476d28daa39e600e9cfa Mon Sep 17 00:00:00 2001 From: GF Date: Sat, 5 Sep 2026 00:04:09 -0400 Subject: [PATCH 2/3] fix: resolve standalone annotation builds and Windows license notices --- Cargo.lock | 1 + Cargo.toml | 6 +++--- README.md | 24 ++++++++---------------- SUPPLY_CHAIN.md | 6 ++++++ THIRD_PARTY_NOTICES.md | 15 +++++++++++++++ deny.toml | 8 ++++++++ docs/RELEASE.md | 18 ++++++++++-------- 7 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/Cargo.lock b/Cargo.lock index c5b09c8..7f722b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5578,6 +5578,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wsi-dicom-annotations" 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 7398e33..18ea948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,10 +46,10 @@ sha2 = "0.10" uuid = { version = "1", features = ["serde", "v4"] } geo = { version = "0.33.1", default-features = false } rstar = "0.12.2" -wsi-dicom-annotations = "=0.1.2" +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" } diff --git a/README.md b/README.md index 3308751..b6dd003 100644 --- a/README.md +++ b/README.md @@ -133,22 +133,14 @@ 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 release gate - -The production manifest uses the exact registry version `wsi-dicom-annotations =0.1.2`. -CI checks out only the viewer. Version 0.1.2 must first be published with the shared -metadata reader, and Cargo.lock must then be refreshed from the registry and checked -with `cargo metadata --locked` and the full standalone CI matrix. This release gate -is currently pending; the local source validation does not prove a standalone build. - -For coordinated development before that release, use an explicit local Cargo overlay: - -```console -cargo --config 'patch.crates-io.wsi-dicom-annotations.path="../wsi-dicom-annotations"' test --workspace --all-targets --locked -``` - -Do not copy this source overlay into release CI or treat its path-based lock entry -as a published dependency checksum. +### 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 diff --git a/SUPPLY_CHAIN.md b/SUPPLY_CHAIN.md index 92bba98..b1fdf96 100644 --- a/SUPPLY_CHAIN.md +++ b/SUPPLY_CHAIN.md @@ -31,6 +31,12 @@ 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 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/deny.toml b/deny.toml index 91d7ffd..35dbe43 100644 --- a/deny.toml +++ b/deny.toml @@ -34,6 +34,13 @@ allow = [ ] confidence-threshold = 0.93 +# Windows DirectWrite binding used by the local epaint font backend. +# Keep approval specific to this reviewed version; see THIRD_PARTY_NOTICES.md. +[[licenses.exceptions]] +name = "dwrote" +version = "=0.11.5" +allow = ["MPL-2.0"] + [bans] multiple-versions = "warn" wildcards = "deny" @@ -46,4 +53,5 @@ allow-registry = ["https://github.com/rust-lang/crates.io-index"] allow-git = [ "https://github.com/frames-sg/j2k.git", "https://github.com/frames-sg/wsi-rs.git", + "https://github.com/frames-sg/wsi-dicom-annotations.git", ] diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 9482665..0bdc89e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -20,17 +20,15 @@ runtime validation must pass when those backends are included in a release. ## Reproducible source gate -The locked graph must resolve `wsi-rs` 0.6.0 at revision `b940ea94` and J2K -0.10.0 at revision `57b6af89` from their upstream Git repositories, plus -`wsi-dicom-annotations` 0.1.2 from crates.io. The annotations release must include -the shared `metadata::open_metadata_object` API. Publication of that version and -a registry lockfile refresh remain prerequisites recorded by the 4 September -2026 validation. +The locked graph must resolve `wsi-rs` 0.6.0 at revision `b940ea94`, J2K 0.10.0 +at revision `57b6af89`, and `wsi-dicom-annotations` 0.1.2 at revision `71851b4a` +from their upstream Git repositories. The annotation revision includes the shared +`metadata::open_metadata_object` API; the manifest pins its full 40-character SHA. CI and packaging must build without sibling checkouts or local source overrides. Run `cargo metadata --locked --format-version 1` from a clean checkout and confirm -that it leaves `Cargo.lock` unchanged. A successful build with a local annotations -overlay does not satisfy this gate. +that it leaves `Cargo.lock` unchanged. Moving annotations to crates.io requires a +matching owner publication and a reviewed registry lockfile refresh. ## Interactive performance gate @@ -56,6 +54,10 @@ Before describing a build as real-time or interactively responsive: ## Acceptance and packaging +- Include [third-party notices](../THIRD_PARTY_NOTICES.md) and the MPL 2.0 + license text with Windows artifacts, retaining access to the corresponding + `dwrote` source described in that notice. + - Open representative SVS, DICOM VL WSI, and raw JPEG 2000 fixtures; exercise fit, pan, zoom, facts pagination, measurement, annotation, and atomic GeoJSON replacement. From 673cc7bb1af6c3741641dac51cba7f23efe25692 Mon Sep 17 00:00:00 2001 From: GF Date: Sat, 5 Sep 2026 03:14:18 -0400 Subject: [PATCH 3/3] docs: align release guidance with the current dependency graph --- SUPPLY_CHAIN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SUPPLY_CHAIN.md b/SUPPLY_CHAIN.md index b1fdf96..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 @@ -40,7 +40,7 @@ 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