Compare commits
67 Commits
v3.13.0-be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bbe37ff388 | |||
|
|
f7612cdd0a | ||
|
|
125a174298 | ||
|
|
b880d2acf9 | ||
|
|
03e17d764a | ||
|
|
3d70db2d3b | ||
|
|
110b293aa0 | ||
|
|
70421225d7 | ||
|
|
3c0e245398 | ||
|
|
00bb77d940 | ||
|
|
01939e2236 | ||
|
|
f0e2f64397 | ||
|
|
43226d9191 | ||
|
|
dc9df56be4 | ||
|
|
9c76afa075 | ||
|
|
6d8a275171 | ||
|
|
1eee3e6119 | ||
|
|
2c54616136 | ||
|
|
9329dd5b55 | ||
|
|
75105ced04 | ||
|
|
b266c0a86a | ||
|
|
f79055f98e | ||
|
|
6b6761088b | ||
|
|
dbb8b1526e | ||
|
|
b996070583 | ||
|
|
1b6fa2194e | ||
|
|
84c7d17b13 | ||
|
|
76687cf496 | ||
|
|
3dd5c70cb2 | ||
|
|
3d9a17bc0c | ||
|
|
333d73e334 | ||
|
|
e381d22200 | ||
|
|
f8546ce0e0 | ||
|
|
973631b7e8 | ||
|
|
e0f42d4f1d | ||
|
|
2800f82e3c | ||
|
|
38214f26de | ||
|
|
cb6cdafd24 | ||
|
|
aa1ba771e0 | ||
|
|
d4153f12b2 | ||
|
|
71c50db9b5 | ||
|
|
4cdcd34a33 | ||
|
|
0dfd321326 | ||
|
|
0fcebe05c6 | ||
|
|
ac45a9497f | ||
|
|
03b180f185 | ||
|
|
e1bc625ab8 | ||
|
|
661ecb5b2e | ||
|
|
b7c8b865dd | ||
|
|
ee165b4d8e | ||
|
|
589d6a1506 | ||
|
|
95200ec6e8 | ||
|
|
8f6947b75d | ||
|
|
79cb2356e1 | ||
|
|
20632f97cf | ||
|
|
c205965f87 | ||
|
|
75488298cd | ||
|
|
b24f039735 | ||
|
|
1f6670c602 | ||
|
|
b9cde4981a | ||
|
|
fc994fe525 | ||
|
|
ada2da34f0 | ||
|
|
5955151ba1 | ||
|
|
419366bd25 | ||
|
|
b59eb30b0f | ||
|
|
57d03fc722 | ||
|
|
17e12d53e5 |
346
.agents/skills/ohif-test-agent/SKILL.md
Normal file
346
.agents/skills/ohif-test-agent/SKILL.md
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
---
|
||||||
|
name: ohif-test-agent
|
||||||
|
description: Generate runnable Playwright E2E tests for the OHIF Viewer using its custom fixture system, page objects, and normalized WebGL viewport coordinates. Use this skill whenever the user asks to write, add, modify, or debug tests in an OHIF/Viewers context — including vague asks like "write a test for X" when working in the OHIF repo, tests touching platform/app/tests/, or anything involving Cornerstone viewports, DICOM studies, measurements, segmentations, or OHIF modes/extensions. Prefer this skill over generic test-writing even if the user doesn't say "Playwright" or "E2E" explicitly.
|
||||||
|
---
|
||||||
|
|
||||||
|
# OHIF Test Agent
|
||||||
|
|
||||||
|
This skill teaches you to generate correct, runnable Playwright end-to-end tests for the OHIF Viewer. Follow the workflow below.
|
||||||
|
|
||||||
|
## Environment model
|
||||||
|
|
||||||
|
This package follows the agentskills.io SKILL.md convention. `SKILL.md` is the entire behavior contract — there is no separate runtime entrypoint.
|
||||||
|
|
||||||
|
## Workflow: how to write a new OHIF test
|
||||||
|
|
||||||
|
1. **Classify the feature.** What area does the test belong to — a measurement tool, segmentation hydration, contour panel interaction, MPR layout, crosshairs, tag browser, etc.? The area determines the mode, the StudyInstanceUID, and the seed spec you'll read.
|
||||||
|
2. **Read the seed spec.** Consult [references/patterns-by-feature.md](references/patterns-by-feature.md) to find the canonical existing spec for that area. Read it end-to-end before writing. This is the single most important step — OHIF specs follow consistent idioms that are easier to mimic than to reconstruct from first principles. (This mirrors Playwright's own agent guidance: use seed tests as the example for generated tests.)
|
||||||
|
3. **Scaffold from the template.** Start from [assets/spec-template.ts](assets/spec-template.ts) — or copy the seed spec and adapt.
|
||||||
|
4. **Look up specifics in the source, not from memory.** The reference files [page-objects.md](references/page-objects.md) and [utilities.md](references/utilities.md) capture the **stable rules** — fixture keys, import conventions, access idioms, the reasons certain things trip people up. They deliberately do not enumerate methods. For the current method surface or a utility's exact signature, open the relevant file under `tests/pages/` or `tests/utils/` — the source evolves, and the source is always right. The seed spec you picked in step 2 is usually the fastest second source, because it co-evolves with the API.
|
||||||
|
5. **Run the test when execution is available.** `pnpm run test:e2e:ci` runs the whole suite, but for iteration use `TEST_ENV=true pnpm exec playwright test tests/YourNew.spec.ts` (or the Playwright VS Code extension). Invoke Playwright directly for targeted flags; `pnpm run test:e2e -- ...` inserts a `--` separator that can prevent Playwright from parsing options such as `--update-snapshots` and `--reporter`.
|
||||||
|
6. **If runtime execution is unavailable, do static validation.** Validate import source, fixture keys, normalized viewport usage, UID/mode pairing, and hydration/tracking prompt handling. Then report clearly that execution was not performed.
|
||||||
|
7. **If it fails, triage before debugging.** Use [references/failure-triage.md](references/failure-triage.md) — most OHIF test failures are timing / hydration, not real regressions.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
OHIF uses Playwright with a custom fixture system. Tests are **not** vanilla Playwright — they import `test`, `expect`, and utilities from `./utils`, which re-exports an extended test runner that injects page objects.
|
||||||
|
|
||||||
|
```text
|
||||||
|
playwright.config.ts → Chromium-only, port 3335, data-cy as testId
|
||||||
|
└─ tests/utils/fixture.ts → Extends playwright-test-coverage, injects page objects
|
||||||
|
└─ tests/*.spec.ts → Each imports { test, expect, ... } from './utils'
|
||||||
|
├─ tests/pages/ → Page objects (ViewportPageObject, MainToolbarPageObject, …)
|
||||||
|
└─ tests/utils/ → Utilities (visitStudy, checkForScreenshot, screenShotPaths, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
Why the custom fixture matters: the page objects are created for each test and bound to the right Playwright `page`. If you `new ViewportPageObject(page)` manually, you skip the fixture wiring and some sub-objects won't resolve correctly.
|
||||||
|
|
||||||
|
### Import rule
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Correct
|
||||||
|
import { test, expect, visitStudy, checkForScreenshot, screenShotPaths } from './utils';
|
||||||
|
|
||||||
|
// Wrong — will compile but fixtures won't be injected
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
```
|
||||||
|
|
||||||
|
A few utilities (`press`, `downloadAsString`, the `assert*` helpers) are NOT re-exported from `./utils`. See [references/utilities.md](references/utilities.md) for the correct import path per utility.
|
||||||
|
|
||||||
|
## The viewport is WebGL
|
||||||
|
|
||||||
|
OHIF renders medical images onto a WebGL canvas. You cannot query *canvas* pixels by CSS selector. Use **normalized coordinates** (0–1 range, top-left is `{x:0, y:0}`) for clicks and drags, and **visual regression** (screenshot comparison) for canvas assertions. (Not everything in the viewport is canvas — some overlays render as SVG you *can* query via DOM, e.g. a vector overlay's color through `getSvgAttribute`. The canvas rule is about raster output painted onto the WebGL surface.)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const activeViewport = await viewportPageObject.active;
|
||||||
|
|
||||||
|
await activeViewport.normalizedClickAt([{ x: 0.5, y: 0.5 }]); // click center
|
||||||
|
await activeViewport.normalizedClickAt([{ x: 0.3, y: 0.3 }, { x: 0.7, y: 0.7 }]); // draw two points
|
||||||
|
await activeViewport.normalizedClickAt([{ x: 0.5, y: 0.5 }], 'right'); // right-click
|
||||||
|
await activeViewport.normalizedDragAt({
|
||||||
|
start: { x: 0.3, y: 0.3 },
|
||||||
|
end: { x: 0.7, y: 0.7 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pixel coordinates (`clickAt`, `doubleClickAt`) exist but prefer normalized for portability across viewport sizes.
|
||||||
|
|
||||||
|
For DOM-rendered state (panel counts, dialog text, overlay text values, button enabled states), assert directly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('17/');
|
||||||
|
const count = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
|
||||||
|
expect(count).toBe(1);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Study loading lifecycle
|
||||||
|
|
||||||
|
Every test follows this sequence. Skipping steps causes flakiness:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
|
||||||
|
const mode = 'viewer';
|
||||||
|
await visitStudy(page, studyInstanceUID, mode, 2000); // 2s delay is the community norm
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`visitStudy` navigates to `/{mode}/ohif?StudyInstanceUIDs={uid}`, waits for `domcontentloaded`, then `networkidle`, then the explicit delay. Default delay is `0`, but most specs pass `2000` to let the first render settle.
|
||||||
|
|
||||||
|
Delay by scene type (observed across the current suite, not a rule to apply blindly):
|
||||||
|
|
||||||
|
| Scene | Delay |
|
||||||
|
|-------|-------|
|
||||||
|
| Default (viewer mode, 2D/MPR/3D layouts, crosshairs) | `2000` |
|
||||||
|
| `mode: 'tmtv'` | `10000` — PET fusion + SUV calculation takes noticeably longer |
|
||||||
|
|
||||||
|
Start at the convention for your scene; ramp only if the test flakes on initial render. 3D layouts in `viewer` mode already stay at `2000` — the stabilization problem there is solved with `attemptAction(() => reduce3DViewportSize(page), 10, 100)`, not with a longer `visitStudy` delay.
|
||||||
|
|
||||||
|
If the study has DICOM SEG, RT, or SR data, OHIF asks whether to hydrate. Handle it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await leftPanelPageObject.loadSeriesByModality('SEG'); // or 'RTSTRUCT', 'SR'
|
||||||
|
await page.waitForTimeout(3000); // allow the prompt to appear
|
||||||
|
await expect(DOMOverlayPageObject.viewport.segmentationHydration.locator).toBeVisible();
|
||||||
|
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
|
||||||
|
```
|
||||||
|
|
||||||
|
The first measurement you create triggers a "start tracking?" prompt:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await DOMOverlayPageObject.viewport.measurementTracking.confirm.click();
|
||||||
|
```
|
||||||
|
|
||||||
|
For 3D / MPR scenes, wrap stabilization in `attemptAction(() => reduce3DViewportSize(page), 10, 100)` or insert a `page.waitForTimeout(...)` after the layout change before asserting.
|
||||||
|
|
||||||
|
## Wait for renders, don't sleep
|
||||||
|
|
||||||
|
`page.waitForTimeout(...)` after an action that re-renders the viewport is a smell. The viewports tell us when they're done — use that signal. `tests/utils/waitForViewportsRendered.ts` exposes three helpers, all barrel-exported from `./utils`:
|
||||||
|
|
||||||
|
- `waitForViewportRenderCycle(page)` — wait for the next full cycle: a viewport enters `needsRender`, then **all** viewports report `rendered` (and volumes are loaded, by default).
|
||||||
|
- `waitForViewportsRendered(page)` — only the second half: wait until all viewports are `rendered`. Use this when the action has already requested a render before you started waiting (e.g. a layout change or `loadSeriesByDescription`).
|
||||||
|
- `waitForAnyViewportNeedsRender(page)` — only the first half. Rarely needed directly.
|
||||||
|
|
||||||
|
The canonical idiom — **start the watcher before the action, await it after**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// start watching for the next render cycle
|
||||||
|
const viewportRenderCycle = waitForViewportRenderCycle(page);
|
||||||
|
|
||||||
|
await action(); // e.g. segmentationHydration.yes.click(), layoutSelection.MPR.click(), addSegmentation, etc.
|
||||||
|
|
||||||
|
// wait for the render to finish
|
||||||
|
await viewportRenderCycle;
|
||||||
|
|
||||||
|
await check(); // e.g. checkForScreenshot, count assertion, overlay text
|
||||||
|
```
|
||||||
|
|
||||||
|
Why "start before"? `waitForViewportRenderCycle` first waits for a viewport to enter `needsRender`. If you start it **after** the action, that transition may already be over and you'll hang until the timeout. Starting it first captures the cycle the action is about to trigger.
|
||||||
|
|
||||||
|
When to use which:
|
||||||
|
|
||||||
|
| Situation | Helper |
|
||||||
|
|-----------|--------|
|
||||||
|
| Click that triggers a re-render and you want to assert after | `waitForViewportRenderCycle(page)` started before the click |
|
||||||
|
| Layout switch / series load — render already in flight | `await waitForViewportsRendered(page)` after the call |
|
||||||
|
| Compose with another await (e.g. screenshot the same time as load) | Save the promise, `await` it later |
|
||||||
|
|
||||||
|
Replace patterns like this:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ Sleep-and-pray
|
||||||
|
await action();
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
await checkForScreenshot(...);
|
||||||
|
|
||||||
|
// ✅ Wait on the actual signal
|
||||||
|
const cycle = waitForViewportRenderCycle(page);
|
||||||
|
await action();
|
||||||
|
await cycle;
|
||||||
|
await checkForScreenshot(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
This shaves real wall-clock time off the suite and removes a class of flake (sleep too short → flake; sleep too long → slow). `tests/SEGHydrationFromMPR.spec.ts` is the canonical seed for this pattern.
|
||||||
|
|
||||||
|
Caveats:
|
||||||
|
- These helpers wait on Cornerstone viewport state. They won't help for purely DOM-side state (panel rows appearing, dialogs opening) — for those, prefer `expect(locator).toHaveCount(n)` / `toBeVisible()` which auto-retry, or `expect.toPass({ timeout })`.
|
||||||
|
- For some actions (hanging-protocol changes are the documented example) the viewport doesn't transition through `needsRender` synchronously — those still need a short `waitForTimeout`. The source comment in `waitForViewportsRendered.ts` calls this out.
|
||||||
|
|
||||||
|
## Fixture-injected page objects
|
||||||
|
|
||||||
|
Destructure these from the test function argument. **Never `new` them manually.**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('my test', async ({
|
||||||
|
page,
|
||||||
|
viewportPageObject,
|
||||||
|
mainToolbarPageObject,
|
||||||
|
leftPanelPageObject,
|
||||||
|
rightPanelPageObject,
|
||||||
|
DOMOverlayPageObject, // note the capital D — this matches the fixture key
|
||||||
|
notFoundStudyPageObject,
|
||||||
|
}) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
Two page objects are **not** fixture-injected:
|
||||||
|
- `DataOverlayPageObject` — reach via `viewportPageObject.getById(id).overlayMenu.dataOverlay`.
|
||||||
|
- `DicomTagBrowserPageObject` — reach via `DOMOverlayPageObject.dialog.dicomTagBrowser`.
|
||||||
|
|
||||||
|
See [references/page-objects.md](references/page-objects.md) for fixture rules and a map of which file covers which concern; read the `.ts` file under `tests/pages/` for the current method surface.
|
||||||
|
|
||||||
|
## When the control you need has no page object yet
|
||||||
|
|
||||||
|
A spec must not reach for `page.getByTestId(...)` / `getByRole(...)` directly for
|
||||||
|
application controls. If the button, menu, dialog, or field you need isn't already
|
||||||
|
exposed by a page object, **add it to one — or create a new page object — instead of
|
||||||
|
inlining a raw selector.** Raw selectors in a spec are the clearest sign a test was
|
||||||
|
written without reading the existing suite: they duplicate locators, bypass the
|
||||||
|
fixture wiring, and rot silently when the DOM changes.
|
||||||
|
|
||||||
|
Where new coverage goes:
|
||||||
|
|
||||||
|
| What you need | Where it belongs |
|
||||||
|
|---|---|
|
||||||
|
| A toolbar button or tool (Zoom, Pan) | a getter on `MainToolbarPageObject`, next to `crosshairs` / `measurementTools` |
|
||||||
|
| A menu, prompt, context menu, or small dialog | `DOMOverlayPageObject` |
|
||||||
|
| A substantial dialog with its own fields (User Preferences) | its **own** page object class, reached through `DOMOverlayPageObject` — follow `DicomTagBrowserPageObject` (`DOMOverlayPageObject.dialog.dicomTagBrowser`) |
|
||||||
|
| Each field/row inside that dialog | a method or sub-object on the dialog's page object — not a raw selector in the spec |
|
||||||
|
| A side-panel control | `LeftPanelPageObject` / `RightPanelPageObject` |
|
||||||
|
|
||||||
|
If the control has no `data-cy`, **add `data-cy` to the source component** and target
|
||||||
|
it — don't fall back to `getByRole`/text selectors, which are brittle and
|
||||||
|
locale-sensitive (`testIdAttribute` is `data-cy`, so `getByTestId('Zoom')` resolves
|
||||||
|
`[data-cy="Zoom"]`). Call out any `data-cy` you add so it ships in the same PR.
|
||||||
|
|
||||||
|
**Worked example — "set the Zoom hotkey in User Preferences":** the options menu, the
|
||||||
|
preferences dialog, each preference field, and the Zoom toolbar button should all be
|
||||||
|
page-object surface — e.g. `mainToolbarPageObject.zoom`,
|
||||||
|
`DOMOverlayPageObject.optionsMenu.settings.click()`,
|
||||||
|
`DOMOverlayPageObject.dialog.userPreferences.hotkey('Zoom').set('q')`. The spec then
|
||||||
|
reads as intent, not as a pile of `getByTestId` calls.
|
||||||
|
|
||||||
|
## Assert the effect, not just the attribute
|
||||||
|
|
||||||
|
Prefer asserting the actual rendered result over a proxy attribute. Activating Zoom and
|
||||||
|
checking `data-active="true"` confirms the *button* toggled — not that zoom works. Drag
|
||||||
|
on the viewport and assert the image actually zoomed (a viewport-scoped screenshot, or a
|
||||||
|
measurable state change). Attribute checks are fine as a secondary signal, not the whole
|
||||||
|
test.
|
||||||
|
|
||||||
|
## Visual regression
|
||||||
|
|
||||||
|
**Direction:** the suite is moving off *full-app* screenshots — not off screenshots
|
||||||
|
altogether. "Avoid screenshots" means: don't screenshot the whole page, and don't
|
||||||
|
screenshot something that has a faithful DOM/state signal. It does **not** mean avoid
|
||||||
|
screenshots for output that is genuinely canvas-only — for that output a screenshot is
|
||||||
|
the correct and required tool, and you should use it without apology. Older specs that
|
||||||
|
screenshot the whole page are the legacy pattern being phased out; viewport-scoped
|
||||||
|
screenshots are not.
|
||||||
|
|
||||||
|
### Screenshot vs. DOM assertion — how to choose
|
||||||
|
|
||||||
|
Reach for the cheapest *faithful* signal, in this order:
|
||||||
|
|
||||||
|
1. **A faithful DOM/SVG/state signal exists → assert on it.** Panel counts, dialog and
|
||||||
|
overlay text, enabled/disabled state, and any overlay that renders as SVG (a vector
|
||||||
|
overlay's color is readable via `getSvgAttribute`) all have a DOM representation — assert
|
||||||
|
on it directly, no screenshot.
|
||||||
|
2. **The thing under test is painted onto the WebGL canvas with no DOM representation → a
|
||||||
|
screenshot is correct and required.** Raster output on the canvas exposes no attribute to
|
||||||
|
read for a painted pixel. Scope a `checkForScreenshot` to the viewport (pane or grid) and
|
||||||
|
assert it — this is the right tool, not a last resort, whenever what you're verifying is
|
||||||
|
the rendered canvas itself.
|
||||||
|
3. **Never substitute a service/state read for a render assertion.** Reading a service's
|
||||||
|
state (any `window.services...`) asserts the *data model*, not the pixels the user sees —
|
||||||
|
it passes even when rendering is broken. `page.evaluate(() => window.services...)` is an
|
||||||
|
escape hatch for *setup*, not for *appearance* assertions.
|
||||||
|
|
||||||
|
For anything drawn onto the WebGL canvas with no DOM signal, compare a screenshot scoped to a specific viewport or the viewport grid:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
locator: viewportPageObject.grid, // scope to the viewport grid — not the whole page
|
||||||
|
screenshotPath: screenShotPaths.length.lengthDisplayedCorrectly,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`checkForScreenshot` retries up to 10 times at 500 ms intervals. Use `screenShotPaths.<category>.<name>` rather than a hand-typed string — the tree of valid keys lives in `tests/utils/screenShotPaths.ts`.
|
||||||
|
|
||||||
|
Rules (apply to all new screenshot assertions):
|
||||||
|
|
||||||
|
- **Use the object form.** The positional form is legacy; don't introduce it in new code, and don't treat existing positional-form usage as a pattern to copy.
|
||||||
|
- **Never screenshot the full app.** Full-page screenshots include panels, toolbars, and dialogs that drift independently of what's under test and make baselines fragile. Scope by passing a `locator` — `viewportPageObject.grid` for the grid, or a specific viewport pane. A bare `normalizedClip: { x: 0, y: 0, width: 1, height: 1 }` with no `locator` is **not** scoping — it clips to the full page. Use `normalizedClip` only to target a sub-region *of a locator* (e.g. a scrollbar strip). If you reach for `fullPage: true`, stop and pick a locator.
|
||||||
|
- **Do not tune `maxDiffPixelRatio` or `threshold`** to make a screenshot pass. If a baseline mismatches, regenerate it after a human review of the diff, or fix the underlying flake.
|
||||||
|
|
||||||
|
## Playwright config facts worth remembering
|
||||||
|
|
||||||
|
| Setting | Value | Why |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| `baseURL` | `http://localhost:3335` | OHIF e2e uses 3335, not 3000 |
|
||||||
|
| `testIdAttribute` | `data-cy` | `getByTestId(...)` maps to `[data-cy="..."]` |
|
||||||
|
| `browser` | Chromium only | Firefox/WebKit disabled (SharedArrayBuffer + stability) |
|
||||||
|
| `retries` | 3 in CI, 0 locally | Flaky rendering needs CI retries |
|
||||||
|
| `workers` | 6 in CI, undefined locally | Parallel execution |
|
||||||
|
| `globalTimeout` / `timeout` | 800_000 ms | Medical image loads are slow |
|
||||||
|
| `actionTimeout` | 10_000 ms | Per-action cap |
|
||||||
|
| `webServer.command` | `cross-env APP_CONFIG=config/e2e.js COVERAGE=true OHIF_PORT=3335 nyc yarn start` | e2e config + coverage |
|
||||||
|
|
||||||
|
## Test data: which study for which test
|
||||||
|
|
||||||
|
| StudyInstanceUID | Mode | Used for |
|
||||||
|
|------------------|------|----------|
|
||||||
|
| `1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5` | `viewer` | General measurements, annotations, context menu |
|
||||||
|
| `1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785` | `viewer` | 3D, MPR, crosshairs |
|
||||||
|
| `1.3.6.1.4.1.14519.5.2.1.256467663913010332776401703474716742458` | `viewer` or `segmentation` | Labelmap SEG |
|
||||||
|
| `1.2.840.113619.2.290.3.3767434740.226.1600859119.501` | `viewer` / `segmentation` / `tmtv` | RTSTRUCT/contour and TMTV |
|
||||||
|
| `1.3.6.1.4.1.14519.5.2.1.7695.4007.324475281161490036195179843543` | `viewer` | SR hydration |
|
||||||
|
|
||||||
|
Full mapping in [references/patterns-by-feature.md](references/patterns-by-feature.md). **Do not invent UIDs** — they must exist on the e2e data server.
|
||||||
|
|
||||||
|
## Rules (short, so they're actually read)
|
||||||
|
|
||||||
|
1. Import `test`, `expect`, and utilities from `./utils`, not `@playwright/test`.
|
||||||
|
2. Destructure fixture-injected page objects; don't `new` them.
|
||||||
|
3. Use normalized coordinates (0–1) for viewport interactions.
|
||||||
|
4. Use `visitStudy` with a real UID, correct mode, and a non-zero delay (2000 is conventional).
|
||||||
|
5. Handle hydration and measurement-tracking prompts where applicable.
|
||||||
|
6. Choose the faithful signal: DOM/SVG assertions where the rendered result has one (panels, dialogs, overlay text, SVG/vector overlays), a viewport-scoped screenshot when what you're verifying is canvas-only raster output, and never a `window.services` state read standing in for a render check. Screenshots use the object form, scoped via a `locator` — never the full app.
|
||||||
|
7. Use `data-cy` selectors (already wired via `testIdAttribute`).
|
||||||
|
8. When an assertion needs retry tolerance, wrap it in `expect.toPass({ timeout })`.
|
||||||
|
9. Test in the correct mode — segmentation tools aren't available in `viewer` mode.
|
||||||
|
10. If a utility isn't exported from `./utils`, import from the deeper path (see [references/utilities.md](references/utilities.md)).
|
||||||
|
11. After an action that re-renders the viewport, prefer `waitForViewportRenderCycle(page)` (started before the action) over `page.waitForTimeout(...)`. See the "Wait for renders, don't sleep" section.
|
||||||
|
12. Don't inline raw `page.getByTestId(...)` / `getByRole(...)` for app controls. If a control has no page object, create or augment one (see "When the control you need has no page object yet"), adding a `data-cy` to the source if needed.
|
||||||
|
13. Assert the actual effect (e.g. the image zoomed), not just a proxy attribute like `data-active`.
|
||||||
|
|
||||||
|
## Pre-output self-check (mandatory)
|
||||||
|
|
||||||
|
Before returning a generated OHIF test, confirm all items:
|
||||||
|
|
||||||
|
1. Imports `test`/`expect` from `./utils` (not `@playwright/test`).
|
||||||
|
2. Uses fixture-injected keys and exact casing (especially `DOMOverlayPageObject`).
|
||||||
|
3. Uses normalized viewport interactions (`normalizedClickAt` / `normalizedDragAt`) unless there is a strong reason otherwise.
|
||||||
|
4. Uses a valid canonical StudyInstanceUID and compatible mode.
|
||||||
|
5. Handles hydration or measurement tracking prompts when the workflow requires them.
|
||||||
|
6. Uses the faithful signal for each assertion — DOM/SVG where the result has a DOM representation, a viewport-scoped screenshot when what's verified is canvas-only raster output, and never a `window.services` state read in place of a render check. Any `checkForScreenshot` call uses the object form, scoped via a `locator` (viewport pane or grid) — no full-app screenshots.
|
||||||
|
7. Replaces `page.waitForTimeout(...)` after viewport-rendering actions with `waitForViewportRenderCycle(page)` (started before the action) — keeps `waitForTimeout` only for non-render waits like the hydration prompt in `beforeEach`.
|
||||||
|
8. If execution was skipped, states that explicitly and provides concrete run commands.
|
||||||
|
9. Every application control is reached through a page object — no raw `getByTestId`/`getByRole` in the spec for buttons, menus, dialogs, or fields. Any control not already covered was added to the right page object (or a new one), with a source `data-cy` if it lacked one.
|
||||||
|
10. Assertions verify the real effect where feasible (e.g. the image visibly zoomed), not only an attribute toggle.
|
||||||
|
|
||||||
|
## Output contract (for non-executing agents)
|
||||||
|
|
||||||
|
When execution cannot be performed in the current environment, the response should include:
|
||||||
|
|
||||||
|
1. The test code.
|
||||||
|
2. Assumptions made (if any).
|
||||||
|
3. Static checks that were verified.
|
||||||
|
4. What still must be run locally and exact commands to run.
|
||||||
|
|
||||||
|
## When to consult each reference
|
||||||
|
|
||||||
|
- **Before writing** → [references/patterns-by-feature.md](references/patterns-by-feature.md). Pick the seed spec for the feature area and read it. The seed spec is the closest thing to a live API example because it co-evolves with the code.
|
||||||
|
- **For a stable rule or idiom** (fixture keys, import paths, panel-access order, capital-D quirk, object-param convention) → [references/page-objects.md](references/page-objects.md), [references/utilities.md](references/utilities.md).
|
||||||
|
- **For a method name, property, or signature** → read the source under `tests/pages/` or `tests/utils/`. Do not rely on a static table for these; they drift as the code is refactored.
|
||||||
|
- **When a test fails** → [references/failure-triage.md](references/failure-triage.md).
|
||||||
54
.agents/skills/ohif-test-agent/assets/spec-template.ts
Normal file
54
.agents/skills/ohif-test-agent/assets/spec-template.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
checkForScreenshot,
|
||||||
|
expect,
|
||||||
|
screenShotPaths,
|
||||||
|
test,
|
||||||
|
visitStudy,
|
||||||
|
waitForViewportRenderCycle,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Pick the right UID + mode for your feature (see references/patterns-by-feature.md)
|
||||||
|
const studyInstanceUID = '1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5';
|
||||||
|
const mode = 'viewer';
|
||||||
|
await visitStudy(page, studyInstanceUID, mode, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('FEATURE NAME', () => {
|
||||||
|
test('describes the behaviour in one sentence', async ({
|
||||||
|
page,
|
||||||
|
viewportPageObject,
|
||||||
|
mainToolbarPageObject,
|
||||||
|
rightPanelPageObject,
|
||||||
|
DOMOverlayPageObject, // capital D on purpose
|
||||||
|
}) => {
|
||||||
|
// 1. Arrange — select a tool, open a panel, etc.
|
||||||
|
|
||||||
|
// 2. Act — interact with the viewport.
|
||||||
|
// Prefer normalized (0–1) coordinates:
|
||||||
|
// const activeViewport = await viewportPageObject.active;
|
||||||
|
// await activeViewport.normalizedClickAt([{ x: 0.3, y: 0.3 }, { x: 0.7, y: 0.7 }]);
|
||||||
|
|
||||||
|
// For actions that re-render the viewport, gate on the render cycle
|
||||||
|
// instead of sleeping — start the watcher BEFORE the action:
|
||||||
|
// const cycle = waitForViewportRenderCycle(page);
|
||||||
|
// await action();
|
||||||
|
// await cycle;
|
||||||
|
|
||||||
|
// 3. Handle prompts (first measurement prompts for tracking; SEG/RT/SR prompts for hydration)
|
||||||
|
// await DOMOverlayPageObject.viewport.measurementTracking.confirm.click();
|
||||||
|
|
||||||
|
// 4. Assert canvas output via visual regression — object form, scoped to the
|
||||||
|
// viewport via a locator (not the full page). normalizedClip is only for
|
||||||
|
// clipping to a sub-region of that locator.
|
||||||
|
// await checkForScreenshot({
|
||||||
|
// page,
|
||||||
|
// locator: viewportPageObject.grid,
|
||||||
|
// screenshotPath: screenShotPaths.YOUR_CATEGORY.YOUR_KEY,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// 5. Assert DOM state directly
|
||||||
|
// const count = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
|
||||||
|
// expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
.agents/skills/ohif-test-agent/references/failure-triage.md
Normal file
72
.agents/skills/ohif-test-agent/references/failure-triage.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Failure triage
|
||||||
|
|
||||||
|
Before debugging, classify. Most OHIF test failures are timing or hydration — not real regressions.
|
||||||
|
|
||||||
|
| Category | Symptom | Fix |
|
||||||
|
|----------|---------|-----|
|
||||||
|
| Timing | Element not visible, action timeout | Add / increase the `delay` param of `visitStudy`; for actions that re-render viewports, use `waitForViewportRenderCycle(page)` (started before the action) instead of `waitForTimeout`; wrap the assertion in `expect.toPass({ timeout })` |
|
||||||
|
| Selector | Element not found | Verify `data-cy` on the target; confirm the panel is open (`toggle()` / `select()` before interacting); check for capital `D` in `DOMOverlayPageObject` when destructuring |
|
||||||
|
| Hydration | Segmentation/RT/SR not interactive | Ensure the `segmentationHydration.yes.click()` fired; wait for an observable hydrated state such as measurement/segment rows or the target series overlay, then wait for the resulting viewport render |
|
||||||
|
| Data | Study not found, empty viewport | Confirm the UID is in the canonical list (see [patterns-by-feature.md](patterns-by-feature.md)); confirm the mode supports the feature (segmentation tools aren't in `viewer` mode) |
|
||||||
|
| Visual drift | Screenshot mismatch but feature works | Have a human review the diff, then regenerate the baseline with `TEST_ENV=true pnpm exec playwright test --update-snapshots`. Do not adjust `maxDiffPixelRatio` or `threshold` to make a failing screenshot pass. |
|
||||||
|
| Real regression | Feature is actually broken | Report as a bug — this is the test doing its job |
|
||||||
|
|
||||||
|
## Prefer render-cycle waits over sleeps
|
||||||
|
|
||||||
|
If you're tempted to add `await page.waitForTimeout(2000)` after an action, ask whether the action re-rendered the viewport. If it did, use:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const cycle = waitForViewportRenderCycle(page);
|
||||||
|
await action();
|
||||||
|
await cycle;
|
||||||
|
await check();
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher must be created **before** the action — it waits for `needsRender` first, and that transition is gone by the time the action returns. See the "Wait for renders, don't sleep" section in [SKILL.md](../SKILL.md) and `tests/SEGHydrationFromMPR.spec.ts`.
|
||||||
|
|
||||||
|
### When the cycle helper times out at `waitForAnyViewportNeedsRender`
|
||||||
|
|
||||||
|
Symptom: the test fails inside `waitForAnyViewportNeedsRender` after 5s, with the action having actually completed in the UI. The action just doesn't transition the viewport through `needsRender` synchronously. Known cases:
|
||||||
|
|
||||||
|
- Hanging-protocol changes.
|
||||||
|
- **RTSTRUCT / contour segmentation hydration confirm.** SEG (labelmap) hydration does fire `needsRender`; contour does not. The fix is to gate on the actual end-state — for hydration in `beforeEach`, `await expect(page.getByTestId('data-row')).toHaveCount(N)` is the right wait.
|
||||||
|
|
||||||
|
Don't react by raising the cycle's timeout — the transition isn't coming. Replace the cycle wrapper with an auto-retrying DOM/SVG assertion, or `expect.toPass({ timeout })` around the assertion block.
|
||||||
|
|
||||||
|
An immediate `waitForViewportsRendered(page)` can also return too early when a click dispatches work through an asynchronous state machine: the old viewport is already `rendered` before the new series or annotations are applied. In that case, first wait for the target state (for example, hydrated measurement rows or the expected series overlay), then call `waitForViewportsRendered(page)` to settle that state's render.
|
||||||
|
|
||||||
|
## The `toPass` pattern
|
||||||
|
|
||||||
|
When an assertion needs to wait for async render / propagation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await expect(async () => {
|
||||||
|
await rightPanelPageObject.labelMapSegmentationPanel.panel.segmentByText('Spleen').click();
|
||||||
|
await expect(activeViewport.overlayText.bottomRight.instanceNumber).toContainText('17/');
|
||||||
|
}).toPass({ timeout: 10_000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
`toPass` reruns the assertion block until it succeeds or the timeout expires — cleaner than a hand-rolled retry loop and surfaces the last failure reason if it times out.
|
||||||
|
|
||||||
|
## Common `DOMOverlayPageObject` mistake
|
||||||
|
|
||||||
|
The fixture key is capital-D `DOMOverlayPageObject`, not lowercase. If your destructure is silently `undefined`, check the casing.
|
||||||
|
|
||||||
|
## `press` import mistake
|
||||||
|
|
||||||
|
`press` is NOT re-exported from `./utils`. `import { press } from './utils'` resolves to `undefined` and fails at runtime. Use:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { press } from './utils/keyboardUtils';
|
||||||
|
await press({ page, key: 'ArrowDown', nTimes: 50 }); // object param, not (page, key)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visual regression iteration
|
||||||
|
|
||||||
|
Screenshots live under `tests/screenshots/chromium/<testFilePath>/`. To accept new output as the baseline:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TEST_ENV=true pnpm exec playwright test tests/YourSpec.spec.ts --update-snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the resulting PNGs carefully — an agent-accepted baseline that's subtly wrong is worse than a failing test.
|
||||||
120
.agents/skills/ohif-test-agent/references/page-objects.md
Normal file
120
.agents/skills/ohif-test-agent/references/page-objects.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# OHIF Page Object guide
|
||||||
|
|
||||||
|
> This file documents the **stable structural rules** of the page object system. For the current list of methods and properties on any class, **read the source under `tests/pages/`** — it is always authoritative, and it evolves as the product does. A static method table in a reference file goes stale the moment someone refactors; the source does not.
|
||||||
|
|
||||||
|
## How to discover the API of a page object
|
||||||
|
|
||||||
|
1. Find the relevant class in `tests/pages/`. File names match class names.
|
||||||
|
2. Read it end-to-end once — most are under a few hundred lines.
|
||||||
|
3. Some classes compose sub-objects (e.g. `RightPanelPageObject` holds a measurementsPanel, contourSegmentationPanel, labelMapSegmentationPanel, tmtvPanel, etc.). Those sub-objects usually live in the same file or a sibling under `tests/pages/`.
|
||||||
|
4. To see how a method is actually used, grep `tests/` or open the seed spec listed in [patterns-by-feature.md](patterns-by-feature.md). Real usage beats a synthesized signature every time.
|
||||||
|
|
||||||
|
Do not try to memorize a method surface from this file — it intentionally does not list one. It lists only the rules you cannot derive from the source by reading a single file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stable rules
|
||||||
|
|
||||||
|
### Fixture keys (case-sensitive)
|
||||||
|
|
||||||
|
These are injected via `tests/utils/fixture.ts`. Destructure them from the test function's first argument — do not `new` them, because the fixture wires sub-objects to the correct `page` and hand-constructed instances skip that wiring.
|
||||||
|
|
||||||
|
- `viewportPageObject`
|
||||||
|
- `mainToolbarPageObject`
|
||||||
|
- `leftPanelPageObject`
|
||||||
|
- `rightPanelPageObject`
|
||||||
|
- `DOMOverlayPageObject` — **capital D**. A silent `undefined` destructure is almost always a casing typo here.
|
||||||
|
- `notFoundStudyPageObject`
|
||||||
|
|
||||||
|
If the fixture file is updated and new keys are added, they will show up there first — check it if something feels missing.
|
||||||
|
|
||||||
|
### Non-fixture page objects
|
||||||
|
|
||||||
|
Some page object classes are not fixture-injected. They are reached through an injected fixture:
|
||||||
|
|
||||||
|
- `DicomTagBrowserPageObject` → via `DOMOverlayPageObject.dialog.dicomTagBrowser`
|
||||||
|
- `DataOverlayPageObject` → via `viewportPageObject.getById(viewportId).overlayMenu.dataOverlay`
|
||||||
|
|
||||||
|
Both can be constructed manually (`new DataOverlayPageObject(page)`) if a test really needs a fresh instance, but the accessor path is the idiomatic one.
|
||||||
|
|
||||||
|
### Viewport wrapper vs. viewport instance
|
||||||
|
|
||||||
|
`viewportPageObject` is a **wrapper**. You almost always want a specific viewport out of it first:
|
||||||
|
|
||||||
|
- `await viewportPageObject.active` — the currently focused viewport
|
||||||
|
- `viewportPageObject.getAll()` — every viewport in the grid
|
||||||
|
- `viewportPageObject.getNth(i)` — zero-indexed
|
||||||
|
- `viewportPageObject.getById(cornerstoneViewportId)` — e.g. `'default'`, `'ctAXIAL'`
|
||||||
|
|
||||||
|
The object these return is the one with `normalizedClickAt`, `normalizedDragAt`, `overlayText`, `nthAnnotation`, etc. Reach for the viewport instance first, then call methods on it.
|
||||||
|
|
||||||
|
### Panel access order
|
||||||
|
|
||||||
|
Every `rightPanelPageObject` sub-panel follows the same three-step idiom: **open the side panel, `.select()` the sub-panel tab, then interact with `.panel.*`**. Skipping either of the first two is the most common cause of "element not found".
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await rightPanelPageObject.toggle();
|
||||||
|
await rightPanelPageObject.measurementsPanel.select();
|
||||||
|
const count = await rightPanelPageObject.measurementsPanel.panel.getMeasurementCount();
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact row/action methods vary by panel — check the source file for the one you need.
|
||||||
|
|
||||||
|
### Layout identifiers are camelCase JS properties
|
||||||
|
|
||||||
|
`mainToolbarPageObject.layoutSelection.<layout>.click()` — access layouts by camelCase property name (e.g. `threeDFourUp`, `axialPrimary`), not with bracket-escaped DICOM-ish strings like `['3DFourUp']`. This is a convention enforced by how the class exposes its tools.
|
||||||
|
|
||||||
|
### Sub-tools auto-open their dropdown
|
||||||
|
|
||||||
|
Tools nested inside a toolbar dropdown (measurement tools, more tools, layouts) each expose a `.click()` that opens the parent menu for you. You almost never need to open the menu first. `await mainToolbarPageObject.measurementTools.length.click()` does both the expand and the select.
|
||||||
|
|
||||||
|
### When the control you need isn't covered yet — create or augment
|
||||||
|
|
||||||
|
The page objects here cover what the suite currently exercises. When your test needs a
|
||||||
|
control that isn't exposed, **extend the page object system rather than dropping a raw
|
||||||
|
`page.getByTestId(...)` into the spec.** A raw selector in a spec is the clearest tell
|
||||||
|
that the author didn't read the existing tests — it duplicates a locator that should
|
||||||
|
live in one place and bypasses the fixture wiring.
|
||||||
|
|
||||||
|
| What you need | Where it goes | Precedent to copy |
|
||||||
|
|---|---|---|
|
||||||
|
| A toolbar button/tool (Zoom, Pan) | a getter on `MainToolbarPageObject` | `crosshairs`, `measurementTools` |
|
||||||
|
| A menu / prompt / context menu / small dialog | `DOMOverlayPageObject` | `viewport.measurementTracking`, `dialog.input` |
|
||||||
|
| A large dialog with its own fields (User Preferences) | a **new** page object class reached via `DOMOverlayPageObject` | `DicomTagBrowserPageObject` (`DOMOverlayPageObject.dialog.dicomTagBrowser`) |
|
||||||
|
| Individual fields/rows in that dialog | methods/sub-objects on the dialog's page object | `DicomTagBrowserPageObject.seriesSelect` |
|
||||||
|
| Side-panel controls | `LeftPanelPageObject` / `RightPanelPageObject` | existing sub-panels |
|
||||||
|
|
||||||
|
Two more expectations:
|
||||||
|
|
||||||
|
- **Missing `data-cy`?** Add it to the source component and target it; don't fall back
|
||||||
|
to `getByRole`/text. `testIdAttribute` is `data-cy`, so `getByTestId('Zoom')` resolves
|
||||||
|
`[data-cy="Zoom"]`. Mention any attribute you added so it ships in the same PR.
|
||||||
|
- **Mirror the existing shape.** A new getter should look like its neighbors (return a
|
||||||
|
locator, or a small object with `.click()` etc.) so the new surface is
|
||||||
|
indistinguishable from what was already there.
|
||||||
|
|
||||||
|
Example — a "set the Zoom hotkey" test should make `mainToolbarPageObject.zoom`, an
|
||||||
|
options-menu accessor on `DOMOverlayPageObject`, and a `userPreferences` dialog page
|
||||||
|
object (with a per-hotkey field accessor) exist — then the spec reads as steps, not
|
||||||
|
selectors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page object map — what each class is *for*
|
||||||
|
|
||||||
|
This table exists to help you pick the right file to open, not to enumerate methods. Source of truth for any specific method remains the `.ts` file.
|
||||||
|
|
||||||
|
| Class | File | Covers |
|
||||||
|
|-------|------|--------|
|
||||||
|
| ViewportPageObject | `tests/pages/ViewportPageObject.ts` | Cornerstone viewports — clicks, drags, overlays, annotations, crosshairs |
|
||||||
|
| MainToolbarPageObject | `tests/pages/MainToolbarPageObject.ts` | Top toolbar — measurement tools, more tools, layouts, crosshairs, pan |
|
||||||
|
| LeftPanelPageObject | `tests/pages/LeftPanelPageObject.ts` | Study browser — thumbnails, load by modality or description |
|
||||||
|
| RightPanelPageObject | `tests/pages/RightPanelPageObject.ts` | Side panels — measurements, contour seg, labelmap seg, TMTV, microscopy |
|
||||||
|
| DOMOverlayPageObject | `tests/pages/DOMOverlayPageObject.ts` | DOM overlays — dialogs, hydration/tracking prompts, context menus, tag-browser accessor |
|
||||||
|
| NotFoundStudyPageObject | `tests/pages/NotFoundStudyPageObject.ts` | Study-not-found error page |
|
||||||
|
| DicomTagBrowserPageObject | `tests/pages/DicomTagBrowserPageObject.ts` | Tag-browser dialog (non-fixture; reach via `DOMOverlayPageObject.dialog`) |
|
||||||
|
| DataOverlayPageObject | `tests/pages/DataOverlayPageObject.ts` | Data-overlay menu (non-fixture; reach via `viewport.overlayMenu`) |
|
||||||
|
|
||||||
|
If the directory adds or renames a file, that diff is your first clue and this table is your second — trust the directory.
|
||||||
|
|
||||||
|
For live usage, the seed spec in [patterns-by-feature.md](patterns-by-feature.md) shows how a class is actually called; the source file tells you everything else that's on it.
|
||||||
148
.agents/skills/ohif-test-agent/references/patterns-by-feature.md
Normal file
148
.agents/skills/ohif-test-agent/references/patterns-by-feature.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Seed specs by feature area
|
||||||
|
|
||||||
|
> When writing a new OHIF test, find the closest feature area below and read the listed spec in full before writing. Playwright's own guidance says the seed test "serves as an example of all the generated tests" — that applies here.
|
||||||
|
>
|
||||||
|
> **If a spec listed below has moved or been renamed**, grep `tests/` for a remaining example (e.g. `grep -rn "loadSeriesByModality('RTSTRUCT')" tests/`). The pattern matters more than the exact filename — specs get renamed, the feature area persists.
|
||||||
|
>
|
||||||
|
> **No close match below?** This list only covers areas with a seed worth copying; don't add a stub for every untested area. See [Feature area not listed above?](#feature-area-not-listed-above-no-existing-seed).
|
||||||
|
|
||||||
|
## Canonical study UIDs
|
||||||
|
|
||||||
|
| UID | Mode(s) | What it has |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `1.3.6.1.4.1.25403.345050719074.3824.20170125095438.5` | `viewer` | CT — default for measurement/annotation tests |
|
||||||
|
| `1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785` | `viewer` | CT volume for 3D/MPR/crosshairs |
|
||||||
|
| `1.3.6.1.4.1.14519.5.2.1.256467663913010332776401703474716742458` | `viewer` or `segmentation` | CT + SEG for labelmap tests |
|
||||||
|
| `1.2.840.113619.2.290.3.3767434740.226.1600859119.501` | `viewer` / `segmentation` / `tmtv` | CT + RTSTRUCT + PET, used for contour and TMTV |
|
||||||
|
| `1.3.6.1.4.1.14519.5.2.1.7695.4007.324475281161490036195179843543` | `viewer` | SR structured report |
|
||||||
|
|
||||||
|
Do not invent UIDs — they must exist on the e2e data server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Simple measurement tools (length, angle, bidirectional, rectangle, ellipse, circle)
|
||||||
|
|
||||||
|
**Seed:** `tests/Length.spec.ts`, `tests/Angle.spec.ts`
|
||||||
|
|
||||||
|
Pattern: select tool via `mainToolbarPageObject.measurementTools.<tool>.click()`, place N points via `activeViewport.normalizedClickAt([...])`, confirm the tracking prompt, screenshot via `screenShotPaths.<name>.<name>DisplayedCorrectly`.
|
||||||
|
|
||||||
|
## 2. Freehand / spline / livewire ROIs
|
||||||
|
|
||||||
|
**Seed:** `tests/FreehandROI.spec.ts`, `tests/Livewire.spec.ts`, `tests/Spline.spec.ts`
|
||||||
|
|
||||||
|
Pattern: `normalizedDragAt({ start, end, config: { steps: 20, delay: 30 } })` for smooth strokes; `subscribeToMeasurementAdded` to assert the event fires; `activeViewport.nthAnnotation(0)` to reference what was drawn.
|
||||||
|
|
||||||
|
## 3. Annotations (arrow, probe)
|
||||||
|
|
||||||
|
**Seed:** `tests/ArrowAnnotate.spec.ts`, `tests/Probe.spec.ts`
|
||||||
|
|
||||||
|
Arrow annotate opens `DOMOverlayPageObject.dialog.input` for the label. Use `fillAndSave(label)`.
|
||||||
|
|
||||||
|
## 4. Measurement panel interactions
|
||||||
|
|
||||||
|
**Seed:** `tests/MeasurementPanel.spec.ts`
|
||||||
|
|
||||||
|
Panel access: `rightPanelPageObject.toggle()` → `.measurementsPanel.select()` → `.panel.nthMeasurement(i)` → `.actions.rename|delete|toggleLock|duplicate|...`. Also demonstrates `addLengthMeasurement(page)` and panel-row `click()` for jump-to.
|
||||||
|
|
||||||
|
## 5. Context menu (right-click on annotation)
|
||||||
|
|
||||||
|
**Seed:** `tests/ContextMenu.spec.ts`
|
||||||
|
|
||||||
|
Two ways to open: `activeViewport.normalizedClickAt([{...}], 'right')` on an empty area, or `activeViewport.nthAnnotation(0).contextMenu.open()` on a drawn annotation. Then `DOMOverlayPageObject.viewport.annotationContextMenu.addLabel|delete.click()`.
|
||||||
|
|
||||||
|
## 6. Labelmap segmentation (SEG) hydration
|
||||||
|
|
||||||
|
**Seed:** `tests/SEGHydration.spec.ts`, `tests/SEGHydrationThenMPR.spec.ts`
|
||||||
|
|
||||||
|
Flow: `leftPanelPageObject.loadSeriesByModality('SEG')` → `waitForTimeout(3000)` → `DOMOverlayPageObject.viewport.segmentationHydration.yes.click()`. Often pokes Cornerstone state directly via `page.evaluate(() => window.cornerstone...)` for zoom/render.
|
||||||
|
|
||||||
|
## 7. Contour segmentation (RTSTRUCT) + interactions
|
||||||
|
|
||||||
|
**Seeds:**
|
||||||
|
- Hydration: `tests/RTHydration.spec.ts`
|
||||||
|
- Rename: `tests/ContourSegmentRename.spec.ts`
|
||||||
|
- Navigation between segments: `tests/ContourSegNavigation.spec.ts`
|
||||||
|
- Duplicate: `tests/ContourSegmentDuplicate.spec.ts`
|
||||||
|
- Visibility: `tests/ContourSegmentToggleVisibility.spec.ts`
|
||||||
|
- Locking: `tests/ContourSegLocking.spec.ts`
|
||||||
|
|
||||||
|
Pattern: load RTSTRUCT series → hydrate → use `rightPanelPageObject.contourSegmentationPanel.panel.nthSegment(i)` / `.segmentByText('Small Sphere')` and their `.actions.rename|delete`, or the segment's `.click()` to jump.
|
||||||
|
|
||||||
|
## 8. Labelmap segmentation editing (brush / eraser / threshold)
|
||||||
|
|
||||||
|
**Seed:** `tests/LabelMapSegLocking.spec.ts`, `tests/SEGDrawingToolsResizing.spec.ts`
|
||||||
|
|
||||||
|
Pattern: `rightPanelPageObject.labelMapSegmentationPanel.tools.brush.setRadius(n)` / `.click()`, then `activeViewport.normalizedDragAt(...)` to paint.
|
||||||
|
|
||||||
|
## 9. TMTV / PET
|
||||||
|
|
||||||
|
**Seeds:** `tests/TMTVCSVReport.spec.ts`, `tests/TMTVSUV.spec.ts`, `tests/TMTVAlignment.spec.ts`, `tests/TMTVRendering.spec.ts`
|
||||||
|
|
||||||
|
Visit `mode: 'tmtv'` with a longer delay (`10000`). `rightPanelPageObject.tmtvPanel` for the side panel. Exporting a report uses `page.waitForEvent('download')` + `downloadAsString(download)`.
|
||||||
|
|
||||||
|
## 10. MPR and 3D layouts
|
||||||
|
|
||||||
|
**Seeds:** `tests/MPR.spec.ts`, `tests/3DOnly.spec.ts`, `tests/3DFourUp.spec.ts`, `tests/3DMain.spec.ts`, `tests/AxialPrimary.spec.ts`
|
||||||
|
|
||||||
|
Pattern: `mainToolbarPageObject.layoutSelection.<layoutName>.click()`. For 3D, wrap stabilization with `attemptAction(() => reduce3DViewportSize(page), 10, 100)` to settle the render before asserting.
|
||||||
|
|
||||||
|
## 11. Crosshairs
|
||||||
|
|
||||||
|
**Seed:** `tests/Crosshairs.spec.ts`
|
||||||
|
|
||||||
|
Pattern: `initializeMousePositionTracker(page)` in `beforeEach`, `mainToolbarPageObject.crosshairs.click()`, then `viewportPageObject.crosshairs.axial.rotate()` / `.increase()`.
|
||||||
|
|
||||||
|
## 12. Overlays — data overlay menu, window/level, orientation
|
||||||
|
|
||||||
|
**Seeds:** `tests/DataOverlayMenu.spec.ts`, `tests/WindowLevelOverlayText.spec.ts`, `tests/MultipleSegmentationDataOverlays.spec.ts`
|
||||||
|
|
||||||
|
Pattern: `viewportPageObject.getById('default').overlayMenu.dataOverlay.toggle()`, then `.addSegmentation(name)` / `.changeSegmentation(from, to)` / `.remove(name)`. For keyboard navigation, remember `press({ page, key, nTimes })` imports from `./utils/keyboardUtils`.
|
||||||
|
|
||||||
|
## 13. DICOM Tag Browser
|
||||||
|
|
||||||
|
**Seed:** `tests/DicomTagBrowser.spec.ts`
|
||||||
|
|
||||||
|
Open with `mainToolbarPageObject.moreTools.tagBrowser.click()`, then interact via `DOMOverlayPageObject.dialog.dicomTagBrowser.waitVisible()` / `.seriesSelect.selectOption(i)` / `.seriesSelect.getOptionText(i)`.
|
||||||
|
|
||||||
|
## 14. Study validation / not-found / worklist
|
||||||
|
|
||||||
|
**Seeds:** `tests/StudyValidation.spec.ts`, `tests/Worklist.spec.ts`
|
||||||
|
|
||||||
|
These are the rare specs that use `page.goto(...)` directly instead of `visitStudy()` — because they test error states or non-study pages. `notFoundStudyPageObject` gives you `errorMessage`, `returnMessage`, `studyListLink`.
|
||||||
|
|
||||||
|
## 15. SR (Structured Report) hydration
|
||||||
|
|
||||||
|
**Seeds:** `tests/SRHydration.spec.ts`, `tests/SRHydrationThenReload.spec.ts`
|
||||||
|
|
||||||
|
Same shape as SEG hydration but load via `loadSeriesByModality('SR')`.
|
||||||
|
|
||||||
|
## Feature area not listed above? (no existing seed)
|
||||||
|
|
||||||
|
Don't add a stub section here for an untested area. When your target isn't listed:
|
||||||
|
|
||||||
|
1. **Look for an indirect seed.** Grep `tests/` for the controls or flow you need
|
||||||
|
(`grep -rn "options-menu" tests/`, a related panel/dialog). An adjacent area's seed
|
||||||
|
usually still shows how the harness is driven.
|
||||||
|
2. **If genuinely uncovered, you're *creating* page objects.** Read
|
||||||
|
[page-objects.md](page-objects.md) → "When the control you need isn't covered yet", then:
|
||||||
|
- One page object per new control. Dialogs follow `DicomTagBrowserPageObject` (reached
|
||||||
|
through `DOMOverlayPageObject`); toolbar buttons get a getter on `MainToolbarPageObject`.
|
||||||
|
- Add a `data-cy` to any control that lacks one.
|
||||||
|
- **Verify the real effect, not a proxy** — drag and screenshot that the image moved via
|
||||||
|
`locator: viewportPageObject.grid`, not just that a button gained `data-active`.
|
||||||
|
|
||||||
|
**Example — user preferences / hotkeys (no seed today):** open the options menu and User
|
||||||
|
Preferences dialog (options-menu accessor on `DOMOverlayPageObject` + a `userPreferences`
|
||||||
|
dialog page object reached through it), set the hotkey, save, then trigger it and confirm the
|
||||||
|
viewport effect (activate Zoom, drag, screenshot the zoom). Add the Zoom button as a getter on
|
||||||
|
`MainToolbarPageObject`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced patterns worth knowing
|
||||||
|
|
||||||
|
- **`subscribeToMeasurementAdded`** (`tests/FreehandROI.spec.ts`) — for async "was a measurement actually added?" assertions. Always `try { ... } finally { await measurementAdded.unsubscribe() }`.
|
||||||
|
- **`attemptAction`** (`tests/3DOnly.spec.ts`) — retry flaky setup (3D render, heavy layout change) without silencing real failures.
|
||||||
|
- **`addOHIFConfiguration`** (`tests/RTHydrationDisableConfirmation.spec.ts`) — pre-load config overrides before `visitStudy`.
|
||||||
|
- **`page.evaluate(() => window.services...)`** — used in several SEG/SR specs to set customizations or poke viewport state. Treat as an escape hatch, not a default.
|
||||||
|
- **`expect.toPass({ timeout })`** — wrap flaky assertions (common for jump-to-measurement tests where rendering settles asynchronously).
|
||||||
100
.agents/skills/ohif-test-agent/references/utilities.md
Normal file
100
.agents/skills/ohif-test-agent/references/utilities.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# OHIF Test Utility guide
|
||||||
|
|
||||||
|
> This file documents the **stable import rules and conventions** around `tests/utils/`. For the current list of exported helpers and their exact signatures, **read `tests/utils/index.ts` and the files it re-exports** — the barrel is always current; a static table here is not. Utilities get added, renamed, and refactored; the rules below change much more slowly.
|
||||||
|
|
||||||
|
## How to discover what's available
|
||||||
|
|
||||||
|
1. Open `tests/utils/index.ts`. Every symbol exported from the barrel is importable as `import { foo } from './utils'`.
|
||||||
|
2. If what you need isn't in the barrel, look in the rest of `tests/utils/` — there are a handful of specialized files (`keyboardUtils.ts`, `assertions.ts`, `download.ts`, …). These need the **deeper import path**; see the rule below.
|
||||||
|
3. For the actual signature, read the utility's `.ts` file. It's one short function per file in most cases.
|
||||||
|
4. To see a utility in context, grep `tests/` (`grep -rn visitStudy tests/`) — or open the seed spec for the relevant feature area ([patterns-by-feature.md](patterns-by-feature.md)). Existing specs are the most reliable signature reference because they co-evolve with the API.
|
||||||
|
|
||||||
|
Do not guess parameter shapes from memory, and do not treat this file as an API catalog — it intentionally isn't one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stable rules
|
||||||
|
|
||||||
|
### Barrel vs. deep imports
|
||||||
|
|
||||||
|
Two import styles exist. They are not interchangeable.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Barrel — anything re-exported from tests/utils/index.ts
|
||||||
|
import { test, expect, visitStudy, checkForScreenshot, screenShotPaths } from './utils';
|
||||||
|
|
||||||
|
// Deep — for files NOT re-exported by the barrel
|
||||||
|
import { press } from './utils/keyboardUtils';
|
||||||
|
import { assertNumberOfModalityLoadBadges } from './utils/assertions';
|
||||||
|
import { downloadAsString } from './utils/download';
|
||||||
|
```
|
||||||
|
|
||||||
|
If a symbol isn't in the barrel, `import { x } from './utils'` **compiles**, resolves `x` to `undefined`, and blows up at the first call site. Confirm by opening `tests/utils/index.ts` for your working revision. At the time of this writing, `press`, `downloadAsString`, and the `assert*` helpers live outside the barrel — but maintainers can move things in or out, so treat `index.ts` as the ground truth rather than this note.
|
||||||
|
|
||||||
|
### Never import `test` / `expect` from `@playwright/test`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ Correct — gets the fixture-extended runner
|
||||||
|
import { test, expect } from './utils';
|
||||||
|
|
||||||
|
// ❌ Wrong — compiles, but every page-object fixture is silently undefined
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
```
|
||||||
|
|
||||||
|
If your test function's destructured arguments (like `viewportPageObject`) are `undefined`, this import is almost always why.
|
||||||
|
|
||||||
|
### `visitStudy` — 2000 ms is a convention, not a default
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await visitStudy(page, studyInstanceUID, mode, 2000);
|
||||||
|
```
|
||||||
|
|
||||||
|
The function's own default delay is `0`. Nearly every spec passes `2000` to let the first render settle. The one consistent exception is `mode: 'tmtv'`, where the suite uniformly uses `10000` because PET fusion and SUV calculation add real wall-clock cost before the UI is interactive.
|
||||||
|
|
||||||
|
Notably, 3D layouts in `viewer` mode (3DOnly, 3DFourUp, 3DMain, 3DPrimary) and MPR also use `2000` — they're not "heavy" in the `visitStudy` sense. Their stabilization problem is solved at the interaction layer with `attemptAction(() => reduce3DViewportSize(page), 10, 100)`, not by a longer visit delay.
|
||||||
|
|
||||||
|
So: pick `10000` when the mode is `tmtv`, `2000` otherwise, and only ramp up if a specific test flakes on first-render assertions. The delay is a good first lever for "not visible" flakes, but it's not a universal upgrade.
|
||||||
|
|
||||||
|
### `checkForScreenshot` — use the object form, never screenshot the full app
|
||||||
|
|
||||||
|
**This is the direction going forward** The suite is being migrated off *full-app* screenshots — not off screenshots altogether. Screenshots are the correct and required tool whenever what you're verifying is canvas-only raster output with no DOM signal; don't avoid them there. Avoid them only where a faithful DOM/SVG signal exists (e.g. a vector overlay's color via `getSvgAttribute`) or where you'd be capturing the whole app. See SKILL.md → "Screenshot vs. DOM assertion — how to choose". Any new spec must follow the rules below, and any modification to an older spec should bring it in line when reasonable.
|
||||||
|
|
||||||
|
- **Object form** (required for all new specs): `checkForScreenshot({ page, screenshotPath, normalizedClip?, ... })`
|
||||||
|
- **Positional form**: legacy. It still appears in older specs because they haven't been migrated yet. **Do not treat existing positional-form usage as a pattern to copy** — those specs are the thing being moved away from. Do not introduce the positional form in new code.
|
||||||
|
|
||||||
|
**Hard rules for new screenshots:**
|
||||||
|
|
||||||
|
1. Use the object form.
|
||||||
|
2. Scope by passing a `locator` — `viewportPageObject.grid` for the whole grid, or a specific viewport pane locator. **Never screenshot the full app.** `normalizedClip` is computed *relative to the locator* (and defaults to the full page when no locator is given), so `{ x: 0, y: 0, width: 1, height: 1 }` alone does not scope anything — reserve `normalizedClip` for clipping to a sub-region of a locator. If you find yourself reaching for `fullPage: true`, stop and pass a locator instead.
|
||||||
|
|
||||||
|
Do not tune `maxDiffPixelRatio` or `threshold` to make a screenshot pass — those are intentionally rarely touched and not the right knob for flakes. If a baseline mismatches, regenerate it (`--update-snapshots`) after a human review of the diff, or fix the underlying instability. Check the current signature in `tests/utils/checkForScreenshot.ts` if something looks off.
|
||||||
|
|
||||||
|
### `screenShotPaths` — use keys, not raw strings
|
||||||
|
|
||||||
|
```ts
|
||||||
|
await checkForScreenshot({
|
||||||
|
page,
|
||||||
|
locator: viewportPageObject.grid,
|
||||||
|
screenshotPath: screenShotPaths.length.lengthDisplayedCorrectly,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The full tree of categories lives in `tests/utils/screenShotPaths.ts`. When you need a new baseline, **add the key there and reference it by name** rather than typing a raw path. A typo in a key becomes a compile error instead of a silent mismatch, and other tests become discoverable through the object.
|
||||||
|
|
||||||
|
### Object-param convention
|
||||||
|
|
||||||
|
Many OHIF test utilities take a single **object argument** rather than positional arguments — notably `press({ page, key, nTimes? })`, the `simulate*` helpers, and the `assert*` helpers. If a call looks like it should work but throws "cannot read properties of undefined," check whether you're passing positional args to something that expects `{ page, ... }`.
|
||||||
|
|
||||||
|
Read the one-line signature at the top of the utility's source file before calling it — it's faster than guessing, and it's always right.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utility shapes worth flagging
|
||||||
|
|
||||||
|
Most utilities are obvious once you read the source; these earn a mention:
|
||||||
|
|
||||||
|
- **`waitForViewportRenderCycle(page, options?)`** — preferred replacement for `page.waitForTimeout(...)` after any viewport-mutating action (hydration confirm, layout change, segmentation add, series load, etc.). Start it **before** the action, await it after — it captures the `needsRender → rendered` transition the action triggers. Use `waitForViewportsRendered(page)` (the second-half-only variant) when the render is already in flight before you can attach a watcher. Source: `tests/utils/waitForViewportsRendered.ts`. Seed: `tests/SEGHydrationFromMPR.spec.ts`. The "Wait for renders, don't sleep" section in [SKILL.md](../SKILL.md) covers the idiom in full.
|
||||||
|
- **`subscribeToMeasurementAdded(page)`** — returns `{ waitFired(timeout?), unsubscribe() }`. Use in freehand/livewire/spline specs to assert the event fired. Always wrap in `try { ... } finally { await sub.unsubscribe() }` so a failing assertion doesn't leak the listener across tests.
|
||||||
|
- **`attemptAction(action, attempts?, delay?)`** — retries a flaky async action without masking real failures. Mainly used to stabilize 3D scenes (`attemptAction(() => reduce3DViewportSize(page), 10, 100)`).
|
||||||
|
|
||||||
|
For everything else, the pattern is: find a spec that uses it (see [patterns-by-feature.md](patterns-by-feature.md)), copy the shape, adapt.
|
||||||
@ -8,7 +8,7 @@ orbs:
|
|||||||
|
|
||||||
defaults: &defaults
|
defaults: &defaults
|
||||||
docker:
|
docker:
|
||||||
- image: cimg/node:24.0.0
|
- image: cimg/node:24.15.0
|
||||||
environment:
|
environment:
|
||||||
TERM: xterm
|
TERM: xterm
|
||||||
QUICK_BUILD: true
|
QUICK_BUILD: true
|
||||||
@ -421,7 +421,7 @@ jobs:
|
|||||||
-o Acquire::Retries=3 \
|
-o Acquire::Retries=3 \
|
||||||
-o Acquire::http::Timeout=20 \
|
-o Acquire::http::Timeout=20 \
|
||||||
-o Acquire::https::Timeout=20
|
-o Acquire::https::Timeout=20
|
||||||
sudo apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6
|
sudo apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6
|
||||||
- run:
|
- run:
|
||||||
name: Start Xvfb
|
name: Start Xvfb
|
||||||
command: Xvfb :99 -screen 0 1920x1080x24 &
|
command: Xvfb :99 -screen 0 1920x1080x24 &
|
||||||
|
|||||||
13
.github/workflows/build-docs.yml
vendored
13
.github/workflows/build-docs.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24.15.0
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Configure git for private repos
|
- name: Configure git for private repos
|
||||||
@ -112,9 +112,14 @@ jobs:
|
|||||||
run: pnpm --filter ohif-docs run build
|
run: pnpm --filter ohif-docs run build
|
||||||
|
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
run: |
|
# The docs are already built by the "Build docs" step above, so pass
|
||||||
cd platform/docs
|
# --no-build: `netlify deploy` runs the build command by default, which
|
||||||
npx netlify-cli deploy --dir=./build --prod
|
# would otherwise pick up the repo-root netlify.toml (configured for the
|
||||||
|
# main viewer app) and build the wrong project. --dir is resolved
|
||||||
|
# relative to the repo root (the netlify.toml base), not the cwd, so it
|
||||||
|
# must be the full path to the prebuilt docs output. --filter selects
|
||||||
|
# the ohif-docs package so Netlify CLI does not abort on monorepo detection.
|
||||||
|
run: npx netlify-cli deploy --filter ohif-docs --dir=platform/docs/build --prod --no-build
|
||||||
env:
|
env:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|||||||
5
.github/workflows/playwright.yml
vendored
5
.github/workflows/playwright.yml
vendored
@ -23,11 +23,12 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
playwright-tests:
|
playwright-tests:
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
|
environment: fork-pr-approval
|
||||||
runs-on: [self-hosted, nashua]
|
runs-on: [self-hosted, nashua]
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [24]
|
node-version: [24.15.0]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
@ -133,7 +134,7 @@ jobs:
|
|||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: |
|
run: |
|
||||||
export NODE_OPTIONS="--max_old_space_size=10192"
|
export NODE_OPTIONS="--max_old_space_size=10192"
|
||||||
pnpm run test:e2e:coverage
|
bash .scripts/ci/with-nashua-lock.sh pnpm run test:e2e:coverage
|
||||||
|
|
||||||
# ── Common: collect test results and coverage ───────────────────────
|
# ── Common: collect test results and coverage ───────────────────────
|
||||||
- name: Create directory of test results
|
- name: Create directory of test results
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -61,8 +61,6 @@ tests/playwright-report/
|
|||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|
||||||
**/.claude/settings.local.json
|
|
||||||
|
|
||||||
# cornerstone3D local linking
|
# cornerstone3D local linking
|
||||||
libs/
|
libs/
|
||||||
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
24
|
24.15.0
|
||||||
|
|||||||
54
.scripts/ci/with-nashua-lock.sh
Normal file
54
.scripts/ci/with-nashua-lock.sh
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Run a command while holding the nashua Playwright mutex.
|
||||||
|
#
|
||||||
|
# Only one Playwright run may execute at a time on the shared nashua self-hosted
|
||||||
|
# runner — across THIS repo (OHIF) AND the cornerstone3D repo. GitHub's
|
||||||
|
# `concurrency:` is scoped per-repository and cannot coordinate across the two
|
||||||
|
# orgs, so the mutex lives on the box's filesystem.
|
||||||
|
#
|
||||||
|
# This is OHIF's own copy (the script can't be shared across separate repos). It
|
||||||
|
# MUST use the SAME lock path as cornerstone3D's copy (NASHUA_LOCK_FILE default
|
||||||
|
# below) — if the paths differ, the mutex silently does nothing.
|
||||||
|
#
|
||||||
|
# flock holds the lock via fd 9 and it releases automatically when the wrapped
|
||||||
|
# command exits — including on job cancel / timeout / SIGKILL — so there are no
|
||||||
|
# stale locks to clean up.
|
||||||
|
#
|
||||||
|
# Usage: .scripts/ci/with-nashua-lock.sh <command> [args...]
|
||||||
|
|
||||||
|
# Strict mode: -e abort on any unhandled command failure, -u treat use of an
|
||||||
|
# unset variable as an error, -o pipefail make a pipeline fail if ANY stage
|
||||||
|
# fails (not just the last). Catches mistakes early instead of pressing on.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOCK="${NASHUA_LOCK_FILE:-/var/tmp/nashua-playwright.lock}" # MUST match cornerstone3D's copy
|
||||||
|
LOCK_WAIT="${NASHUA_LOCK_WAIT:-5400}" # max seconds to wait
|
||||||
|
|
||||||
|
# Guard: a command to wrap is required. With no arguments there is nothing to
|
||||||
|
# run, so print usage and exit instead of silently grabbing and releasing the
|
||||||
|
# lock for no reason (which would mask a mis-wired workflow step).
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "usage: $0 <command> [args...]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Open the lock file on fd 9 (read-write, create if missing, never truncate).
|
||||||
|
exec 9<>"$LOCK" || { echo "::error::cannot open lock file $LOCK"; exit 1; }
|
||||||
|
|
||||||
|
# Try instantly; if busy, report who holds it, then block up to LOCK_WAIT.
|
||||||
|
if ! flock -n 9; then
|
||||||
|
echo "nashua Playwright runner busy — held by: $(cat "${LOCK}.info" 2>/dev/null || echo unknown). Waiting up to ${LOCK_WAIT}s…"
|
||||||
|
if ! flock -w "$LOCK_WAIT" 9; then
|
||||||
|
echo "::error::Timed out after ${LOCK_WAIT}s waiting for the nashua Playwright lock"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Record the current holder for other jobs' "held by" message (best-effort).
|
||||||
|
echo "${GITHUB_REPOSITORY:-local}#${GITHUB_RUN_ID:-0} @ $(date -u +%FT%TZ 2>/dev/null || true)" > "${LOCK}.info" 2>/dev/null || true
|
||||||
|
echo "✅ Acquired nashua Playwright lock ($LOCK); running: $*"
|
||||||
|
|
||||||
|
# Replace the shell with the command. fd 9 is inherited (not close-on-exec), so
|
||||||
|
# the lock is held for the whole command and released when it exits.
|
||||||
|
exec "$@"
|
||||||
@ -176,6 +176,10 @@ Always prioritrize pub sub, by calling a services subscribe over useEffects as i
|
|||||||
### Never modify core architecture
|
### Never modify core architecture
|
||||||
Do not modify the core and always find a way to implement the solution via the extensions and modes, only modify core as a last resort if all other fail or there's an architectural constraint.
|
Do not modify the core and always find a way to implement the solution via the extensions and modes, only modify core as a last resort if all other fail or there's an architectural constraint.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
The `ohif-test-agent` skill (Playwright E2E test guidance) lives at `.agents/skills/ohif-test-agent/`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Plugin Configuration
|
### Plugin Configuration
|
||||||
|
|||||||
@ -23,7 +23,7 @@
|
|||||||
# Stage 1: Build the application
|
# Stage 1: Build the application
|
||||||
# docker build -t ohif/viewer:latest .
|
# docker build -t ohif/viewer:latest .
|
||||||
# Copy Files
|
# Copy Files
|
||||||
FROM node:24-slim as builder
|
FROM node:24.15.0-slim as builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 \
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
27
README.md
27
README.md
@ -244,6 +244,32 @@ also supports a number of commands that can be found in their respective
|
|||||||
\* - For more information on different builds, check out our [Deploy
|
\* - For more information on different builds, check out our [Deploy
|
||||||
Docs][deployment-docs]
|
Docs][deployment-docs]
|
||||||
|
|
||||||
|
### Which config each command uses
|
||||||
|
|
||||||
|
The dev server and the production build select a different default
|
||||||
|
[configuration file][config-file] when `APP_CONFIG` is not set explicitly:
|
||||||
|
|
||||||
|
| Command | Default config | Data sources | `?customization=` |
|
||||||
|
| ------------------------------- | -------------------- | ------------ | ----------------- |
|
||||||
|
| `dev`, `dev:fast`, `start` | `config/dev.js` | Full set | Enabled |
|
||||||
|
| `build` | `config/default.js` | One demo source | Disabled |
|
||||||
|
|
||||||
|
- **`config/dev.js`** is the full-featured local-development config: every data
|
||||||
|
source is enabled, the `?customization=` URL feature is turned on (via
|
||||||
|
`customizationUrlPrefixes`), and it is kept at parity with the public demo
|
||||||
|
(`config/netlify.js`) so customizations behave locally the same way they do on
|
||||||
|
the demo.
|
||||||
|
- **`config/netlify.js`** is the public demo / Netlify deploy config
|
||||||
|
(`build:viewer:ci`), with the same full data-source set and `?customization=`
|
||||||
|
enabled.
|
||||||
|
- **`config/default.js`** is a locked-down baseline and is now **only** the
|
||||||
|
default for a plain production build (`build` with no `APP_CONFIG`): a single
|
||||||
|
read-only demo data source and `?customization=` off.
|
||||||
|
|
||||||
|
Any explicit `APP_CONFIG` overrides the default, e.g.
|
||||||
|
`APP_CONFIG=config/default.js pnpm run dev` or
|
||||||
|
`APP_CONFIG=config/netlify.js pnpm run build`.
|
||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
The OHIF Medical Image Viewing Platform is maintained as a
|
The OHIF Medical Image Viewing Platform is maintained as a
|
||||||
@ -371,6 +397,7 @@ MIT © [OHIF](https://github.com/OHIF)
|
|||||||
[ohif-architecture]: https://docs.ohif.org/architecture/index.html
|
[ohif-architecture]: https://docs.ohif.org/architecture/index.html
|
||||||
[ohif-extensions]: https://docs.ohif.org/architecture/index.html
|
[ohif-extensions]: https://docs.ohif.org/architecture/index.html
|
||||||
[deployment-docs]: https://docs.ohif.org/deployment/
|
[deployment-docs]: https://docs.ohif.org/deployment/
|
||||||
|
[config-file]: https://docs.ohif.org/configuration/configurationFiles
|
||||||
[react-url]: https://reactjs.org/
|
[react-url]: https://reactjs.org/
|
||||||
[pwa-url]: https://developers.google.com/web/progressive-web-apps/
|
[pwa-url]: https://developers.google.com/web/progressive-web-apps/
|
||||||
[ohif-viewer-url]: https://www.npmjs.com/package/@ohif/app
|
[ohif-viewer-url]: https://www.npmjs.com/package/@ohif/app
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ohif/extension-cornerstone-dicom-pmap",
|
"name": "@ohif/extension-cornerstone-dicom-pmap",
|
||||||
"version": "3.13.0-beta.95",
|
"version": "3.13.0-beta.125",
|
||||||
"description": "DICOM Parametric Map read workflow",
|
"description": "DICOM Parametric Map read workflow",
|
||||||
"author": "OHIF",
|
"author": "OHIF",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -41,8 +41,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.29.7",
|
"@babel/runtime": "7.29.7",
|
||||||
"@cornerstonejs/adapters": "5.0.2",
|
"@cornerstonejs/adapters": "5.4.17",
|
||||||
"@cornerstonejs/core": "5.0.2",
|
"@cornerstonejs/core": "5.4.17",
|
||||||
"@kitware/vtk.js": "35.5.3"
|
"@kitware/vtk.js": "35.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ohif/extension-cornerstone-dicom-rt",
|
"name": "@ohif/extension-cornerstone-dicom-rt",
|
||||||
"version": "3.13.0-beta.95",
|
"version": "3.13.0-beta.125",
|
||||||
"description": "DICOM RT read workflow",
|
"description": "DICOM RT read workflow",
|
||||||
"author": "OHIF",
|
"author": "OHIF",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ohif/extension-cornerstone-dicom-seg",
|
"name": "@ohif/extension-cornerstone-dicom-seg",
|
||||||
"version": "3.13.0-beta.95",
|
"version": "3.13.0-beta.125",
|
||||||
"description": "DICOM SEG read workflow",
|
"description": "DICOM SEG read workflow",
|
||||||
"author": "OHIF",
|
"author": "OHIF",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -41,8 +41,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.29.7",
|
"@babel/runtime": "7.29.7",
|
||||||
"@cornerstonejs/adapters": "5.0.2",
|
"@cornerstonejs/adapters": "5.4.17",
|
||||||
"@cornerstonejs/core": "5.0.2",
|
"@cornerstonejs/core": "5.4.17",
|
||||||
"@kitware/vtk.js": "35.5.3"
|
"@kitware/vtk.js": "35.5.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -6,6 +6,11 @@ import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
|
|||||||
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
|
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
|
||||||
|
|
||||||
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
|
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
|
||||||
|
import {
|
||||||
|
getSegmentationSaveOptions,
|
||||||
|
LABELMAP_SEG_SOP_CLASS_UID,
|
||||||
|
BITMAP_SEG_SOP_CLASS_UID,
|
||||||
|
} from './utils/segmentationConfig';
|
||||||
|
|
||||||
const getTargetViewport = ({ viewportId, viewportGridService }) => {
|
const getTargetViewport = ({ viewportId, viewportGridService }) => {
|
||||||
const { viewports, activeViewportId } = viewportGridService.getState();
|
const { viewports, activeViewportId } = viewportGridService.getState();
|
||||||
@ -28,13 +33,12 @@ const {
|
|||||||
},
|
},
|
||||||
} = adaptersRT;
|
} = adaptersRT;
|
||||||
|
|
||||||
|
|
||||||
const commandsModule = ({
|
const commandsModule = ({
|
||||||
servicesManager,
|
servicesManager,
|
||||||
extensionManager,
|
extensionManager,
|
||||||
commandsManager,
|
commandsManager,
|
||||||
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
|
||||||
const { segmentationService, displaySetService, viewportGridService } =
|
const { segmentationService, displaySetService, viewportGridService, customizationService } =
|
||||||
servicesManager.services as AppTypes.Services;
|
servicesManager.services as AppTypes.Services;
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
@ -88,49 +92,85 @@ const commandsModule = ({
|
|||||||
* @returns Returns the generated segmentation data.
|
* @returns Returns the generated segmentation data.
|
||||||
*/
|
*/
|
||||||
generateSegmentation: ({ segmentationId, options = {} }) => {
|
generateSegmentation: ({ segmentationId, options = {} }) => {
|
||||||
|
// `dataSource` (a data source name) is consumed here to resolve the store
|
||||||
|
// overrides; it must not be forwarded to the adapter's generateSegmentation.
|
||||||
|
const { dataSource: dataSourceName, ...generateOptions } = options;
|
||||||
const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId);
|
const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId);
|
||||||
const predecessorImageId = options.predecessorImageId ?? segmentation.predecessorImageId;
|
const predecessorImageId =
|
||||||
|
generateOptions.predecessorImageId ?? segmentation.predecessorImageId;
|
||||||
|
|
||||||
const { imageIds } = segmentation.representationData.Labelmap;
|
// A data source may override the app-wide `segmentation.store.*` defaults
|
||||||
|
// via `configuration.segmentation.store` (different back ends support
|
||||||
|
// different SEG encodings). Use the named target data source when storing,
|
||||||
|
// otherwise the active one (e.g. download).
|
||||||
|
const dataSourceDefinition = dataSourceName
|
||||||
|
? extensionManager.getDataSourceDefinition(dataSourceName)
|
||||||
|
: extensionManager.getActiveDataSourceDefinition();
|
||||||
|
const dataSourceStoreOverride = dataSourceDefinition?.configuration?.segmentation?.store;
|
||||||
|
|
||||||
const segImages = imageIds.map(imageId => cache.getImage(imageId));
|
const labelmapData = segmentation.representationData.Labelmap;
|
||||||
const referencedImages = segImages.map(image => cache.getImage(image.referencedImageId));
|
|
||||||
|
|
||||||
const labelmaps2D = [];
|
// Build a labelmap3D (one labelmaps2D entry per source slice) from a list of
|
||||||
|
// derived labelmap image ids. When `referencedImageIds` is supplied (the
|
||||||
|
// multi-layer/overlap path) each frame is indexed by its source slice so the
|
||||||
|
// layers align to the same frames; otherwise frames are sequential (the legacy
|
||||||
|
// single-layer behavior, kept byte-identical).
|
||||||
|
const buildLabelmap3D = (segImageIds: string[], metadata, referencedImageIds?: string[]) => {
|
||||||
|
const segImages = segImageIds.map(imageId => cache.getImage(imageId));
|
||||||
|
const labelmaps2D = [];
|
||||||
|
|
||||||
let z = 0;
|
// Map each source imageId to its frame index once (O(n)) so the per-slice lookup
|
||||||
|
// below is O(1) — avoids the O(slices^2) indexOf scan on the multi-layer path.
|
||||||
|
const referencedFrameIndexById = referencedImageIds
|
||||||
|
? new Map(referencedImageIds.map((imageId, index) => [imageId, index]))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
for (const segImage of segImages) {
|
let z = 0;
|
||||||
const segmentsOnLabelmap = new Set();
|
|
||||||
const pixelData = segImage.getPixelData();
|
|
||||||
const { rows, columns } = segImage;
|
|
||||||
|
|
||||||
// Use a single pass through the pixel data
|
for (const segImage of segImages) {
|
||||||
for (let i = 0; i < pixelData.length; i++) {
|
const segmentsOnLabelmap = new Set();
|
||||||
const segment = pixelData[i];
|
const pixelData = segImage.getPixelData();
|
||||||
if (segment !== 0) {
|
const { rows, columns } = segImage;
|
||||||
segmentsOnLabelmap.add(segment);
|
|
||||||
|
// Use a single pass through the pixel data
|
||||||
|
for (let i = 0; i < pixelData.length; i++) {
|
||||||
|
const segment = pixelData[i];
|
||||||
|
if (segment !== 0) {
|
||||||
|
segmentsOnLabelmap.add(segment);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const frameIndex = referencedFrameIndexById
|
||||||
|
? referencedFrameIndexById.get(segImage.referencedImageId) ?? -1
|
||||||
|
: z++;
|
||||||
|
|
||||||
|
if (frameIndex < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
labelmaps2D[frameIndex] = {
|
||||||
|
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
|
||||||
|
pixelData,
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
labelmaps2D[z++] = {
|
const allSegmentsOnLabelmap = labelmaps2D
|
||||||
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
|
.filter(Boolean)
|
||||||
pixelData,
|
.map(labelmap => labelmap.segmentsOnLabelmap);
|
||||||
rows,
|
|
||||||
columns,
|
return {
|
||||||
|
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
|
||||||
|
metadata,
|
||||||
|
labelmaps2D,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap);
|
|
||||||
|
|
||||||
const labelmap3D = {
|
|
||||||
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
|
|
||||||
metadata: [],
|
|
||||||
labelmaps2D,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Segment metadata (shared across all layers).
|
||||||
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
|
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
|
||||||
const representations = segmentationService.getRepresentationsForSegmentation(segmentationId);
|
const representations = segmentationService.getRepresentationsForSegmentation(segmentationId);
|
||||||
|
const metadata = [];
|
||||||
|
|
||||||
Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => {
|
Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => {
|
||||||
// segmentation service already has a color for each segment
|
// segmentation service already has a color for each segment
|
||||||
@ -151,7 +191,7 @@ const commandsModule = ({
|
|||||||
color.slice(0, 3).map(value => value / 255)
|
color.slice(0, 3).map(value => value / 255)
|
||||||
).map(value => Math.round(value));
|
).map(value => Math.round(value));
|
||||||
|
|
||||||
const segmentMetadata = {
|
metadata[segmentIndex] = {
|
||||||
SegmentNumber: segmentIndex.toString(),
|
SegmentNumber: segmentIndex.toString(),
|
||||||
SegmentLabel: label,
|
SegmentLabel: label,
|
||||||
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
|
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
|
||||||
@ -168,13 +208,76 @@ const commandsModule = ({
|
|||||||
CodeMeaning: 'Tissue',
|
CodeMeaning: 'Tissue',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
labelmap3D.metadata[segmentIndex] = segmentMetadata;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
|
// Multi-layer (overlapping) SEGs register one labelmap layer per conflict-free
|
||||||
|
// group. Export each layer as its own labelmap3D against the UNIQUE referenced
|
||||||
|
// source series, so cornerstone writes overlapping segments as separate frames
|
||||||
|
// that reference the same source slice (the DICOM SEG overlap encoding). The
|
||||||
|
// cs3D adapter's fillSegmentation accepts an array of labelmap3D for exactly
|
||||||
|
// this. Single-layer SEGs keep the original single-labelmap3D path unchanged.
|
||||||
|
const layers = labelmapData.labelmaps ? Object.values(labelmapData.labelmaps) : undefined;
|
||||||
|
|
||||||
|
// The referenced source images must be fully loaded (in cache) before we can
|
||||||
|
// build the SEG dataset against them; fail loudly rather than passing undefined
|
||||||
|
// frames to the adapter.
|
||||||
|
const resolveReferencedImage = (referencedImageId: string, sliceIndex: number) => {
|
||||||
|
const referencedImage = cache.getImage(referencedImageId);
|
||||||
|
if (!referencedImage) {
|
||||||
|
throw new Error(
|
||||||
|
`Referenced source image not in cache for segmentation slice ${sliceIndex} ` +
|
||||||
|
`(referencedImageId: ${referencedImageId}). Ensure the referenced series is fully loaded before storing.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return referencedImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
let referencedImages;
|
||||||
|
let labelmaps3D;
|
||||||
|
|
||||||
|
if (layers && layers.length > 1) {
|
||||||
|
const referencedImageIds =
|
||||||
|
layers[0].referencedImageIds ?? labelmapData.referencedImageIds ?? [];
|
||||||
|
referencedImages = referencedImageIds.map(resolveReferencedImage);
|
||||||
|
labelmaps3D = layers.map(layer =>
|
||||||
|
buildLabelmap3D(layer.imageIds ?? [], metadata, referencedImageIds)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const { imageIds } = labelmapData;
|
||||||
|
const segImages = imageIds.map(imageId => cache.getImage(imageId));
|
||||||
|
referencedImages = segImages.map((image, sliceIndex) =>
|
||||||
|
resolveReferencedImage(image.referencedImageId, sliceIndex)
|
||||||
|
);
|
||||||
|
labelmaps3D = buildLabelmap3D(imageIds, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveOptions = {
|
||||||
predecessorImageId,
|
predecessorImageId,
|
||||||
...options,
|
...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride),
|
||||||
});
|
...generateOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A LABELMAP SEG frame stores a single label per voxel, so the labelmap
|
||||||
|
// encoder cannot represent overlapping segments — it keeps only the last
|
||||||
|
// layer written to each voxel. Overlapping segmentations arrive here as
|
||||||
|
// multiple layers, so switch those to the binary SEG encoding, which
|
||||||
|
// writes overlapping segments as separate frames referencing the same
|
||||||
|
// source slice.
|
||||||
|
const hasOverlappingLayers = Boolean(layers && layers.length > 1);
|
||||||
|
if (hasOverlappingLayers && saveOptions.sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) {
|
||||||
|
console.warn(
|
||||||
|
'generateSegmentation: overlapping segments cannot be stored as a LABELMAP SEG; ' +
|
||||||
|
'switching to the binary SEG encoding for this store.'
|
||||||
|
);
|
||||||
|
saveOptions.sopClassUID = BITMAP_SEG_SOP_CLASS_UID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedSegmentation = generateSegmentation(
|
||||||
|
referencedImages,
|
||||||
|
labelmaps3D,
|
||||||
|
metaData,
|
||||||
|
saveOptions
|
||||||
|
);
|
||||||
|
|
||||||
return generatedSegmentation;
|
return generatedSegmentation;
|
||||||
},
|
},
|
||||||
@ -240,9 +343,7 @@ const commandsModule = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultFileName =
|
const defaultFileName =
|
||||||
modality === 'RTSTRUCT'
|
modality === 'RTSTRUCT' ? `rtss-${segmentationId}.dcm` : `${label || 'segmentation'}.dcm`;
|
||||||
? `rtss-${segmentationId}.dcm`
|
|
||||||
: `${label || 'segmentation'}.dcm`;
|
|
||||||
|
|
||||||
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||||
dataSource: dataSourceName,
|
dataSource: dataSourceName,
|
||||||
@ -257,6 +358,8 @@ const commandsModule = ({
|
|||||||
const args = {
|
const args = {
|
||||||
segmentationId,
|
segmentationId,
|
||||||
options: {
|
options: {
|
||||||
|
// Resolve store overrides against the data source we are storing into.
|
||||||
|
dataSource: dataSourceName,
|
||||||
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
|
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
|
||||||
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
|
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
|
||||||
predecessorImageId: series,
|
predecessorImageId: series,
|
||||||
@ -289,6 +392,8 @@ const commandsModule = ({
|
|||||||
|
|
||||||
generateContour: async args => {
|
generateContour: async args => {
|
||||||
const { segmentationId, options } = args;
|
const { segmentationId, options } = args;
|
||||||
|
// `dataSource` is only used by the SEG store path; keep it out of the RTSS options.
|
||||||
|
const { dataSource: _dataSource, ...contourOptions } = options ?? {};
|
||||||
const segmentations = segmentationService.getSegmentation(segmentationId);
|
const segmentations = segmentationService.getSegmentation(segmentationId);
|
||||||
|
|
||||||
// inject colors to the segmentIndex
|
// inject colors to the segmentIndex
|
||||||
@ -301,10 +406,11 @@ const commandsModule = ({
|
|||||||
Number(segmentIndex)
|
Number(segmentIndex)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const predecessorImageId = options?.predecessorImageId ?? segmentations.predecessorImageId;
|
const predecessorImageId =
|
||||||
|
contourOptions.predecessorImageId ?? segmentations.predecessorImageId;
|
||||||
const dataset = await generateRTSSFromRepresentation(segmentations, {
|
const dataset = await generateRTSSFromRepresentation(segmentations, {
|
||||||
predecessorImageId,
|
predecessorImageId,
|
||||||
...options,
|
...contourOptions,
|
||||||
});
|
});
|
||||||
return { dataset };
|
return { dataset };
|
||||||
},
|
},
|
||||||
|
|||||||
@ -70,7 +70,10 @@ function SegmentSelector({
|
|||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
value={value}
|
value={value}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="overflow-hidden">
|
<SelectTrigger
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-cy={`logical-contour-segment-${label.toLowerCase()}-trigger`}
|
||||||
|
>
|
||||||
<SelectValue placeholder={t(placeholder)} />
|
<SelectValue placeholder={t(placeholder)} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -180,6 +183,7 @@ function LogicalContourOperationOptions() {
|
|||||||
value={value}
|
value={value}
|
||||||
key={`logical-contour-operation-${value}`}
|
key={`logical-contour-operation-${value}`}
|
||||||
onClick={() => setOperation(option)}
|
onClick={() => setOperation(option)}
|
||||||
|
data-cy={`logical-contour-operation-${value}`}
|
||||||
>
|
>
|
||||||
<Icons.ByName name={icon}></Icons.ByName>
|
<Icons.ByName name={icon}></Icons.ByName>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@ -207,6 +211,7 @@ function LogicalContourOperationOptions() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex justify-end pl-[34px]">
|
<div className="flex justify-end pl-[34px]">
|
||||||
<Button
|
<Button
|
||||||
|
data-cy="apply-logical-contour-operation"
|
||||||
className="border-primary/60 grow border"
|
className="border-primary/60 grow border"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -221,6 +226,7 @@ function LogicalContourOperationOptions() {
|
|||||||
<div className="flex items-center justify-start gap-2">
|
<div className="flex items-center justify-start gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="logical-contour-operations-create-new-segment-switch"
|
id="logical-contour-operations-create-new-segment-switch"
|
||||||
|
data-cy="logical-contour-create-new-segment-switch"
|
||||||
onCheckedChange={setCreateNewSegment}
|
onCheckedChange={setCreateNewSegment}
|
||||||
></Switch>
|
></Switch>
|
||||||
<Label htmlFor="logical-contour-operations-create-new-segment-switch">
|
<Label htmlFor="logical-contour-operations-create-new-segment-switch">
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_SEG_STORE_MODE,
|
||||||
|
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
|
||||||
|
type SegmentationMode,
|
||||||
|
} from '../utils/segmentationConfig';
|
||||||
|
|
||||||
|
export type SegmentLabelCustomization = {
|
||||||
|
enabledByDefault?: boolean;
|
||||||
|
labelColor?: number[];
|
||||||
|
hoverTimeout?: number;
|
||||||
|
background?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SegmentationStoreCustomization = {
|
||||||
|
defaultMode?: SegmentationMode;
|
||||||
|
transferSyntaxUID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Extension-registered defaults: Label Map SEG + RLE Lossless. */
|
||||||
|
const segmentationCustomization = {
|
||||||
|
'segmentation.store.defaultMode': DEFAULT_SEG_STORE_MODE,
|
||||||
|
'segmentation.store.transferSyntaxUID': DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID,
|
||||||
|
'segmentation.segmentLabel': {
|
||||||
|
enabledByDefault: false,
|
||||||
|
hoverTimeout: 1,
|
||||||
|
} satisfies SegmentLabelCustomization,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default segmentationCustomization;
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import segmentationCustomization from './customizations/segmentationCustomization';
|
||||||
|
|
||||||
|
export default function getCustomizationModule() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
value: segmentationCustomization,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -1,16 +1,237 @@
|
|||||||
import { utils, Types as OhifTypes } from '@ohif/core';
|
import { utils, Types as OhifTypes, DicomMetadataStore, classes, log } from '@ohif/core';
|
||||||
import i18n from '@ohif/i18n';
|
import i18n from '@ohif/i18n';
|
||||||
import { metaData, eventTarget } from '@cornerstonejs/core';
|
import { metaData, eventTarget, utilities as csUtils } from '@cornerstonejs/core';
|
||||||
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
|
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
|
||||||
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
|
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
|
||||||
|
|
||||||
import { SOPClassHandlerId } from './id';
|
import { SOPClassHandlerId } from './id';
|
||||||
import { dicomlabToRGB } from './utils/dicomlabToRGB';
|
import { dicomlabToRGB } from './utils/dicomlabToRGB';
|
||||||
|
import { getSegmentationParserType } from './utils/segmentationConfig';
|
||||||
|
import {
|
||||||
|
getFrameIndexFromImageId,
|
||||||
|
isLocalSchemeImageId,
|
||||||
|
stripFrameFromImageId,
|
||||||
|
} from './utils/segLocalImageIds';
|
||||||
|
|
||||||
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4', '1.2.840.10008.5.1.4.1.1.66.7'];
|
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4', '1.2.840.10008.5.1.4.1.1.66.7'];
|
||||||
|
const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
|
||||||
|
|
||||||
const loadPromises = {};
|
const loadPromises = {};
|
||||||
|
|
||||||
|
const SEG_LOAD_LOG_PREFIX = '[SEG load]';
|
||||||
|
|
||||||
|
// Max number of SEG frames fetched/decoded concurrently by the segmentation
|
||||||
|
// loader. Hard-coded to 16 for now; intended to become configurable (and to
|
||||||
|
// pair with the full-instance prefetch capability) in a follow-up.
|
||||||
|
const SEG_FRAME_DECODE_CONCURRENCY = 16;
|
||||||
|
|
||||||
|
|
||||||
|
function _normalizeImageId(imageId: string | string[] | undefined): string | undefined {
|
||||||
|
if (imageId == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Array.isArray(imageId) ? imageId[0] : imageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands a WADO-RS frame imageId (…/frames/1) into one imageId per frame.
|
||||||
|
* Multiframe SEG is stored as separate /frames/N resources on the server.
|
||||||
|
*/
|
||||||
|
function getFrameImageIds(segImageId: string, numberOfFrames: number): string[] {
|
||||||
|
const frameMatch = segImageId.match(/(.*\/frames\/)(\d+)(.*)$/);
|
||||||
|
if (!frameMatch || numberOfFrames <= 1) {
|
||||||
|
return [segImageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = frameMatch[1];
|
||||||
|
const suffix = frameMatch[3] || '';
|
||||||
|
const frameImageIds: string[] = [];
|
||||||
|
|
||||||
|
for (let frameNumber = 1; frameNumber <= numberOfFrames; frameNumber++) {
|
||||||
|
frameImageIds.push(`${prefix}${frameNumber}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return frameImageIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getSegNumberOfFrames(instance: Record<string, unknown>): number {
|
||||||
|
const fromTag = Number(instance.NumberOfFrames);
|
||||||
|
if (fromTag > 0) {
|
||||||
|
return fromTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
const perFrame = instance.PerFrameFunctionalGroupsSequence;
|
||||||
|
if (Array.isArray(perFrame) && perFrame.length > 0) {
|
||||||
|
return perFrame.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureSegImageIdMetadataRegistered(
|
||||||
|
imageId: string | undefined,
|
||||||
|
instance: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (!imageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataProvider = classes.MetadataProvider;
|
||||||
|
|
||||||
|
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||||
|
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||||
|
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!StudyInstanceUID || !SeriesInstanceUID || !SOPInstanceUID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataProvider.addImageIdToUIDs(imageId, {
|
||||||
|
StudyInstanceUID,
|
||||||
|
SeriesInstanceUID,
|
||||||
|
SOPInstanceUID,
|
||||||
|
frameNumber: getFrameIndexFromImageId(imageId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensures metadataProvider.get('instance', imageId) resolves for frame-qualified local SEG ids. */
|
||||||
|
function _ensureSegInstanceMetadataAvailable(
|
||||||
|
imageId: string | undefined,
|
||||||
|
instance: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (!imageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureSegImageIdMetadataRegistered(imageId, instance);
|
||||||
|
|
||||||
|
if (metaData.get('instance', imageId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||||
|
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||||
|
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const storedInstance =
|
||||||
|
StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID
|
||||||
|
? DicomMetadataStore.getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
classes.MetadataProvider.addCustomMetadata(
|
||||||
|
imageId,
|
||||||
|
'instance',
|
||||||
|
storedInstance || instance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getSegDataSource(extensionManager, instance: Record<string, unknown>) {
|
||||||
|
const StudyInstanceUID = instance.StudyInstanceUID as string | undefined;
|
||||||
|
const SeriesInstanceUID = instance.SeriesInstanceUID as string | undefined;
|
||||||
|
const SOPInstanceUID = (instance.SOPInstanceUID || instance.SopInstanceUID) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
let localUrl: string | undefined;
|
||||||
|
|
||||||
|
if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) {
|
||||||
|
const storedInstance = DicomMetadataStore.getInstance(
|
||||||
|
StudyInstanceUID,
|
||||||
|
SeriesInstanceUID,
|
||||||
|
SOPInstanceUID
|
||||||
|
);
|
||||||
|
localUrl = storedInstance?.url as string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
localUrl = localUrl || (instance.url as string | undefined);
|
||||||
|
|
||||||
|
if (localUrl && isLocalSchemeImageId(localUrl)) {
|
||||||
|
const dicomLocal = extensionManager.getDataSources('dicomlocal');
|
||||||
|
|
||||||
|
if (dicomLocal?.[0]) {
|
||||||
|
return dicomLocal[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensionManager.getActiveDataSource()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getSegImageIdFromInstance(
|
||||||
|
instance: Record<string, unknown>,
|
||||||
|
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
|
||||||
|
): string | undefined {
|
||||||
|
const numberOfFrames = _getSegNumberOfFrames(instance);
|
||||||
|
const frame = numberOfFrames > 1 ? 1 : undefined;
|
||||||
|
|
||||||
|
return _normalizeImageId(
|
||||||
|
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolveFrameImageIds(
|
||||||
|
segImageIdStr: string,
|
||||||
|
instance: Record<string, unknown>,
|
||||||
|
dataSource: { getImageIdsForInstance?: (args: { instance: unknown; frame?: number }) => unknown }
|
||||||
|
): string[] {
|
||||||
|
const numberOfFrames = _getSegNumberOfFrames(instance);
|
||||||
|
const fromFrameUrl = getFrameImageIds(segImageIdStr, numberOfFrames);
|
||||||
|
|
||||||
|
if (fromFrameUrl.length > 1) {
|
||||||
|
return fromFrameUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberOfFrames <= 1) {
|
||||||
|
return [segImageIdStr];
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameImageIds: string[] = [];
|
||||||
|
|
||||||
|
for (let frame = 1; frame <= numberOfFrames; frame++) {
|
||||||
|
const frameImageId = _normalizeImageId(
|
||||||
|
dataSource.getImageIdsForInstance?.({ instance, frame }) as string | string[] | undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (frameImageId) {
|
||||||
|
frameImageIds.push(frameImageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frameImageIds.length ? frameImageIds : [segImageIdStr];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _logSegImageIds({
|
||||||
|
segDisplaySet,
|
||||||
|
segImageIdStr,
|
||||||
|
frameImageIds,
|
||||||
|
referencedImageIds,
|
||||||
|
}: {
|
||||||
|
segDisplaySet: AppTypes.DisplaySet;
|
||||||
|
segImageIdStr: string;
|
||||||
|
frameImageIds: string[];
|
||||||
|
referencedImageIds: string[];
|
||||||
|
}) {
|
||||||
|
const instance = segDisplaySet.instance as Record<string, unknown>;
|
||||||
|
const numberOfFrames = Number(instance?.NumberOfFrames) || 1;
|
||||||
|
|
||||||
|
log.debug(SEG_LOAD_LOG_PREFIX, 'Loading SEG pixel data', {
|
||||||
|
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
|
||||||
|
SeriesInstanceUID: segDisplaySet.SeriesInstanceUID,
|
||||||
|
SOPClassUID: segDisplaySet.SOPClassUID,
|
||||||
|
NumberOfFrames: numberOfFrames,
|
||||||
|
segmentCount: Object.keys(segDisplaySet.segments || {}).length,
|
||||||
|
referencedDisplaySetInstanceUID: segDisplaySet.referencedDisplaySetInstanceUID,
|
||||||
|
referencedImageIdCount: referencedImageIds.length,
|
||||||
|
referencedImageIds,
|
||||||
|
segImageIdForMetadata: segImageIdStr,
|
||||||
|
frameImageIds,
|
||||||
|
loadSegFramesIndividually: frameImageIds.length > 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function _getDisplaySetsFromSeries(
|
function _getDisplaySetsFromSeries(
|
||||||
instances,
|
instances,
|
||||||
servicesManager: AppTypes.ServicesManager,
|
servicesManager: AppTypes.ServicesManager,
|
||||||
@ -171,6 +392,11 @@ function _load(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose the in-flight load promise so observers (e.g. the viewport service
|
||||||
|
// waiting to attach the representation) can react to a load failure without
|
||||||
|
// re-invoking load().
|
||||||
|
segDisplaySet.loadingPromise = loadPromises[SOPInstanceUID];
|
||||||
|
|
||||||
return loadPromises[SOPInstanceUID];
|
return loadPromises[SOPInstanceUID];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,16 +404,18 @@ async function _loadSegments({
|
|||||||
extensionManager,
|
extensionManager,
|
||||||
servicesManager,
|
servicesManager,
|
||||||
segDisplaySet,
|
segDisplaySet,
|
||||||
headers,
|
}: withAppTypes<{ segDisplaySet: AppTypes.DisplaySet }>) {
|
||||||
}: withAppTypes) {
|
const { segmentationService, uiNotificationService, customizationService } =
|
||||||
const utilityModule = extensionManager.getModuleEntry(
|
servicesManager.services;
|
||||||
'@ohif/extension-cornerstone.utilityModule.common'
|
const instance = segDisplaySet.instance as Record<string, unknown>;
|
||||||
);
|
const dataSource = _getSegDataSource(extensionManager, instance);
|
||||||
|
const segImageIdStr = _getSegImageIdFromInstance(instance, dataSource);
|
||||||
|
|
||||||
const { segmentationService, uiNotificationService } = servicesManager.services;
|
if (!segImageIdStr) {
|
||||||
|
throw new Error(
|
||||||
const { dicomLoaderService } = utilityModule.exports;
|
'Could not get imageId for SEG instance (no local wadouri url and getImageIdsForInstance returned nothing).'
|
||||||
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
|
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
|
||||||
segDisplaySet.referencedDisplaySetInstanceUID
|
segDisplaySet.referencedDisplaySetInstanceUID
|
||||||
@ -197,31 +425,114 @@ async function _loadSegments({
|
|||||||
throw new Error('referencedDisplaySet is missing for SEG');
|
throw new Error('referencedDisplaySet is missing for SEG');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer cached stack imageIds (multiframe SEG fix #4890), then data source expansion.
|
||||||
let { imageIds } = referencedDisplaySet;
|
let { imageIds } = referencedDisplaySet;
|
||||||
|
|
||||||
if (!imageIds) {
|
if (!imageIds?.length) {
|
||||||
// try images
|
imageIds = dataSource.getImageIdsForDisplaySet?.(referencedDisplaySet);
|
||||||
const { images } = referencedDisplaySet;
|
|
||||||
imageIds = images.map(image => image.imageId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo: what should be defaults here
|
if (!imageIds?.length) {
|
||||||
|
imageIds = (referencedDisplaySet as { images?: { imageId: string }[] }).images?.map(
|
||||||
|
(img: { imageId: string }) => img.imageId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageIds?.length) {
|
||||||
|
throw new Error('referencedDisplaySet has no imageIds');
|
||||||
|
}
|
||||||
|
|
||||||
|
(segDisplaySet as AppTypes.DisplaySet & { referencedImageIds?: string[] }).referencedImageIds =
|
||||||
|
imageIds;
|
||||||
|
|
||||||
|
if (!referencedDisplaySet.imageIds?.length) {
|
||||||
|
referencedDisplaySet.imageIds = imageIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameImageIds = _resolveFrameImageIds(
|
||||||
|
segImageIdStr,
|
||||||
|
segDisplaySet.instance as Record<string, unknown>,
|
||||||
|
dataSource
|
||||||
|
);
|
||||||
|
|
||||||
|
const segImageIdForMetadata = isLocalSchemeImageId(segImageIdStr)
|
||||||
|
? stripFrameFromImageId(segImageIdStr)
|
||||||
|
: segImageIdStr;
|
||||||
|
|
||||||
|
_logSegImageIds({
|
||||||
|
segDisplaySet,
|
||||||
|
segImageIdStr: segImageIdForMetadata,
|
||||||
|
frameImageIds,
|
||||||
|
referencedImageIds: imageIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
_ensureSegInstanceMetadataAvailable(segImageIdForMetadata, instance);
|
||||||
|
frameImageIds.forEach(id => _ensureSegInstanceMetadataAvailable(id, instance));
|
||||||
|
|
||||||
const tolerance = 0.001;
|
const tolerance = 0.001;
|
||||||
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
|
const onProgress = evt => {
|
||||||
const { percentComplete } = evt.detail;
|
const { percentComplete } = evt.detail;
|
||||||
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
|
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
|
||||||
percentComplete,
|
percentComplete,
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
|
||||||
|
|
||||||
const results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer(
|
// Fetch the whole SEG instance as a single Part 10 object and register its
|
||||||
imageIds,
|
// per-frame compressed pixels into the Cornerstone3D frame registry, so the
|
||||||
arrayBuffer,
|
// per-frame loads below are served locally instead of one network request
|
||||||
{ metadataProvider: metaData, tolerance }
|
// per frame: SEG frames are so small and numerous that one bulk fetch beats
|
||||||
);
|
// hundreds of tiny requests. Enabled by default; per-frame loading is the
|
||||||
|
// exception (loadMultiframeAsPart10: false in the data source config, or the
|
||||||
|
// cornerstone.segmentation.loadMultiframeAsPart10 customization). The
|
||||||
|
// prefetch is awaited until it completes OR fails — deliberately no timeout:
|
||||||
|
// a failed/unsupported instance fetch resolves quickly and falls back to
|
||||||
|
// per-frame, while a slow large fetch is still the fastest way to all frames.
|
||||||
|
const loadMultiframeAsPart10 =
|
||||||
|
(dataSource?.getConfig?.()?.loadMultiframeAsPart10 as boolean | undefined) ??
|
||||||
|
(customizationService?.getCustomization?.(
|
||||||
|
'cornerstone.segmentation.loadMultiframeAsPart10'
|
||||||
|
) as boolean | undefined) ??
|
||||||
|
true;
|
||||||
|
|
||||||
|
let prefetch;
|
||||||
|
if (loadMultiframeAsPart10) {
|
||||||
|
prefetch = dataSource.retrieve?.prefetchInstanceFrames?.({
|
||||||
|
instance,
|
||||||
|
imageId: segImageIdForMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prefetch?.done) {
|
||||||
|
await prefetch.done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDicomSegImageId(
|
||||||
|
imageIds,
|
||||||
|
segImageIdForMetadata,
|
||||||
|
{
|
||||||
|
metadataProvider: metaData,
|
||||||
|
tolerance,
|
||||||
|
parserType: getSegmentationParserType(
|
||||||
|
segDisplaySet.SOPClassUID,
|
||||||
|
customizationService
|
||||||
|
),
|
||||||
|
frameImageIds,
|
||||||
|
concurrency: SEG_FRAME_DECODE_CONCURRENCY,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
eventTarget.removeEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress);
|
||||||
|
prefetch?.cancel?.();
|
||||||
|
}
|
||||||
|
|
||||||
let usedRecommendedDisplayCIELabValue = true;
|
let usedRecommendedDisplayCIELabValue = true;
|
||||||
results.segMetadata.data.forEach((data, i) => {
|
const resultsTyped = results as {
|
||||||
|
segMetadata: { data: { rgba?: number[]; RecommendedDisplayCIELabValue?: number[] }[] };
|
||||||
|
};
|
||||||
|
resultsTyped.segMetadata.data.forEach((data, i) => {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
data.rgba = data.RecommendedDisplayCIELabValue;
|
data.rgba = data.RecommendedDisplayCIELabValue;
|
||||||
|
|
||||||
@ -246,6 +557,17 @@ async function _loadSegments({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(segDisplaySet, results);
|
Object.assign(segDisplaySet, results);
|
||||||
|
|
||||||
|
const labelMapImageIds = (results as { labelMapImages?: { imageId: string }[][] })
|
||||||
|
.labelMapImages?.flat()
|
||||||
|
.map(image => image.imageId);
|
||||||
|
|
||||||
|
log.debug(SEG_LOAD_LOG_PREFIX, 'SEG parse complete', {
|
||||||
|
SOPInstanceUID: segDisplaySet.SOPInstanceUID,
|
||||||
|
labelMapImageCount: labelMapImageIds?.length ?? 0,
|
||||||
|
labelMapImageIds,
|
||||||
|
segmentIndices: Object.keys(segDisplaySet.segments || {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _segmentationExists(segDisplaySet) {
|
function _segmentationExists(segDisplaySet) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import getSopClassHandlerModule from './getSopClassHandlerModule';
|
import getSopClassHandlerModule from './getSopClassHandlerModule';
|
||||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||||
import getCommandsModule from './commandsModule';
|
import getCommandsModule from './commandsModule';
|
||||||
|
import getCustomizationModule from './getCustomizationModule';
|
||||||
import { getToolbarModule } from './getToolbarModule';
|
import { getToolbarModule } from './getToolbarModule';
|
||||||
|
|
||||||
const Component = React.lazy(() => {
|
const Component = React.lazy(() => {
|
||||||
@ -28,6 +29,7 @@ const extension = {
|
|||||||
*/
|
*/
|
||||||
id,
|
id,
|
||||||
getCommandsModule,
|
getCommandsModule,
|
||||||
|
getCustomizationModule,
|
||||||
getToolbarModule,
|
getToolbarModule,
|
||||||
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
|
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
|
||||||
const ExtendedOHIFCornerstoneSEGViewport = props => {
|
const ExtendedOHIFCornerstoneSEGViewport = props => {
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
export function isLocalSchemeImageId(imageId: string): boolean {
|
||||||
|
return /^(wadouri:|dicomfile:|dicomweb:)/.test(imageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripFrameFromImageId(imageId: string): string {
|
||||||
|
const queryIndex = imageId.indexOf('?');
|
||||||
|
if (queryIndex === -1) {
|
||||||
|
return imageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = imageId.slice(0, queryIndex);
|
||||||
|
const rebuiltQuery = imageId
|
||||||
|
.slice(queryIndex + 1)
|
||||||
|
.split('&')
|
||||||
|
.filter(param => param.split('=')[0] !== 'frame')
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
return rebuiltQuery ? `${basePath}?${rebuiltQuery}` : basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendFrameToImageId(baseImageId: string, frame: number): string {
|
||||||
|
const withoutFrame = stripFrameFromImageId(baseImageId);
|
||||||
|
const separator = withoutFrame.includes('?') ? '&' : '?';
|
||||||
|
|
||||||
|
return `${withoutFrame}${separator}frame=${frame}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrameIndexFromImageId(imageId: string): number {
|
||||||
|
const frameMatch = imageId.match(/(?:&|\?)frame=(\d+)/);
|
||||||
|
|
||||||
|
return frameMatch ? Number(frameMatch[1]) : 1;
|
||||||
|
}
|
||||||
100
extensions/cornerstone-dicom-seg/src/utils/segmentationConfig.ts
Normal file
100
extensions/cornerstone-dicom-seg/src/utils/segmentationConfig.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
export const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7';
|
||||||
|
export const BITMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.4';
|
||||||
|
/** RLE Lossless — OHIF default SEG store transfer syntax. */
|
||||||
|
export const DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.5';
|
||||||
|
/** OHIF default SEG store mode (Label Map Segmentation SOP Class). */
|
||||||
|
export const DEFAULT_SEG_STORE_MODE = 'labelmap' as const;
|
||||||
|
|
||||||
|
export type SegmentationMode = 'labelmap' | 'bitmap';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-data-source overrides for the SEG store defaults. A data source may set
|
||||||
|
* these under `configuration.segmentation.store` to override the values coming
|
||||||
|
* from the `segmentation.store.*` customizations. Different back ends support
|
||||||
|
* different SEG encodings, so the data source is allowed to win over the
|
||||||
|
* app-wide customization default.
|
||||||
|
*/
|
||||||
|
export type SegmentationStoreOverride = {
|
||||||
|
defaultMode?: SegmentationMode;
|
||||||
|
transferSyntaxUID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SegmentationCustomizationReader = {
|
||||||
|
getCustomization: (customizationId: string) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStoreDefaultMode(
|
||||||
|
customizationService?: SegmentationCustomizationReader,
|
||||||
|
override?: SegmentationStoreOverride
|
||||||
|
): SegmentationMode {
|
||||||
|
const mode =
|
||||||
|
override?.defaultMode ??
|
||||||
|
(customizationService?.getCustomization('segmentation.store.defaultMode') as
|
||||||
|
| SegmentationMode
|
||||||
|
| undefined) ??
|
||||||
|
DEFAULT_SEG_STORE_MODE;
|
||||||
|
|
||||||
|
return mode === 'bitmap' ? 'bitmap' : 'labelmap';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoreTransferSyntaxUID(
|
||||||
|
customizationService?: SegmentationCustomizationReader,
|
||||||
|
override?: SegmentationStoreOverride
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
override?.transferSyntaxUID ??
|
||||||
|
(customizationService?.getCustomization(
|
||||||
|
'segmentation.store.transferSyntaxUID'
|
||||||
|
) as string | undefined) ??
|
||||||
|
DEFAULT_SEG_STORE_TRANSFER_SYNTAX_UID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the parser type for loading a DICOM SEG instance.
|
||||||
|
* Uses the instance SOP Class UID when it is a known SEG class; otherwise falls back to store defaultMode.
|
||||||
|
*/
|
||||||
|
export function getSegmentationParserType(
|
||||||
|
sopClassUID: string | undefined,
|
||||||
|
customizationService?: SegmentationCustomizationReader
|
||||||
|
): SegmentationMode {
|
||||||
|
if (sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) {
|
||||||
|
return 'labelmap';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sopClassUID === BITMAP_SEG_SOP_CLASS_UID) {
|
||||||
|
return 'bitmap';
|
||||||
|
}
|
||||||
|
|
||||||
|
return getStoreDefaultMode(customizationService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options passed to @cornerstonejs/adapters generateSegmentation when exporting or storing SEG.
|
||||||
|
*
|
||||||
|
* Defaults to **Label Map + RLE Lossless**. Customizations (or per-data-source
|
||||||
|
* `configuration.segmentation.store`) are only needed to opt into bitmap and/or
|
||||||
|
* uncompressed Explicit VR Little Endian.
|
||||||
|
*/
|
||||||
|
export function getSegmentationSaveOptions(
|
||||||
|
customizationService?: SegmentationCustomizationReader,
|
||||||
|
override?: SegmentationStoreOverride
|
||||||
|
): {
|
||||||
|
sopClassUID: string;
|
||||||
|
transferSyntaxUID: string;
|
||||||
|
transferSyntaxUid: string;
|
||||||
|
} {
|
||||||
|
const defaultMode = getStoreDefaultMode(customizationService, override);
|
||||||
|
const sopClassUID =
|
||||||
|
defaultMode === 'bitmap' ? BITMAP_SEG_SOP_CLASS_UID : LABELMAP_SEG_SOP_CLASS_UID;
|
||||||
|
const transferSyntaxUID = getStoreTransferSyntaxUID(
|
||||||
|
customizationService,
|
||||||
|
override
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sopClassUID,
|
||||||
|
transferSyntaxUID,
|
||||||
|
transferSyntaxUid: transferSyntaxUID,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -95,10 +95,13 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getCornerstoneViewport = useCallback(() => {
|
const getCornerstoneViewport = useCallback(() => {
|
||||||
|
// Stack uses the referenced series (data[0]); SEG is applied as an overlay (data[1]).
|
||||||
|
// Passing only the SEG display set leaves the stack with derived labelmap imageIds,
|
||||||
|
// which are not displayable without the underlying grayscale series.
|
||||||
return (
|
return (
|
||||||
<OHIFCornerstoneViewport
|
<OHIFCornerstoneViewport
|
||||||
{...props}
|
{...props}
|
||||||
displaySets={[segDisplaySet]}
|
displaySets={[referencedDisplaySet, segDisplaySet]}
|
||||||
viewportOptions={{
|
viewportOptions={{
|
||||||
viewportType: viewportOptions.viewportType,
|
viewportType: viewportOptions.viewportType,
|
||||||
toolGroupId: toolGroupId,
|
toolGroupId: toolGroupId,
|
||||||
@ -111,7 +114,14 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]);
|
}, [
|
||||||
|
viewportId,
|
||||||
|
segDisplaySet,
|
||||||
|
referencedDisplaySet,
|
||||||
|
toolGroupId,
|
||||||
|
props,
|
||||||
|
viewportOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (segIsLoading) {
|
if (segIsLoading) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ohif/extension-cornerstone-dicom-sr",
|
"name": "@ohif/extension-cornerstone-dicom-sr",
|
||||||
"version": "3.13.0-beta.95",
|
"version": "3.13.0-beta.125",
|
||||||
"description": "OHIF extension for an SR Cornerstone Viewport",
|
"description": "OHIF extension for an SR Cornerstone Viewport",
|
||||||
"author": "OHIF",
|
"author": "OHIF",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -32,7 +32,7 @@
|
|||||||
"@ohif/core": "workspace:*",
|
"@ohif/core": "workspace:*",
|
||||||
"@ohif/extension-cornerstone": "workspace:*",
|
"@ohif/extension-cornerstone": "workspace:*",
|
||||||
"@ohif/ui": "workspace:*",
|
"@ohif/ui": "workspace:*",
|
||||||
"dcmjs": "0.49.4",
|
"dcmjs": "0.52.0",
|
||||||
"dicom-parser": "1.8.21",
|
"dicom-parser": "1.8.21",
|
||||||
"hammerjs": "2.0.8",
|
"hammerjs": "2.0.8",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
@ -40,9 +40,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.29.7",
|
"@babel/runtime": "7.29.7",
|
||||||
"@cornerstonejs/adapters": "5.0.2",
|
"@cornerstonejs/adapters": "5.4.17",
|
||||||
"@cornerstonejs/core": "5.0.2",
|
"@cornerstonejs/core": "5.4.17",
|
||||||
"@cornerstonejs/tools": "5.0.2",
|
"@cornerstonejs/tools": "5.4.17",
|
||||||
"classnames": "2.5.1"
|
"classnames": "2.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -142,15 +142,22 @@ export default function hydrateStructuredReport(
|
|||||||
hydratableMeasurementsInSR,
|
hydratableMeasurementsInSR,
|
||||||
sopInstanceUIDToImageId
|
sopInstanceUIDToImageId
|
||||||
);
|
);
|
||||||
|
const displaySetsByFrameOfReferenceUID = new Map();
|
||||||
|
|
||||||
for (const FrameOfReferenceUID of frameOfReferenceUIDs) {
|
for (const FrameOfReferenceUID of frameOfReferenceUIDs) {
|
||||||
const displaySetsFOR = displaySetService.getDisplaySetsBy(
|
const displaySetsFOR = displaySetService.getDisplaySetsBy(
|
||||||
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet
|
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet
|
||||||
);
|
);
|
||||||
const ds = chooseDisplaySet(displaySetsFOR, FrameOfReferenceUID);
|
const ds = getReferencedDisplaySet(
|
||||||
|
displaySet,
|
||||||
|
displaySetsFOR,
|
||||||
|
FrameOfReferenceUID,
|
||||||
|
displaySetService
|
||||||
|
);
|
||||||
if (!ds) {
|
if (!ds) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
displaySetsByFrameOfReferenceUID.set(FrameOfReferenceUID, ds);
|
||||||
if (!SeriesInstanceUIDs.includes(ds.SeriesInstanceUID)) {
|
if (!SeriesInstanceUIDs.includes(ds.SeriesInstanceUID)) {
|
||||||
SeriesInstanceUIDs.push(ds.SeriesInstanceUID);
|
SeriesInstanceUIDs.push(ds.SeriesInstanceUID);
|
||||||
}
|
}
|
||||||
@ -174,7 +181,7 @@ export default function hydrateStructuredReport(
|
|||||||
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
|
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
|
||||||
|
|
||||||
if (!imageId) {
|
if (!imageId) {
|
||||||
return getReferenceData3D(toolData, servicesManager);
|
return getReferenceData3D(toolData, servicesManager, displaySetsByFrameOfReferenceUID);
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = metaData.get('instance', imageId);
|
const instance = metaData.get('instance', imageId);
|
||||||
@ -316,21 +323,60 @@ function chooseDisplaySet(displaySets, reference) {
|
|||||||
console.warn('No display set found for', reference);
|
console.warn('No display set found for', reference);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (displaySets.length === 1) {
|
const sortedDisplaySets = OHIF.utils.sortDisplaySetsCopy(displaySets);
|
||||||
return displaySets[0];
|
if (sortedDisplaySets.length === 1) {
|
||||||
|
return sortedDisplaySets[0];
|
||||||
}
|
}
|
||||||
const volumeDs = displaySets.find(ds => ds.isReconstructable);
|
const volumeDs = sortedDisplaySets.find(ds => ds.isReconstructable);
|
||||||
if (volumeDs) {
|
if (volumeDs) {
|
||||||
return volumeDs;
|
return volumeDs;
|
||||||
}
|
}
|
||||||
return displaySets[0];
|
return sortedDisplaySets[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCOORD3D only identifies a frame of reference, so many series can be valid
|
||||||
|
* candidates. The SR loader has already selected and recorded a stable display
|
||||||
|
* set for each measurement. Reuse that selection during hydration so the
|
||||||
|
* viewport series and annotation volume cannot depend on display-set load order.
|
||||||
|
*/
|
||||||
|
function getReferencedDisplaySet(
|
||||||
|
srDisplaySet,
|
||||||
|
displaySets,
|
||||||
|
FrameOfReferenceUID,
|
||||||
|
displaySetService
|
||||||
|
) {
|
||||||
|
const referencedDisplaySetInstanceUID = srDisplaySet.measurements?.find(measurement =>
|
||||||
|
measurement.coords?.some(
|
||||||
|
coord =>
|
||||||
|
coord.ValueType === 'SCOORD3D' &&
|
||||||
|
coord.ReferencedFrameOfReferenceSequence === FrameOfReferenceUID
|
||||||
|
)
|
||||||
|
)?.displaySetInstanceUID;
|
||||||
|
|
||||||
|
const referencedDisplaySet = referencedDisplaySetInstanceUID
|
||||||
|
? displaySetService.getDisplaySetByUID(referencedDisplaySetInstanceUID)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
referencedDisplaySet?.FrameOfReferenceUID === FrameOfReferenceUID &&
|
||||||
|
!referencedDisplaySet.isDerivedDisplaySet
|
||||||
|
) {
|
||||||
|
return referencedDisplaySet;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chooseDisplaySet(displaySets, FrameOfReferenceUID);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the additional reference data appropriate for a 3d reference.
|
* Gets the additional reference data appropriate for a 3d reference.
|
||||||
* This will choose a volume id, frame of reference and a plane restriction.
|
* This will choose a volume id, frame of reference and a plane restriction.
|
||||||
*/
|
*/
|
||||||
function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
|
function getReferenceData3D(
|
||||||
|
toolData,
|
||||||
|
servicesManager: Types.ServicesManager,
|
||||||
|
displaySetsByFrameOfReferenceUID = new Map()
|
||||||
|
) {
|
||||||
const { FrameOfReferenceUID } = toolData.annotation.metadata;
|
const { FrameOfReferenceUID } = toolData.annotation.metadata;
|
||||||
const { points } = toolData.annotation.data.handles;
|
const { points } = toolData.annotation.data.handles;
|
||||||
const { displaySetService } = servicesManager.services;
|
const { displaySetService } = servicesManager.services;
|
||||||
@ -342,7 +388,9 @@ function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
|
|||||||
FrameOfReferenceUID,
|
FrameOfReferenceUID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const ds = chooseDisplaySet(displaySetsFOR, toolData.annotation);
|
const ds =
|
||||||
|
displaySetsByFrameOfReferenceUID.get(FrameOfReferenceUID) ||
|
||||||
|
chooseDisplaySet(displaySetsFOR, toolData.annotation);
|
||||||
const cameraView = chooseCameraView(ds, points);
|
const cameraView = chooseCameraView(ds, points);
|
||||||
|
|
||||||
const viewReference = {
|
const viewReference = {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ohif/extension-cornerstone-dynamic-volume",
|
"name": "@ohif/extension-cornerstone-dynamic-volume",
|
||||||
"version": "3.13.0-beta.95",
|
"version": "3.13.0-beta.125",
|
||||||
"description": "OHIF extension for 4D volumes data",
|
"description": "OHIF extension for 4D volumes data",
|
||||||
"author": "OHIF",
|
"author": "OHIF",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"@ohif/extension-default": "workspace:*",
|
"@ohif/extension-default": "workspace:*",
|
||||||
"@ohif/i18n": "workspace:*",
|
"@ohif/i18n": "workspace:*",
|
||||||
"@ohif/ui": "workspace:*",
|
"@ohif/ui": "workspace:*",
|
||||||
"dcmjs": "0.49.4",
|
"dcmjs": "0.52.0",
|
||||||
"dicom-parser": "1.8.21",
|
"dicom-parser": "1.8.21",
|
||||||
"hammerjs": "2.0.8",
|
"hammerjs": "2.0.8",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
@ -45,8 +45,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.29.7",
|
"@babel/runtime": "7.29.7",
|
||||||
"@cornerstonejs/core": "5.0.2",
|
"@cornerstonejs/core": "5.4.17",
|
||||||
"@cornerstonejs/tools": "5.0.2",
|
"@cornerstonejs/tools": "5.4.17",
|
||||||
"classnames": "2.5.1"
|
"classnames": "2.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ohif/extension-cornerstone",
|
"name": "@ohif/extension-cornerstone",
|
||||||
"version": "3.13.0-beta.95",
|
"version": "3.13.0-beta.125",
|
||||||
"description": "OHIF extension for Cornerstone",
|
"description": "OHIF extension for Cornerstone",
|
||||||
"author": "OHIF",
|
"author": "OHIF",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -38,11 +38,11 @@
|
|||||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
|
"@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2",
|
||||||
"@cornerstonejs/codec-openjpeg": "1.3.0",
|
"@cornerstonejs/codec-openjpeg": "1.3.0",
|
||||||
"@cornerstonejs/codec-openjph": "2.4.7",
|
"@cornerstonejs/codec-openjph": "2.4.7",
|
||||||
"@cornerstonejs/dicom-image-loader": "5.0.2",
|
"@cornerstonejs/dicom-image-loader": "5.4.17",
|
||||||
"@ohif/core": "workspace:*",
|
"@ohif/core": "workspace:*",
|
||||||
"@ohif/extension-default": "workspace:*",
|
"@ohif/extension-default": "workspace:*",
|
||||||
"@ohif/ui": "workspace:*",
|
"@ohif/ui": "workspace:*",
|
||||||
"dcmjs": "0.49.4",
|
"dcmjs": "0.52.0",
|
||||||
"dicom-parser": "1.8.21",
|
"dicom-parser": "1.8.21",
|
||||||
"hammerjs": "2.0.8",
|
"hammerjs": "2.0.8",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
@ -52,13 +52,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.29.7",
|
"@babel/runtime": "7.29.7",
|
||||||
"@cornerstonejs/adapters": "5.0.2",
|
"@cornerstonejs/adapters": "5.4.17",
|
||||||
"@cornerstonejs/ai": "5.0.2",
|
"@cornerstonejs/ai": "5.4.17",
|
||||||
"@cornerstonejs/core": "5.0.2",
|
"@cornerstonejs/core": "5.4.17",
|
||||||
"@cornerstonejs/labelmap-interpolation": "5.0.2",
|
"@cornerstonejs/labelmap-interpolation": "5.4.17",
|
||||||
"@cornerstonejs/metadata": "5.0.2",
|
"@cornerstonejs/metadata": "5.4.17",
|
||||||
"@cornerstonejs/polymorphic-segmentation": "5.0.2",
|
"@cornerstonejs/polymorphic-segmentation": "5.4.17",
|
||||||
"@cornerstonejs/tools": "5.0.2",
|
"@cornerstonejs/tools": "5.4.17",
|
||||||
"@icr/polyseg-wasm": "0.4.0",
|
"@icr/polyseg-wasm": "0.4.0",
|
||||||
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
|
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
|
||||||
"@kitware/vtk.js": "35.5.3",
|
"@kitware/vtk.js": "35.5.3",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { vec3 } from 'gl-matrix';
|
import { vec3 } from 'gl-matrix';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { metaData, Enums, utilities, eventTarget } from '@cornerstonejs/core';
|
import { metaData, Enums, eventTarget } from '@cornerstonejs/core';
|
||||||
import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools';
|
import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools';
|
||||||
import type { ImageSliceData } from '@cornerstonejs/core/types';
|
import type { ImageSliceData } from '@cornerstonejs/core/types';
|
||||||
import { ViewportOverlay, formatDICOMDate } from '@ohif/ui-next';
|
import { ViewportOverlay, formatDICOMDate } from '@ohif/ui-next';
|
||||||
@ -9,12 +9,14 @@ import type { InstanceMetadata } from '@ohif/core/src/types';
|
|||||||
import { formatDICOMTime, formatNumberPrecision } from './utils';
|
import { formatDICOMTime, formatNumberPrecision } from './utils';
|
||||||
import { utils } from '@ohif/core';
|
import { utils } from '@ohif/core';
|
||||||
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
||||||
|
import { getViewportAdapter } from '../../services/ViewportService/adapter';
|
||||||
|
import { getViewportDataShapeType } from '../../utils/viewportDataShape';
|
||||||
|
|
||||||
import './CustomizableViewportOverlay.css';
|
import './CustomizableViewportOverlay.css';
|
||||||
import { useViewportRendering } from '../../hooks';
|
import { useViewportRendering } from '../../hooks';
|
||||||
|
|
||||||
const EPSILON = 1e-4;
|
const EPSILON = 1e-4;
|
||||||
const { formatPN } = utils;
|
const { formatPN, formatValue } = utils;
|
||||||
|
|
||||||
type ViewportData = StackViewportData | VolumeViewportData;
|
type ViewportData = StackViewportData | VolumeViewportData;
|
||||||
|
|
||||||
@ -184,7 +186,12 @@ function CustomizableViewportOverlay({
|
|||||||
} else {
|
} else {
|
||||||
const renderItem = customizationService.transform(item);
|
const renderItem = customizationService.transform(item);
|
||||||
|
|
||||||
if (typeof renderItem.contentF === 'function') {
|
if (
|
||||||
|
renderItem &&
|
||||||
|
typeof renderItem === 'object' &&
|
||||||
|
'contentF' in renderItem &&
|
||||||
|
typeof renderItem.contentF === 'function'
|
||||||
|
) {
|
||||||
return renderItem.contentF(overlayItemProps);
|
return renderItem.contentF(overlayItemProps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -264,7 +271,7 @@ function getDisplaySets(viewportData, displaySetService) {
|
|||||||
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
|
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
|
||||||
let instanceNumber;
|
let instanceNumber;
|
||||||
|
|
||||||
switch (viewportData.viewportType) {
|
switch (getViewportDataShapeType(viewportData)) {
|
||||||
case Enums.ViewportType.STACK:
|
case Enums.ViewportType.STACK:
|
||||||
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
|
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
|
||||||
break;
|
break;
|
||||||
@ -331,8 +338,11 @@ function _getInstanceNumberFromVolume(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const camera = cornerstoneViewport.getCamera();
|
const viewPlaneNormal = getViewportAdapter(cornerstoneViewport).getViewPlaneNormal();
|
||||||
const { viewPlaneNormal } = camera;
|
|
||||||
|
if (!viewPlaneNormal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
|
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
|
||||||
|
|
||||||
const scanAxisNormal = direction.slice(6, 9);
|
const scanAxisNormal = direction.slice(6, 9);
|
||||||
@ -345,7 +355,10 @@ function _getInstanceNumberFromVolume(
|
|||||||
const imageId = imageIds[imageIndex];
|
const imageId = imageIds[imageIndex];
|
||||||
|
|
||||||
if (!imageId) {
|
if (!imageId) {
|
||||||
return {};
|
// No image at this index (e.g. a single-image volume scrolled out of
|
||||||
|
// range). Return undefined so the overlay falls back to the slice count
|
||||||
|
// instead of rendering an empty object as "[object Object]".
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
|
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
|
||||||
@ -357,7 +370,8 @@ function OverlayItem(props) {
|
|||||||
const { instance, customization = {} } = props;
|
const { instance, customization = {} } = props;
|
||||||
const { color, attribute, title, label, background } = customization;
|
const { color, attribute, title, label, background } = customization;
|
||||||
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
|
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
|
||||||
if (value === undefined || value === null) {
|
const displayValue = formatValue(value);
|
||||||
|
if (displayValue === null || displayValue === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@ -367,7 +381,7 @@ function OverlayItem(props) {
|
|||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
|
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
|
||||||
<span className="ml-0 shrink-0">{value}</span>
|
<span className="ml-0 shrink-0">{displayValue}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Enums, utilities as csUtils } from '@cornerstonejs/core';
|
import { utilities as csUtils } from '@cornerstonejs/core';
|
||||||
import { ImageScrollbar } from '@ohif/ui-next';
|
import { ImageScrollbar } from '@ohif/ui-next';
|
||||||
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
|
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
|
||||||
|
import { getSliceEventName, getViewportSliceCount } from '../../utils/viewportDataShape';
|
||||||
|
|
||||||
function CornerstoneImageScrollbar({
|
function CornerstoneImageScrollbar({
|
||||||
viewportData,
|
viewportData,
|
||||||
@ -47,10 +48,10 @@ function CornerstoneImageScrollbar({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const imageIndex = viewport.getCurrentImageIdIndex();
|
const imageIndex = viewport.getCurrentImageIdIndex();
|
||||||
const numberOfSlices = viewport.getNumberOfSlices();
|
const numberOfSlices = getViewportSliceCount(viewportData, viewport);
|
||||||
|
|
||||||
setImageSliceData({
|
setImageSliceData({
|
||||||
imageIndex: imageIndex,
|
imageIndex,
|
||||||
numberOfSlices,
|
numberOfSlices,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -62,11 +63,7 @@ function CornerstoneImageScrollbar({
|
|||||||
if (!viewportData) {
|
if (!viewportData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { viewportType } = viewportData;
|
const eventId = getSliceEventName(viewportData);
|
||||||
const eventId =
|
|
||||||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
|
|
||||||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
|
||||||
Enums.Events.IMAGE_RENDERED;
|
|
||||||
|
|
||||||
const updateIndex = event => {
|
const updateIndex = event => {
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { vec3 } from 'gl-matrix';
|
|||||||
|
|
||||||
import './ViewportOrientationMarkers.css';
|
import './ViewportOrientationMarkers.css';
|
||||||
import { useViewportRendering } from '../../hooks';
|
import { useViewportRendering } from '../../hooks';
|
||||||
|
import { getViewportDataShapeType } from '../../utils/viewportDataShape';
|
||||||
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
|
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
|
||||||
|
|
||||||
function ViewportOrientationMarkers({
|
function ViewportOrientationMarkers({
|
||||||
@ -46,7 +47,9 @@ function ViewportOrientationMarkers({
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewportData.viewportType === 'stack') {
|
// Use the persisted data shape, not viewportType: a native stack reports
|
||||||
|
// PLANAR_NEXT, which would skip this synthetic-IOP default-cosine guard.
|
||||||
|
if (getViewportDataShapeType(viewportData) === Enums.ViewportType.STACK) {
|
||||||
const imageIndex = imageSliceData.imageIndex;
|
const imageIndex = imageSliceData.imageIndex;
|
||||||
const imageId = viewportData.data[0].imageIds?.[imageIndex];
|
const imageId = viewportData.data[0].imageIds?.[imageIndex];
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Enums } from '@cornerstonejs/core';
|
|
||||||
import { ViewportData } from './types';
|
import { ViewportData } from './types';
|
||||||
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||||
|
import { getViewportAdapter } from '../../../services/ViewportService/adapter';
|
||||||
|
|
||||||
export function getImageIndexFromEvent(event): number | undefined {
|
export function getImageIndexFromEvent(event): number | undefined {
|
||||||
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;
|
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;
|
||||||
@ -24,12 +24,17 @@ export function isProgressFullMode(viewportData: ViewportData, viewport): boolea
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewportData.viewportType === Enums.ViewportType.STACK) {
|
// A stack renders the full progress UI; an acquisition-plane volume is the
|
||||||
|
// volume-mode equivalent. The adapter classifies both lanes (legacy by
|
||||||
|
// viewport type / isInAcquisitionPlane; native by content mode + view-state
|
||||||
|
// orientation, since PLANAR_NEXT collapses the runtime type).
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
const shape = adapter.getShape();
|
||||||
|
if (shape === 'stack') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (shape === 'volume') {
|
||||||
if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
|
return adapter.isInAcquisitionPlane();
|
||||||
return !!viewport.isInAcquisitionPlane?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import { cache as cornerstoneCache, Enums, eventTarget, utilities } from '@cornerstonejs/core';
|
||||||
cache as cornerstoneCache,
|
|
||||||
Enums,
|
|
||||||
eventTarget,
|
|
||||||
utilities,
|
|
||||||
} from '@cornerstonejs/core';
|
|
||||||
import { useByteArray } from '@ohif/ui-next';
|
import { useByteArray } from '@ohif/ui-next';
|
||||||
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||||
|
import { getSliceEventName, getViewportSliceCount } from '../../../utils/viewportDataShape';
|
||||||
import { getImageIdFromCacheEvent, getImageIndexFromEvent, isProgressFullMode } from './helpers';
|
import { getImageIdFromCacheEvent, getImageIndexFromEvent, isProgressFullMode } from './helpers';
|
||||||
import { ImageSliceData, ViewportData } from './types';
|
import { ImageSliceData, ViewportData } from './types';
|
||||||
|
|
||||||
@ -97,26 +93,48 @@ export function useViewportSliceSync({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
// Last values we pushed, so re-seeding on camera changes does not churn React
|
||||||
if (viewport && !isVolume3DViewportType(viewport)) {
|
// state on pure pan/zoom (which keep the slice geometry unchanged).
|
||||||
|
const lastSlice = { imageIndex: -1, numberOfSlices: -1 };
|
||||||
|
|
||||||
|
const pushSliceData = (imageIndex: number, numberOfSlices: number) => {
|
||||||
|
if (imageIndex === lastSlice.imageIndex && numberOfSlices === lastSlice.numberOfSlices) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSlice.imageIndex = imageIndex;
|
||||||
|
lastSlice.numberOfSlices = numberOfSlices;
|
||||||
|
setImageSliceData({ imageIndex, numberOfSlices });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seeds the shared slice state from the live viewport. Re-run on the initial
|
||||||
|
// effect and on camera/orientation changes (below).
|
||||||
|
const syncFromViewport = () => {
|
||||||
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
|
if (!viewport || isVolume3DViewportType(viewport)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const currentImageIndex = viewport.getCurrentImageIdIndex();
|
const currentImageIndex = viewport.getCurrentImageIdIndex();
|
||||||
const currentNumberOfSlices = viewport.getNumberOfSlices();
|
const currentNumberOfSlices = getViewportSliceCount(viewportData, viewport);
|
||||||
|
|
||||||
setImageSliceData({
|
pushSliceData(currentImageIndex, currentNumberOfSlices);
|
||||||
imageIndex: currentImageIndex,
|
|
||||||
numberOfSlices: currentNumberOfSlices,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const { viewportType } = viewportData;
|
syncFromViewport();
|
||||||
const eventId =
|
|
||||||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
|
// A post-mount camera carry (e.g. the layout-selector MPR protocol restoring
|
||||||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
// the prior stack slice onto the freshly-mounted volume viewport) moves the
|
||||||
Enums.Events.IMAGE_RENDERED;
|
// camera and fires its slice events synchronously during the mount — before
|
||||||
|
// these listeners attach and around the initial seed above — so the scrollbar
|
||||||
|
// can latch the mount-time index instead of the carried slice. Re-seed once on
|
||||||
|
// the next frame, after the mount+carry settles; pushSliceData makes it a
|
||||||
|
// no-op when nothing changed (no churn/flicker).
|
||||||
|
const reseedRaf = requestAnimationFrame(syncFromViewport);
|
||||||
|
|
||||||
|
const eventId = getSliceEventName(viewportData);
|
||||||
|
|
||||||
const updateIndex = event => {
|
const updateIndex = event => {
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
@ -130,16 +148,22 @@ export function useViewportSliceSync({
|
|||||||
}
|
}
|
||||||
const nextNumberOfSlices = viewport.getNumberOfSlices();
|
const nextNumberOfSlices = viewport.getNumberOfSlices();
|
||||||
|
|
||||||
setImageSliceData({
|
pushSliceData(nextImageIndex, nextNumberOfSlices);
|
||||||
imageIndex: nextImageIndex,
|
|
||||||
numberOfSlices: nextNumberOfSlices,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
element.addEventListener(eventId, updateIndex);
|
element.addEventListener(eventId, updateIndex);
|
||||||
|
// Native ("next") viewports keep the same viewportData across a stack->volume
|
||||||
|
// transition or an orientation change, so this effect does not re-run and the
|
||||||
|
// slice-navigation event above may not fire until the first scroll, leaving the
|
||||||
|
// scrollbar unseeded (or stale, with a now-wrong slice count). CAMERA_MODIFIED
|
||||||
|
// fires on those orientation/geometry changes, so re-seed from the viewport
|
||||||
|
// then; the pushSliceData guard makes pan/zoom (same geometry) a no-op.
|
||||||
|
element.addEventListener(Enums.Events.CAMERA_MODIFIED, syncFromViewport);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelAnimationFrame(reseedRaf);
|
||||||
element.removeEventListener(eventId, updateIndex);
|
element.removeEventListener(eventId, updateIndex);
|
||||||
|
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, syncFromViewport);
|
||||||
};
|
};
|
||||||
}, [viewportData, element, viewportId, cornerstoneViewportService, setImageSliceData]);
|
}, [viewportData, element, viewportId, cornerstoneViewportService, setImageSliceData]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
annotation,
|
annotation,
|
||||||
Types as ToolTypes,
|
Types as ToolTypes,
|
||||||
SplineContourSegmentationTool,
|
SplineContourSegmentationTool,
|
||||||
|
cancelActiveManipulations,
|
||||||
} from '@cornerstonejs/tools';
|
} from '@cornerstonejs/tools';
|
||||||
import {
|
import {
|
||||||
SegmentInfo,
|
SegmentInfo,
|
||||||
@ -28,8 +29,10 @@ import {
|
|||||||
colorPickerDialog,
|
colorPickerDialog,
|
||||||
callInputDialog,
|
callInputDialog,
|
||||||
} from '@ohif/extension-default';
|
} from '@ohif/extension-default';
|
||||||
import { vec3, mat4 } from 'gl-matrix';
|
|
||||||
import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync';
|
import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync';
|
||||||
|
// Sanctioned flag read: RTSTRUCT contour hydration pins the referenced image to
|
||||||
|
// stack mode on the native ("next") path, a decision made before a target viewport exists.
|
||||||
|
import { getHydrationViewportTypeForModality } from './utils/nextViewportPolicies';
|
||||||
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
|
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
|
||||||
import { getViewportEnabledElement } from './utils/getViewportEnabledElement';
|
import { getViewportEnabledElement } from './utils/getViewportEnabledElement';
|
||||||
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
|
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
|
||||||
@ -40,6 +43,8 @@ import {
|
|||||||
isVolume3DViewportType,
|
isVolume3DViewportType,
|
||||||
isVolumeViewportType,
|
isVolumeViewportType,
|
||||||
} from './utils/getLegacyViewportType';
|
} from './utils/getLegacyViewportType';
|
||||||
|
import { viewportOperations as ops } from './services/ViewportService/backends/viewportOperations';
|
||||||
|
import { getViewportAdapter } from './services/ViewportService/adapter';
|
||||||
import {
|
import {
|
||||||
usePositionPresentationStore,
|
usePositionPresentationStore,
|
||||||
useSegmentationPresentationStore,
|
useSegmentationPresentationStore,
|
||||||
@ -51,8 +56,6 @@ import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats
|
|||||||
import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport';
|
import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport';
|
||||||
import { getUpdatedViewportsForSegmentation } from './utils/hydrationUtils';
|
import { getUpdatedViewportsForSegmentation } from './utils/hydrationUtils';
|
||||||
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
|
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
|
||||||
import { isMeasurementWithinViewport } from './utils/isMeasurementWithinViewport';
|
|
||||||
import { getCenterExtent } from './utils/getCenterExtent';
|
|
||||||
import { EasingFunctionEnum } from './utils/transitions';
|
import { EasingFunctionEnum } from './utils/transitions';
|
||||||
import { createSegmentationForViewport } from './utils/createSegmentationForViewport';
|
import { createSegmentationForViewport } from './utils/createSegmentationForViewport';
|
||||||
import { utilities as segmentationUtilities } from '@cornerstonejs/tools/segmentation';
|
import { utilities as segmentationUtilities } from '@cornerstonejs/tools/segmentation';
|
||||||
@ -145,6 +148,15 @@ function commandsModule({
|
|||||||
return getViewportEnabledElement(viewportId);
|
return getViewportEnabledElement(viewportId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves the cornerstone viewport for a command: the given viewport id, else the
|
||||||
|
// active one. Returns undefined when nothing is enabled.
|
||||||
|
function _resolveViewport(viewportId?: string) {
|
||||||
|
const enabledElement = viewportId
|
||||||
|
? _getViewportEnabledElement(viewportId)
|
||||||
|
: _getActiveViewportEnabledElement();
|
||||||
|
return enabledElement?.viewport;
|
||||||
|
}
|
||||||
|
|
||||||
function _getActiveViewportToolGroupId() {
|
function _getActiveViewportToolGroupId() {
|
||||||
const viewport = _getActiveViewportEnabledElement();
|
const viewport = _getActiveViewportEnabledElement();
|
||||||
const toolGroup = viewport && toolGroupService.getToolGroupForViewport(viewport.id);
|
const toolGroup = viewport && toolGroupService.getToolGroupForViewport(viewport.id);
|
||||||
@ -237,23 +249,11 @@ function commandsModule({
|
|||||||
viewport.setViewReference(metadata);
|
viewport.setViewReference(metadata);
|
||||||
viewport.render();
|
viewport.render();
|
||||||
|
|
||||||
/**
|
// If the measurement is not visible inside the current viewport, move the
|
||||||
* If the measurement is not visible inside the current viewport,
|
// camera to it. The operations backend handles the lane: legacy re-centers
|
||||||
* we need to move the camera to the measurement.
|
// in-plane (getCamera/setCamera), native skips it (no in-plane pan yet, CS-14)
|
||||||
*/
|
// since setViewReference above already navigated to the measurement's slice.
|
||||||
if (!isMeasurementWithinViewport(viewport, measurement)) {
|
if (ops.centerOnMeasurement(viewport, measurement)) {
|
||||||
const camera = viewport.getCamera();
|
|
||||||
const { focalPoint: cameraFocalPoint, position: cameraPosition } = camera;
|
|
||||||
const { center, extent } = getCenterExtent(measurement);
|
|
||||||
const position = vec3.sub(vec3.create(), cameraPosition, cameraFocalPoint);
|
|
||||||
vec3.add(position, position, center);
|
|
||||||
viewport.setCamera({ focalPoint: center, position: position as any });
|
|
||||||
/** Zoom out if the measurement is too large */
|
|
||||||
const measurementSize = vec3.dist(extent.min, extent.max);
|
|
||||||
if (measurementSize > camera.parallelScale) {
|
|
||||||
const scaleFactor = measurementSize / camera.parallelScale;
|
|
||||||
viewport.setZoom(viewport.getZoom() / scaleFactor);
|
|
||||||
}
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,6 +306,20 @@ function commandsModule({
|
|||||||
commandsManager.run('setDisplaySetsForViewports', { viewportsToUpdate: updatedViewports });
|
commandsManager.run('setDisplaySetsForViewports', { viewportsToUpdate: updatedViewports });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels any in-progress annotation manipulation (e.g. drawing a Spline,
|
||||||
|
* Livewire or PlanarFreehand contour) on the active viewport. Reached on
|
||||||
|
* Escape via the `cancelActiveOperation` command. `cancelActiveManipulations`
|
||||||
|
* invokes the `cancel` method of each active/passive tool that has an
|
||||||
|
* in-progress annotation, so it is a no-op when nothing is being drawn.
|
||||||
|
*/
|
||||||
|
cancelMeasurement: () => {
|
||||||
|
const element = _getActiveViewportEnabledElement()?.viewport?.element;
|
||||||
|
if (element) {
|
||||||
|
cancelActiveManipulations(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
hydrateSecondaryDisplaySet: async ({ displaySet, viewportId }) => {
|
hydrateSecondaryDisplaySet: async ({ displaySet, viewportId }) => {
|
||||||
if (!displaySet) {
|
if (!displaySet) {
|
||||||
return;
|
return;
|
||||||
@ -357,6 +371,9 @@ function commandsModule({
|
|||||||
const results = commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', {
|
const results = commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', {
|
||||||
viewportId,
|
viewportId,
|
||||||
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
|
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
|
||||||
|
// RTSTRUCT-on-next pins the referenced image to stack mode on hydrate;
|
||||||
|
// see the policy's rationale in utils/nextViewportPolicies.
|
||||||
|
viewportType: getHydrationViewportTypeForModality(displaySet.Modality),
|
||||||
});
|
});
|
||||||
|
|
||||||
const disableEditing = customizationService.getCustomization(
|
const disableEditing = customizationService.getCustomization(
|
||||||
@ -906,34 +923,28 @@ function commandsModule({
|
|||||||
const windowWidthNum = Number(windowWidth);
|
const windowWidthNum = Number(windowWidth);
|
||||||
const windowCenterNum = Number(windowCenter);
|
const windowCenterNum = Number(windowCenter);
|
||||||
|
|
||||||
// get actor from the viewport
|
|
||||||
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
|
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
|
||||||
const viewport = renderingEngine.getViewport(viewportId);
|
const viewport = renderingEngine.getViewport(viewportId);
|
||||||
|
|
||||||
const { lower, upper } = csUtils.windowLevel.toLowHighRange(windowWidthNum, windowCenterNum);
|
// Stale/invalid viewport ids resolve to undefined; bail out before the VOI
|
||||||
|
// apply + render below would throw.
|
||||||
if (isVolumeViewportType(viewport)) {
|
if (!viewport) {
|
||||||
const volumeId = actions.getVolumeIdForDisplaySet({
|
return;
|
||||||
viewportId,
|
|
||||||
displaySetInstanceUID,
|
|
||||||
});
|
|
||||||
viewport.setProperties(
|
|
||||||
{
|
|
||||||
voiRange: {
|
|
||||||
upper,
|
|
||||||
lower,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
volumeId
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
viewport.setProperties({
|
|
||||||
voiRange: {
|
|
||||||
upper,
|
|
||||||
lower,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy volume viewports target a specific volume; the command owns that
|
||||||
|
// resolution (it needs the service). The operations backend applies the VOI
|
||||||
|
// (legacy setProperties vs native setDisplaySetPresentation on the active binding).
|
||||||
|
const volumeId = isVolumeViewportType(viewport)
|
||||||
|
? actions.getVolumeIdForDisplaySet({ viewportId, displaySetInstanceUID })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
ops.setWindowLevel(viewport, {
|
||||||
|
windowWidth: windowWidthNum,
|
||||||
|
windowCenter: windowCenterNum,
|
||||||
|
volumeId,
|
||||||
|
displaySetInstanceUID,
|
||||||
|
});
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
toggleViewportColorbar: ({ viewportId, displaySetInstanceUIDs, options = {} }) => {
|
toggleViewportColorbar: ({ viewportId, displaySetInstanceUIDs, options = {} }) => {
|
||||||
@ -1172,25 +1183,11 @@ function commandsModule({
|
|||||||
viewportId?: string;
|
viewportId?: string;
|
||||||
newValue?: 'toggle' | boolean;
|
newValue?: 'toggle' | boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const enabledElement = viewportId
|
const viewport = _resolveViewport(viewportId);
|
||||||
? _getViewportEnabledElement(viewportId)
|
if (!viewport) {
|
||||||
: _getActiveViewportEnabledElement();
|
|
||||||
|
|
||||||
if (!enabledElement) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ops.flipHorizontal(viewport, newValue);
|
||||||
const { viewport } = enabledElement;
|
|
||||||
|
|
||||||
let flipHorizontal: boolean;
|
|
||||||
if (newValue === 'toggle') {
|
|
||||||
const { flipHorizontal: currentHorizontalFlip } = viewport.getCamera();
|
|
||||||
flipHorizontal = !currentHorizontalFlip;
|
|
||||||
} else {
|
|
||||||
flipHorizontal = newValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
viewport.setCamera({ flipHorizontal });
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
flipViewportVertical: ({
|
flipViewportVertical: ({
|
||||||
@ -1200,78 +1197,36 @@ function commandsModule({
|
|||||||
viewportId?: string;
|
viewportId?: string;
|
||||||
newValue?: 'toggle' | boolean;
|
newValue?: 'toggle' | boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const enabledElement = viewportId
|
const viewport = _resolveViewport(viewportId);
|
||||||
? _getViewportEnabledElement(viewportId)
|
if (!viewport) {
|
||||||
: _getActiveViewportEnabledElement();
|
|
||||||
|
|
||||||
if (!enabledElement) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ops.flipVertical(viewport, newValue);
|
||||||
const { viewport } = enabledElement;
|
|
||||||
|
|
||||||
let flipVertical: boolean;
|
|
||||||
if (newValue === 'toggle') {
|
|
||||||
const { flipVertical: currentVerticalFlip } = viewport.getCamera();
|
|
||||||
flipVertical = !currentVerticalFlip;
|
|
||||||
} else {
|
|
||||||
flipVertical = newValue;
|
|
||||||
}
|
|
||||||
viewport.setCamera({ flipVertical });
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
invertViewport: ({ element }) => {
|
invertViewport: ({ element }) => {
|
||||||
let enabledElement;
|
const viewport = element === undefined ? _resolveViewport() : element.viewport;
|
||||||
|
if (!viewport) {
|
||||||
if (element === undefined) {
|
|
||||||
enabledElement = _getActiveViewportEnabledElement();
|
|
||||||
} else {
|
|
||||||
enabledElement = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enabledElement) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ops.invert(viewport);
|
||||||
const { viewport } = enabledElement;
|
|
||||||
|
|
||||||
const { invert } = viewport.getProperties();
|
|
||||||
viewport.setProperties({ invert: !invert });
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
resetViewport: () => {
|
resetViewport: () => {
|
||||||
const enabledElement = _getActiveViewportEnabledElement();
|
const viewport = _resolveViewport();
|
||||||
|
if (!viewport) {
|
||||||
if (!enabledElement) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ops.reset(viewport);
|
||||||
const { viewport } = enabledElement;
|
|
||||||
|
|
||||||
viewport.resetProperties?.();
|
|
||||||
viewport.resetCamera();
|
|
||||||
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
scaleViewport: ({ direction }) => {
|
scaleViewport: ({ direction }) => {
|
||||||
const enabledElement = _getActiveViewportEnabledElement();
|
const viewport = _resolveViewport();
|
||||||
const scaleFactor = direction > 0 ? 0.9 : 1.1;
|
if (!viewport) {
|
||||||
|
|
||||||
if (!enabledElement) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { viewport } = enabledElement;
|
ops.scaleBy(viewport, direction);
|
||||||
|
viewport.render();
|
||||||
if (isStackViewportType(viewport)) {
|
|
||||||
if (direction) {
|
|
||||||
const { parallelScale } = viewport.getCamera();
|
|
||||||
viewport.setCamera({ parallelScale: parallelScale * scaleFactor });
|
|
||||||
viewport.render();
|
|
||||||
} else {
|
|
||||||
viewport.resetCamera();
|
|
||||||
viewport.render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Jumps the active viewport or the specified one to the given slice index */
|
/** Jumps the active viewport or the specified one to the given slice index */
|
||||||
@ -1350,24 +1305,15 @@ function commandsModule({
|
|||||||
// HP takes priority over the default opacity
|
// HP takes priority over the default opacity
|
||||||
colormap = { ...colormap, opacity: hpOpacity || opacity };
|
colormap = { ...colormap, opacity: hpOpacity || opacity };
|
||||||
|
|
||||||
if (isStackViewportType(viewport)) {
|
// The legacy orthographic branch resolves the volumeId from the display set;
|
||||||
viewport.setProperties({ colormap });
|
// fall back to the viewport's first display set (needs viewportGridService, so
|
||||||
|
// it is resolved here in the command rather than in the operations backend).
|
||||||
|
if (isOrthographicViewportType(viewport) && !displaySetInstanceUID) {
|
||||||
|
const { viewports } = viewportGridService.getState();
|
||||||
|
displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOrthographicViewportType(viewport)) {
|
ops.setColormap(viewport, { colormap, displaySetInstanceUID });
|
||||||
if (!displaySetInstanceUID) {
|
|
||||||
const { viewports } = viewportGridService.getState();
|
|
||||||
displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToDo: Find a better way of obtaining the volumeId that corresponds to the displaySetInstanceUID
|
|
||||||
const volumeId =
|
|
||||||
viewport
|
|
||||||
.getAllVolumeIds()
|
|
||||||
.find((_volumeId: string) => _volumeId.includes(displaySetInstanceUID)) ??
|
|
||||||
viewport.getVolumeId();
|
|
||||||
viewport.setProperties({ colormap }, volumeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
viewport.render();
|
viewport.render();
|
||||||
@ -1473,9 +1419,7 @@ function commandsModule({
|
|||||||
if (!viewport) {
|
if (!viewport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
viewport.setProperties({
|
ops.setPreset(viewport, preset);
|
||||||
preset,
|
|
||||||
});
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1487,20 +1431,10 @@ function commandsModule({
|
|||||||
|
|
||||||
setVolumeRenderingQulaity: ({ viewportId, volumeQuality }) => {
|
setVolumeRenderingQulaity: ({ viewportId, volumeQuality }) => {
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
const { actor } = viewport.getActors()[0];
|
if (!viewport) {
|
||||||
const mapper = actor.getMapper();
|
return;
|
||||||
const image = mapper.getInputData();
|
}
|
||||||
const dims = image.getDimensions();
|
ops.setVolumeRenderingQuality(viewport, volumeQuality);
|
||||||
const spacing = image.getSpacing();
|
|
||||||
const spatialDiagonal = vec3.length(
|
|
||||||
vec3.fromValues(dims[0] * spacing[0], dims[1] * spacing[1], dims[2] * spacing[2])
|
|
||||||
);
|
|
||||||
|
|
||||||
let sampleDistance = spacing.reduce((a, b) => a + b) / 3.0;
|
|
||||||
sampleDistance /= volumeQuality > 1 ? 0.5 * volumeQuality ** 2 : 1.0;
|
|
||||||
const samplesPerRay = spatialDiagonal / sampleDistance + 1;
|
|
||||||
mapper.setMaximumSamplesPerRay(samplesPerRay);
|
|
||||||
mapper.setSampleDistance(sampleDistance);
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1511,27 +1445,10 @@ function commandsModule({
|
|||||||
*/
|
*/
|
||||||
shiftVolumeOpacityPoints: ({ viewportId, shift }) => {
|
shiftVolumeOpacityPoints: ({ viewportId, shift }) => {
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
const { actor } = viewport.getActors()[0];
|
if (!viewport) {
|
||||||
const ofun = actor.getProperty().getScalarOpacity(0);
|
return;
|
||||||
|
|
||||||
const opacityPointValues = []; // Array to hold values
|
|
||||||
// Gather Existing Values
|
|
||||||
const size = ofun.getSize();
|
|
||||||
for (let pointIdx = 0; pointIdx < size; pointIdx++) {
|
|
||||||
const opacityPointValue = [0, 0, 0, 0];
|
|
||||||
ofun.getNodeValue(pointIdx, opacityPointValue);
|
|
||||||
// opacityPointValue now holds [xLocation, opacity, midpoint, sharpness]
|
|
||||||
opacityPointValues.push(opacityPointValue);
|
|
||||||
}
|
}
|
||||||
// Add offset
|
ops.shiftVolumeOpacityPoints(viewport, shift);
|
||||||
opacityPointValues.forEach(opacityPointValue => {
|
|
||||||
opacityPointValue[0] += shift; // Change the location value
|
|
||||||
});
|
|
||||||
// Set new values
|
|
||||||
ofun.removeAllPoints();
|
|
||||||
opacityPointValues.forEach(opacityPointValue => {
|
|
||||||
ofun.addPoint(...opacityPointValue);
|
|
||||||
});
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1547,25 +1464,10 @@ function commandsModule({
|
|||||||
|
|
||||||
setVolumeLighting: ({ viewportId, options }) => {
|
setVolumeLighting: ({ viewportId, options }) => {
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
const { actor } = viewport.getActors()[0];
|
if (!viewport) {
|
||||||
const property = actor.getProperty();
|
return;
|
||||||
|
|
||||||
if (options.shade !== undefined) {
|
|
||||||
property.setShade(options.shade);
|
|
||||||
}
|
}
|
||||||
|
ops.setVolumeLighting(viewport, options);
|
||||||
if (options.ambient !== undefined) {
|
|
||||||
property.setAmbient(options.ambient);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.diffuse !== undefined) {
|
|
||||||
property.setDiffuse(options.diffuse);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.specular !== undefined) {
|
|
||||||
property.setSpecular(options.specular);
|
|
||||||
}
|
|
||||||
|
|
||||||
viewport.render();
|
viewport.render();
|
||||||
},
|
},
|
||||||
resetCrosshairs: ({ viewportId }) => {
|
resetCrosshairs: ({ viewportId }) => {
|
||||||
@ -1573,7 +1475,13 @@ function commandsModule({
|
|||||||
|
|
||||||
const getCrosshairInstances = toolGroupId => {
|
const getCrosshairInstances = toolGroupId => {
|
||||||
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
|
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
|
||||||
crosshairInstances.push(toolGroup.getToolInstance('Crosshairs'));
|
// Only fetch the instance when Crosshairs is registered in this tool
|
||||||
|
// group. getToolInstance logs a warning for an unregistered tool, and a
|
||||||
|
// viewport's default tool group does not always include Crosshairs (e.g.
|
||||||
|
// next viewports), which made Reset Viewport log a spurious warning.
|
||||||
|
if (toolGroup?.hasTool('Crosshairs')) {
|
||||||
|
crosshairInstances.push(toolGroup.getToolInstance('Crosshairs'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!viewportId) {
|
if (!viewportId) {
|
||||||
@ -1581,7 +1489,9 @@ function commandsModule({
|
|||||||
toolGroupIds.forEach(getCrosshairInstances);
|
toolGroupIds.forEach(getCrosshairInstances);
|
||||||
} else {
|
} else {
|
||||||
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
||||||
getCrosshairInstances(toolGroup.id);
|
if (toolGroup) {
|
||||||
|
getCrosshairInstances(toolGroup.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
crosshairInstances.forEach(ins => {
|
crosshairInstances.forEach(ins => {
|
||||||
@ -1967,8 +1877,15 @@ function commandsModule({
|
|||||||
* Use it before initializing the toolGroup with the tools.
|
* Use it before initializing the toolGroup with the tools.
|
||||||
*/
|
*/
|
||||||
initializeSegmentLabelTool: ({ tools }) => {
|
initializeSegmentLabelTool: ({ tools }) => {
|
||||||
const appConfig = extensionManager.appConfig;
|
const { customizationService } = servicesManager.services;
|
||||||
const segmentLabelConfig = appConfig.segmentation?.segmentLabel;
|
const segmentLabelConfig = customizationService.getCustomization(
|
||||||
|
'segmentation.segmentLabel'
|
||||||
|
) as {
|
||||||
|
enabledByDefault?: boolean;
|
||||||
|
labelColor?: number[];
|
||||||
|
hoverTimeout?: number;
|
||||||
|
background?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (segmentLabelConfig?.enabledByDefault) {
|
if (segmentLabelConfig?.enabledByDefault) {
|
||||||
const activeTools = tools?.active ?? [];
|
const activeTools = tools?.active ?? [];
|
||||||
@ -2028,6 +1945,24 @@ function commandsModule({
|
|||||||
rejectPreview: () => {
|
rejectPreview: () => {
|
||||||
actions._handlePreviewAction('reject');
|
actions._handlePreviewAction('reject');
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Generic Escape handler. A single Escape press should discard whatever the
|
||||||
|
* user has in progress, but that can be one of two unrelated things: a
|
||||||
|
* provisional segmentation preview, or an annotation being drawn. Rather
|
||||||
|
* than bind both `rejectPreview` and `cancelMeasurement` to `esc` (Mousetrap
|
||||||
|
* keeps only one handler per key, so the second silently shadows the first),
|
||||||
|
* this command orchestrates both single-purpose commands. Each is a no-op
|
||||||
|
* when its state is not active, so running both is safe and order-independent.
|
||||||
|
*/
|
||||||
|
cancelActiveOperation: () => {
|
||||||
|
try {
|
||||||
|
actions.rejectPreview();
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Error rejecting active preview', error);
|
||||||
|
} finally {
|
||||||
|
actions.cancelMeasurement();
|
||||||
|
}
|
||||||
|
},
|
||||||
clearMarkersForMarkerLabelmap: () => {
|
clearMarkersForMarkerLabelmap: () => {
|
||||||
const { viewport } = _getActiveViewportEnabledElement();
|
const { viewport } = _getActiveViewportEnabledElement();
|
||||||
const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroupForViewport(viewport.id);
|
const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroupForViewport(viewport.id);
|
||||||
@ -2125,7 +2060,11 @@ function commandsModule({
|
|||||||
}
|
}
|
||||||
segmentationService.addSegment(activeSegmentation.segmentationId);
|
segmentationService.addSegment(activeSegmentation.segmentationId);
|
||||||
},
|
},
|
||||||
loadSegmentationDisplaySetsForViewport: ({ viewportId, displaySetInstanceUIDs }) => {
|
loadSegmentationDisplaySetsForViewport: ({
|
||||||
|
viewportId,
|
||||||
|
displaySetInstanceUIDs,
|
||||||
|
viewportType,
|
||||||
|
}) => {
|
||||||
const updatedViewports = getUpdatedViewportsForSegmentation({
|
const updatedViewports = getUpdatedViewportsForSegmentation({
|
||||||
viewportId,
|
viewportId,
|
||||||
servicesManager,
|
servicesManager,
|
||||||
@ -2145,13 +2084,21 @@ function commandsModule({
|
|||||||
viewportsToUpdate: updatedViewports.map(viewport => ({
|
viewportsToUpdate: updatedViewports.map(viewport => ({
|
||||||
viewportId: viewport.viewportId,
|
viewportId: viewport.viewportId,
|
||||||
displaySetInstanceUIDs: viewport.displaySetInstanceUIDs,
|
displaySetInstanceUIDs: viewport.displaySetInstanceUIDs,
|
||||||
|
// When the caller pins a viewportType (RTSTRUCT contour hydration on a
|
||||||
|
// native "next" viewport requests 'stack'), force it so the referenced
|
||||||
|
// image stays in that render mode instead of resolving to a volume slice.
|
||||||
|
...(viewportType
|
||||||
|
? { viewportOptions: { ...viewport.viewportOptions, viewportType } }
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setViewportOrientation: ({ viewportId, orientation }) => {
|
setViewportOrientation: ({ viewportId, orientation }) => {
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
|
|
||||||
if (!viewport || !isOrthographicViewportType(viewport)) {
|
// Accept any viewport already rendering volume content (legacy ORTHOGRAPHIC
|
||||||
|
// or a native viewport in volume mode) — both expose setOrientation().
|
||||||
|
if (!viewport || !getViewportAdapter(viewport).canReorientInPlace()) {
|
||||||
console.warn('Orientation can only be set on volume viewports');
|
console.warn('Orientation can only be set on volume viewports');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2223,50 +2170,12 @@ function commandsModule({
|
|||||||
viewportId?: string;
|
viewportId?: string;
|
||||||
rotationMode?: 'apply' | 'set';
|
rotationMode?: 'apply' | 'set';
|
||||||
}) => {
|
}) => {
|
||||||
const enabledElement = viewportId
|
const viewport = _resolveViewport(viewportId);
|
||||||
? _getViewportEnabledElement(viewportId)
|
if (!viewport) {
|
||||||
: _getActiveViewportEnabledElement();
|
|
||||||
|
|
||||||
if (!enabledElement) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ops.rotate(viewport, rotation, rotationMode);
|
||||||
const { viewport } = enabledElement;
|
viewport.render();
|
||||||
|
|
||||||
if (isVolumeViewportType(viewport)) {
|
|
||||||
const camera = viewport.getCamera();
|
|
||||||
const rotAngle = (rotation * Math.PI) / 180;
|
|
||||||
const rotMat = mat4.identity(new Float32Array(16));
|
|
||||||
mat4.rotate(rotMat, rotMat, rotAngle, camera.viewPlaneNormal);
|
|
||||||
const rotatedViewUp = vec3.transformMat4(vec3.create(), camera.viewUp, rotMat);
|
|
||||||
viewport.setCamera({ viewUp: rotatedViewUp as CoreTypes.Point3 });
|
|
||||||
viewport.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewport.getRotation !== undefined) {
|
|
||||||
const { rotation: currentRotation } = viewport.getViewPresentation();
|
|
||||||
const newRotation =
|
|
||||||
rotationMode === 'apply'
|
|
||||||
? (currentRotation + rotation + 360) % 360
|
|
||||||
: (() => {
|
|
||||||
// In 'set' mode, account for the effect horizontal/vertical flips
|
|
||||||
// have on the perceived rotation direction. A single flip mirrors
|
|
||||||
// the image and inverses rotation direction, while two flips
|
|
||||||
// restore the original parity. We therefore invert the rotation
|
|
||||||
// angle when an odd number of flips are applied so that the
|
|
||||||
// requested absolute rotation matches the user expectation.
|
|
||||||
const { flipHorizontal = false, flipVertical = false } =
|
|
||||||
viewport.getViewPresentation();
|
|
||||||
|
|
||||||
const flipsParity = (flipHorizontal ? 1 : 0) + (flipVertical ? 1 : 0);
|
|
||||||
const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation;
|
|
||||||
|
|
||||||
return (effectiveRotation + 360) % 360;
|
|
||||||
})();
|
|
||||||
viewport.setViewPresentation({ rotation: newRotation });
|
|
||||||
viewport.render();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
startRecordingForAnnotationGroup: () => {
|
startRecordingForAnnotationGroup: () => {
|
||||||
cornerstoneTools.AnnotationTool.startGroupRecording();
|
cornerstoneTools.AnnotationTool.startGroupRecording();
|
||||||
@ -2550,6 +2459,9 @@ function commandsModule({
|
|||||||
removeMeasurement: {
|
removeMeasurement: {
|
||||||
commandFn: actions.removeMeasurement,
|
commandFn: actions.removeMeasurement,
|
||||||
},
|
},
|
||||||
|
cancelMeasurement: {
|
||||||
|
commandFn: actions.cancelMeasurement,
|
||||||
|
},
|
||||||
toggleLockMeasurement: {
|
toggleLockMeasurement: {
|
||||||
commandFn: actions.toggleLockMeasurement,
|
commandFn: actions.toggleLockMeasurement,
|
||||||
},
|
},
|
||||||
@ -2798,6 +2710,7 @@ function commandsModule({
|
|||||||
toggleSegmentSelect: actions.toggleSegmentSelect,
|
toggleSegmentSelect: actions.toggleSegmentSelect,
|
||||||
acceptPreview: actions.acceptPreview,
|
acceptPreview: actions.acceptPreview,
|
||||||
rejectPreview: actions.rejectPreview,
|
rejectPreview: actions.rejectPreview,
|
||||||
|
cancelActiveOperation: actions.cancelActiveOperation,
|
||||||
toggleUseCenterSegmentIndex: actions.toggleUseCenterSegmentIndex,
|
toggleUseCenterSegmentIndex: actions.toggleUseCenterSegmentIndex,
|
||||||
toggleLabelmapAssist: actions.toggleLabelmapAssist,
|
toggleLabelmapAssist: actions.toggleLabelmapAssist,
|
||||||
interpolateScrollForMarkerLabelmap: actions.interpolateScrollForMarkerLabelmap,
|
interpolateScrollForMarkerLabelmap: actions.interpolateScrollForMarkerLabelmap,
|
||||||
|
|||||||
@ -60,11 +60,11 @@ const DicomUploadProgressItem = memo(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case UploadStatus.InProgress:
|
case UploadStatus.InProgress:
|
||||||
return <Icons.ByName name="icon-transferring" />;
|
return <Icons.ByName name="icon-transferring" className="text-highlight" />;
|
||||||
case UploadStatus.Failed:
|
case UploadStatus.Failed:
|
||||||
return <Icons.ByName name="icon-alert-small" />;
|
return <Icons.ByName name="icon-alert-small" className="text-destructive" />;
|
||||||
case UploadStatus.Cancelled:
|
case UploadStatus.Cancelled:
|
||||||
return <Icons.ByName name="icon-alert-outline" />;
|
return <Icons.ByName name="icon-alert-outline" className="text-highlight" />;
|
||||||
default:
|
default:
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ function TrackingStatus({ viewportId }: { viewportId: string }) {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span>
|
<span>
|
||||||
<Icons.StatusTracking className="h-4 w-4" />
|
<Icons.StatusTracking className="h-4 w-4 text-highlight" />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
|
|||||||
@ -83,10 +83,16 @@ const ViewportColorbarsContainer = memo(function ViewportColorbarsContainer({
|
|||||||
const { displaySetInstanceUID: dsUID } =
|
const { displaySetInstanceUID: dsUID } =
|
||||||
displaySetService.getDisplaySetByUID(displaySetInstanceUID) ?? {};
|
displaySetService.getDisplaySetByUID(displaySetInstanceUID) ?? {};
|
||||||
|
|
||||||
|
// Default the fused (horizontal) colorbar to the foreground (e.g. the PT
|
||||||
|
// in a PET/CT fusion), which is the meaningful layer. Only fall back to
|
||||||
|
// the background (CT) colorbar when the foreground has been explicitly
|
||||||
|
// faded to zero opacity. Previously a null/undefined opacity (e.g. before
|
||||||
|
// the hook resolved it) also fell through to the background, so the
|
||||||
|
// colorbar would flicker between CT and PT depending on timing.
|
||||||
const targetUID =
|
const targetUID =
|
||||||
opacity === 0 || opacity == null
|
opacity === 0
|
||||||
? backgroundDisplaySet?.displaySetInstanceUID
|
? backgroundDisplaySet?.displaySetInstanceUID
|
||||||
: foregroundDisplaySets[0].displaySetInstanceUID;
|
: foregroundDisplaySets[0]?.displaySetInstanceUID;
|
||||||
|
|
||||||
return dsUID === targetUID;
|
return dsUID === targetUID;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { cn, Icons, useIconPresentation } from '@ohif/ui-next';
|
import { cn, Icons, useIconPresentation } from '@ohif/ui-next';
|
||||||
import { useSystem } from '@ohif/core';
|
import { useSystem } from '@ohif/core';
|
||||||
import { Enums } from '@cornerstonejs/core';
|
import { Enums } from '@cornerstonejs/core';
|
||||||
|
import { getViewportAdapter } from '../../services/ViewportService/adapter';
|
||||||
import { Popover, PopoverTrigger, PopoverContent, Button, useViewportGrid } from '@ohif/ui-next';
|
import { Popover, PopoverTrigger, PopoverContent, Button, useViewportGrid } from '@ohif/ui-next';
|
||||||
|
|
||||||
function ViewportOrientationMenu({
|
function ViewportOrientationMenu({
|
||||||
@ -36,8 +37,6 @@ function ViewportOrientationMenu({
|
|||||||
|
|
||||||
const handleOrientationChange = (orientation: string) => {
|
const handleOrientationChange = (orientation: string) => {
|
||||||
setCurrentOrientation(orientation);
|
setCurrentOrientation(orientation);
|
||||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportIdToUse);
|
|
||||||
const currentViewportType = viewportInfo?.getViewportType();
|
|
||||||
|
|
||||||
if (!displaySets.length) {
|
if (!displaySets.length) {
|
||||||
return;
|
return;
|
||||||
@ -72,8 +71,17 @@ function ViewportOrientationMenu({
|
|||||||
|
|
||||||
const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID);
|
const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID);
|
||||||
|
|
||||||
// If viewport is not already a volume type, we need to convert it
|
// A viewport already rendering in volume mode (legacy ORTHOGRAPHIC OR a next
|
||||||
if (currentViewportType !== Enums.ViewportType.ORTHOGRAPHIC) {
|
// viewport that reports planarNext but renders volume actors, e.g. a CT+PET
|
||||||
|
// fusion) can be reoriented in place. Recreating it via setDisplaySetsForViewports
|
||||||
|
// would pass empty displaySetOptions and drop per-display-set presentation such
|
||||||
|
// as the PET overlay colormap/opacity. Only the genuine stack -> volume (MPR)
|
||||||
|
// case needs recreation.
|
||||||
|
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportIdToUse);
|
||||||
|
const isVolumeMode = !!csViewport && getViewportAdapter(csViewport).canReorientInPlace();
|
||||||
|
|
||||||
|
// If viewport is not already in volume mode, we need to convert it
|
||||||
|
if (!isVolumeMode) {
|
||||||
// Configure the viewport to be a volume viewport with current display sets
|
// Configure the viewport to be a volume viewport with current display sets
|
||||||
const updatedViewport = {
|
const updatedViewport = {
|
||||||
viewportId: viewportIdToUse,
|
viewportId: viewportIdToUse,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { cache as cs3DCache, Types } from '@cornerstonejs/core';
|
|||||||
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
|
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
|
||||||
import { utilities as csUtils } from '@cornerstonejs/core';
|
import { utilities as csUtils } from '@cornerstonejs/core';
|
||||||
import { getViewportVolumeHistogram } from './getViewportVolumeHistogram';
|
import { getViewportVolumeHistogram } from './getViewportVolumeHistogram';
|
||||||
|
import { getViewportAdapter } from '../../services/ViewportService/adapter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets node opacity from volume actor
|
* Gets node opacity from volume actor
|
||||||
@ -101,8 +102,18 @@ export const getWindowLevelsData = async (
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumeIds = (viewport as Types.IBaseVolumeViewport).getAllVolumeIds();
|
// The per-volume histogram WL panel is a legacy volume-viewport feature; the
|
||||||
const viewportProperties = viewport.getProperties();
|
// adapter reports no volumeIds for native viewports (and legacy stacks), so
|
||||||
|
// those degrade gracefully to no histogram rows rather than erroring; the
|
||||||
|
// native WL path is driven by setViewportWindowLevel.
|
||||||
|
// TODO(next): port per-volume histograms to the native volume API.
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
const volumeIds = adapter.getVolumeIds();
|
||||||
|
if (!volumeIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportProperties = adapter.getPresentation();
|
||||||
const { voiRange } = viewportProperties || {};
|
const { voiRange } = viewportProperties || {};
|
||||||
const viewportVoi = voiRange
|
const viewportVoi = voiRange
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -1,15 +1,54 @@
|
|||||||
import React, { ReactElement, useEffect, useRef, useState } from 'react';
|
import React, { ReactElement, useEffect, useRef, useState } from 'react';
|
||||||
import { AllInOneMenu, ScrollArea, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next';
|
import { AllInOneMenu, ScrollArea, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next';
|
||||||
import { useViewportRendering } from '../../hooks/useViewportRendering';
|
import { useViewportRendering } from '../../hooks/useViewportRendering';
|
||||||
|
import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets';
|
||||||
import { WindowLevelPreset } from '../../types/WindowLevel';
|
import { WindowLevelPreset } from '../../types/WindowLevel';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function WindowLevel({ viewportId }: { viewportId?: string } = {}): ReactElement {
|
export function WindowLevel({ viewportId }: { viewportId?: string } = {}): ReactElement {
|
||||||
const { t } = useTranslation('WindowLevelActionMenu');
|
const { t } = useTranslation('WindowLevelActionMenu');
|
||||||
const { viewportDisplaySets } = useViewportRendering(viewportId);
|
const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId);
|
||||||
|
// Default the active tab to the foreground layer (e.g. the PT in a PET/CT
|
||||||
|
// fusion), matching the other window-level controls, instead of the grayscale
|
||||||
|
// background (CT) at index 0. The CT/PT tabs still let the user switch.
|
||||||
|
const defaultDisplaySetUID =
|
||||||
|
foregroundDisplaySets?.length > 0
|
||||||
|
? foregroundDisplaySets[foregroundDisplaySets.length - 1].displaySetInstanceUID
|
||||||
|
: viewportDisplaySets?.[0]?.displaySetInstanceUID;
|
||||||
const [activeDisplaySetUID, setActiveDisplaySetUID] = useState<string | undefined>(
|
const [activeDisplaySetUID, setActiveDisplaySetUID] = useState<string | undefined>(
|
||||||
viewportDisplaySets?.[0]?.displaySetInstanceUID
|
defaultDisplaySetUID
|
||||||
);
|
);
|
||||||
|
// Tracks whether the user has explicitly picked a tab, so the foreground-default
|
||||||
|
// sync below stops overriding their choice.
|
||||||
|
const userSelectedRef = useRef(false);
|
||||||
|
|
||||||
|
// Adopt the foreground default if the display sets resolve after first render
|
||||||
|
// (and the user has not picked a tab yet). This must re-sync even when the
|
||||||
|
// initial render already seeded `activeDisplaySetUID` with the CT fallback
|
||||||
|
// (because `foregroundDisplaySets` was still empty at mount) — otherwise the
|
||||||
|
// tab stays pinned to CT once the foreground (PT) layer resolves.
|
||||||
|
useEffect(() => {
|
||||||
|
// If the active tab's display set is no longer in the viewport (e.g. the
|
||||||
|
// viewport switched to a different study/series), drop the now-stale
|
||||||
|
// selection — including a user pick — so it re-defaults instead of leaving an
|
||||||
|
// invalid UID that later fails validateActiveDisplaySet.
|
||||||
|
const activeStillPresent = viewportDisplaySets?.some(
|
||||||
|
ds => ds.displaySetInstanceUID === activeDisplaySetUID
|
||||||
|
);
|
||||||
|
if (activeDisplaySetUID && viewportDisplaySets?.length && !activeStillPresent) {
|
||||||
|
userSelectedRef.current = false;
|
||||||
|
setActiveDisplaySetUID(defaultDisplaySetUID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!userSelectedRef.current &&
|
||||||
|
defaultDisplaySetUID &&
|
||||||
|
activeDisplaySetUID !== defaultDisplaySetUID
|
||||||
|
) {
|
||||||
|
setActiveDisplaySetUID(defaultDisplaySetUID);
|
||||||
|
}
|
||||||
|
}, [activeDisplaySetUID, defaultDisplaySetUID, viewportDisplaySets]);
|
||||||
|
|
||||||
// Use the hook with the active display set
|
// Use the hook with the active display set
|
||||||
const { windowLevelPresets, setWindowLevel } = useViewportRendering(viewportId, {
|
const { windowLevelPresets, setWindowLevel } = useViewportRendering(viewportId, {
|
||||||
@ -49,6 +88,7 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React
|
|||||||
<Tabs
|
<Tabs
|
||||||
value={activeDisplaySetUID}
|
value={activeDisplaySetUID}
|
||||||
onValueChange={displaySetUID => {
|
onValueChange={displaySetUID => {
|
||||||
|
userSelectedRef.current = true;
|
||||||
setActiveDisplaySetUID(displaySetUID);
|
setActiveDisplaySetUID(displaySetUID);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import type { Button } from '@ohif/core/types';
|
import type { Button } from '@ohif/core/types';
|
||||||
import { ViewportGridService } from '@ohif/core';
|
import { ViewportGridService, ToolbarService } from '@ohif/core';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
|
||||||
import { MIN_SEGMENTATION_DRAWING_RADIUS, MAX_SEGMENTATION_DRAWING_RADIUS } from './constants';
|
const { TOOLBAR_SECTIONS } = ToolbarService;
|
||||||
|
|
||||||
|
export const MIN_SEGMENTATION_DRAWING_RADIUS = 0.5;
|
||||||
|
export const MAX_SEGMENTATION_DRAWING_RADIUS = 99.5;
|
||||||
|
|
||||||
const setToolActiveToolbar = {
|
const setToolActiveToolbar = {
|
||||||
commandName: 'setToolActiveToolbar',
|
commandName: 'setToolActiveToolbar',
|
||||||
@ -20,155 +23,16 @@ const callbacks = (toolName: string) => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const toolbarButtons: Button[] = [
|
/**
|
||||||
{
|
* Segmentation editing toolbar buttons: the toolbox section containers plus
|
||||||
id: 'AdvancedRenderingControls',
|
* the labelmap / contour editing tools and utilities. These complement the
|
||||||
uiType: 'ohif.advancedRenderingControls',
|
* general buttons in `cornerstone.toolbarButtons`; together they are the
|
||||||
props: {
|
* default button set for the segmentation mode, and modes such as basic /
|
||||||
buttonSection: true,
|
* longitudinal can pull them in via a customization (see
|
||||||
},
|
* `segmentationEditing.jsonc`).
|
||||||
},
|
*/
|
||||||
{
|
const segmentationToolbarButtons: Button[] = [
|
||||||
id: 'modalityLoadBadge',
|
// section containers for the nested toolboxes and toolbars
|
||||||
uiType: 'ohif.modalityLoadBadge',
|
|
||||||
props: {
|
|
||||||
icon: 'Status',
|
|
||||||
label: i18n.t('Buttons:Status'),
|
|
||||||
tooltip: i18n.t('Buttons:Status'),
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.modalityLoadBadge',
|
|
||||||
hideWhenDisabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'navigationComponent',
|
|
||||||
uiType: 'ohif.navigationComponent',
|
|
||||||
props: {
|
|
||||||
icon: 'Navigation',
|
|
||||||
label: i18n.t('Buttons:Navigation'),
|
|
||||||
tooltip: i18n.t('Buttons:Navigate between segments/measurements and manage their visibility'),
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.navigationComponent',
|
|
||||||
hideWhenDisabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'trackingStatus',
|
|
||||||
uiType: 'ohif.trackingStatus',
|
|
||||||
props: {
|
|
||||||
icon: 'TrackingStatus',
|
|
||||||
label: i18n.t('Buttons:Tracking Status'),
|
|
||||||
tooltip: i18n.t('Buttons:View and manage tracking status of measurements and annotations'),
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.trackingStatus',
|
|
||||||
hideWhenDisabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'dataOverlayMenu',
|
|
||||||
uiType: 'ohif.dataOverlayMenu',
|
|
||||||
props: {
|
|
||||||
icon: 'ViewportViews',
|
|
||||||
label: i18n.t('Buttons:Data Overlay'),
|
|
||||||
tooltip: i18n.t(
|
|
||||||
'Buttons:Configure data overlay options and manage foreground/background display sets'
|
|
||||||
),
|
|
||||||
evaluate: 'evaluate.dataOverlayMenu',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'orientationMenu',
|
|
||||||
uiType: 'ohif.orientationMenu',
|
|
||||||
props: {
|
|
||||||
icon: 'OrientationSwitch',
|
|
||||||
label: i18n.t('Buttons:Orientation'),
|
|
||||||
tooltip: i18n.t(
|
|
||||||
'Buttons:Change viewport orientation between axial, sagittal, coronal and reformat planes'
|
|
||||||
),
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.orientationMenu',
|
|
||||||
// hideWhenDisabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'windowLevelMenuEmbedded',
|
|
||||||
uiType: 'ohif.windowLevelMenuEmbedded',
|
|
||||||
props: {
|
|
||||||
icon: 'WindowLevel',
|
|
||||||
label: i18n.t('Buttons:Window Level'),
|
|
||||||
tooltip: i18n.t('Buttons:Adjust window/level presets and customize image contrast settings'),
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.windowLevelMenuEmbedded',
|
|
||||||
hideWhenDisabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'windowLevelMenu',
|
|
||||||
uiType: 'ohif.windowLevelMenu',
|
|
||||||
props: {
|
|
||||||
icon: 'WindowLevel',
|
|
||||||
label: i18n.t('Buttons:Window Level'),
|
|
||||||
tooltip: i18n.t('Buttons:Adjust window/level presets and customize image contrast settings'),
|
|
||||||
evaluate: 'evaluate.windowLevelMenu',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'voiManualControlMenu',
|
|
||||||
uiType: 'ohif.voiManualControlMenu',
|
|
||||||
props: {
|
|
||||||
icon: 'WindowLevelAdvanced',
|
|
||||||
label: i18n.t('Buttons:Advanced Window Level'),
|
|
||||||
tooltip: i18n.t('Buttons:Advanced window/level settings with manual controls and presets'),
|
|
||||||
evaluate: 'evaluate.voiManualControlMenu',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'thresholdMenu',
|
|
||||||
uiType: 'ohif.thresholdMenu',
|
|
||||||
props: {
|
|
||||||
icon: 'Threshold',
|
|
||||||
label: i18n.t('Buttons:Threshold'),
|
|
||||||
tooltip: i18n.t('Buttons:Image threshold settings'),
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.thresholdMenu',
|
|
||||||
hideWhenDisabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'opacityMenu',
|
|
||||||
uiType: 'ohif.opacityMenu',
|
|
||||||
props: {
|
|
||||||
icon: 'Opacity',
|
|
||||||
label: i18n.t('Buttons:Opacity'),
|
|
||||||
tooltip: i18n.t('Buttons:Image opacity settings'),
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.opacityMenu',
|
|
||||||
hideWhenDisabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Colorbar',
|
|
||||||
uiType: 'ohif.colorbar',
|
|
||||||
props: {
|
|
||||||
type: 'tool',
|
|
||||||
label: i18n.t('Buttons:Colorbar'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// sections
|
|
||||||
{
|
|
||||||
id: 'MoreTools',
|
|
||||||
uiType: 'ohif.toolButtonList',
|
|
||||||
props: {
|
|
||||||
buttonSection: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'BrushTools',
|
id: 'BrushTools',
|
||||||
uiType: 'ohif.toolBoxButtonGroup',
|
uiType: 'ohif.toolBoxButtonGroup',
|
||||||
@ -176,7 +40,6 @@ export const toolbarButtons: Button[] = [
|
|||||||
buttonSection: true,
|
buttonSection: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Section containers for the nested toolboxes and toolbars.
|
|
||||||
{
|
{
|
||||||
id: 'LabelMapUtilities',
|
id: 'LabelMapUtilities',
|
||||||
uiType: 'ohif.Toolbar',
|
uiType: 'ohif.Toolbar',
|
||||||
@ -206,215 +69,6 @@ export const toolbarButtons: Button[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// tool defs
|
// tool defs
|
||||||
{
|
|
||||||
id: 'Zoom',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-zoom',
|
|
||||||
label: i18n.t('Buttons:Zoom'),
|
|
||||||
commands: setToolActiveToolbar,
|
|
||||||
evaluate: 'evaluate.cornerstoneTool',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'WindowLevel',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-window-level',
|
|
||||||
label: i18n.t('Buttons:Window Level'),
|
|
||||||
commands: setToolActiveToolbar,
|
|
||||||
evaluate: 'evaluate.cornerstoneTool',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Pan',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-move',
|
|
||||||
label: i18n.t('Buttons:Pan'),
|
|
||||||
commands: setToolActiveToolbar,
|
|
||||||
evaluate: 'evaluate.cornerstoneTool',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'TrackballRotate',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
type: 'tool',
|
|
||||||
icon: 'tool-3d-rotate',
|
|
||||||
label: i18n.t('Buttons:3D Rotate'),
|
|
||||||
commands: setToolActiveToolbar,
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.cornerstoneTool',
|
|
||||||
disabledText: i18n.t('Buttons:Select a 3D viewport to enable this tool'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Capture',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-capture',
|
|
||||||
label: i18n.t('Buttons:Capture'),
|
|
||||||
commands: 'showDownloadViewportModal',
|
|
||||||
evaluate: [
|
|
||||||
'evaluate.action',
|
|
||||||
{
|
|
||||||
name: 'evaluate.viewport.supported',
|
|
||||||
unsupportedViewportTypes: ['video', 'wholeSlide'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Layout',
|
|
||||||
uiType: 'ohif.layoutSelector',
|
|
||||||
props: {
|
|
||||||
rows: 3,
|
|
||||||
columns: 4,
|
|
||||||
evaluate: 'evaluate.action',
|
|
||||||
commands: 'setViewportGridLayout',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Crosshairs',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-crosshair',
|
|
||||||
label: i18n.t('Buttons:Crosshairs'),
|
|
||||||
commands: {
|
|
||||||
commandName: 'setToolActiveToolbar',
|
|
||||||
commandOptions: {
|
|
||||||
toolGroupIds: ['mpr'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
evaluate: {
|
|
||||||
name: 'evaluate.cornerstoneTool',
|
|
||||||
disabledText: i18n.t('Buttons:Select an MPR viewport to enable this tool'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Reset',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-reset',
|
|
||||||
label: i18n.t('Buttons:Reset View'),
|
|
||||||
tooltip: i18n.t('Buttons:Reset View'),
|
|
||||||
commands: 'resetViewport',
|
|
||||||
evaluate: 'evaluate.action',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rotate-right',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-rotate-right',
|
|
||||||
label: i18n.t('Buttons:Rotate Right'),
|
|
||||||
tooltip: i18n.t('Buttons:Rotate +90'),
|
|
||||||
commands: 'rotateViewportCW',
|
|
||||||
evaluate: 'evaluate.action',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flipHorizontal',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-flip-horizontal',
|
|
||||||
label: i18n.t('Buttons:Flip Horizontal'),
|
|
||||||
tooltip: i18n.t('Buttons:Flip Horizontally'),
|
|
||||||
commands: 'flipViewportHorizontal',
|
|
||||||
evaluate: [
|
|
||||||
'evaluate.viewportProperties.toggle',
|
|
||||||
{
|
|
||||||
name: 'evaluate.viewport.supported',
|
|
||||||
unsupportedViewportTypes: ['volume3d'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ReferenceLines',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-referenceLines',
|
|
||||||
label: i18n.t('Buttons:Reference Lines'),
|
|
||||||
tooltip: i18n.t('Buttons:Show Reference Lines'),
|
|
||||||
commands: 'toggleEnabledDisabledToolbar',
|
|
||||||
evaluate: 'evaluate.cornerstoneTool.toggle',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ImageOverlayViewer',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'toggle-dicom-overlay',
|
|
||||||
label: i18n.t('Buttons:Image Overlay'),
|
|
||||||
tooltip: i18n.t('Buttons:Toggle Image Overlay'),
|
|
||||||
commands: 'toggleEnabledDisabledToolbar',
|
|
||||||
evaluate: 'evaluate.cornerstoneTool.toggle',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'StackScroll',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-stack-scroll',
|
|
||||||
label: i18n.t('Buttons:Stack Scroll'),
|
|
||||||
tooltip: i18n.t('Buttons:Stack Scroll'),
|
|
||||||
commands: setToolActiveToolbar,
|
|
||||||
evaluate: 'evaluate.cornerstoneTool',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'invert',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-invert',
|
|
||||||
label: i18n.t('Buttons:Invert'),
|
|
||||||
tooltip: i18n.t('Buttons:Invert Colors'),
|
|
||||||
commands: 'invertViewport',
|
|
||||||
evaluate: 'evaluate.viewportProperties.toggle',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Cine',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-cine',
|
|
||||||
label: i18n.t('Buttons:Cine'),
|
|
||||||
tooltip: i18n.t('Buttons:Cine'),
|
|
||||||
commands: 'toggleCine',
|
|
||||||
evaluate: [
|
|
||||||
'evaluate.cine',
|
|
||||||
{
|
|
||||||
name: 'evaluate.viewport.supported',
|
|
||||||
unsupportedViewportTypes: ['volume3d'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'Magnify',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'tool-magnify',
|
|
||||||
label: i18n.t('Buttons:Zoom-in'),
|
|
||||||
tooltip: i18n.t('Buttons:Zoom-in'),
|
|
||||||
commands: setToolActiveToolbar,
|
|
||||||
evaluate: 'evaluate.cornerstoneTool',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'TagBrowser',
|
|
||||||
uiType: 'ohif.toolButton',
|
|
||||||
props: {
|
|
||||||
icon: 'dicom-tag-browser',
|
|
||||||
label: i18n.t('Buttons:Dicom Tag Browser'),
|
|
||||||
tooltip: i18n.t('Buttons:Dicom Tag Browser'),
|
|
||||||
commands: 'openDICOMTagViewer',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'PlanarFreehandContourSegmentationTool',
|
id: 'PlanarFreehandContourSegmentationTool',
|
||||||
uiType: 'ohif.toolBoxButton',
|
uiType: 'ohif.toolBoxButton',
|
||||||
@ -559,7 +213,11 @@ export const toolbarButtons: Button[] = [
|
|||||||
value: 'CatmullRomSplineROI',
|
value: 'CatmullRomSplineROI',
|
||||||
label: i18n.t('Buttons:Catmull Rom Spline'),
|
label: i18n.t('Buttons:Catmull Rom Spline'),
|
||||||
},
|
},
|
||||||
{ id: 'LinearSplineROI', value: 'LinearSplineROI', label: i18n.t('Buttons:Linear Spline') },
|
{
|
||||||
|
id: 'LinearSplineROI',
|
||||||
|
value: 'LinearSplineROI',
|
||||||
|
label: i18n.t('Buttons:Linear Spline'),
|
||||||
|
},
|
||||||
{ id: 'BSplineROI', value: 'BSplineROI', label: i18n.t('Buttons:B-Spline') },
|
{ id: 'BSplineROI', value: 'BSplineROI', label: i18n.t('Buttons:B-Spline') },
|
||||||
],
|
],
|
||||||
commands: {
|
commands: {
|
||||||
@ -763,18 +421,23 @@ export const toolbarButtons: Button[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'RegionSegmentPlus',
|
id: 'ClickSegment',
|
||||||
uiType: 'ohif.toolBoxButton',
|
uiType: 'ohif.toolBoxButton',
|
||||||
props: {
|
props: {
|
||||||
icon: 'icon-tool-click-segment',
|
icon: 'icon-tool-click-segment',
|
||||||
label: i18n.t('Buttons:One Click Segment'),
|
label: i18n.t('Buttons:Click to Segment'),
|
||||||
tooltip: i18n.t(
|
tooltip: i18n.t(
|
||||||
'Buttons:Detects segmentable regions with one click. Hover for visual feedback—click when a plus sign appears to auto-segment the lesion.'
|
'Buttons:PET only. Click-to-segment lesions with no configuration. Hover for visual feedback—click when a plus sign appears to segment the lesion.'
|
||||||
),
|
),
|
||||||
evaluate: [
|
evaluate: [
|
||||||
|
{
|
||||||
|
name: 'evaluate.modality.supported',
|
||||||
|
supportedModalities: ['PT'],
|
||||||
|
disabledText: i18n.t('Buttons:Tool not available for this modality'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'evaluate.cornerstone.segmentation',
|
name: 'evaluate.cornerstone.segmentation',
|
||||||
toolNames: ['RegionSegmentPlus'],
|
toolNames: ['ClickSegment'],
|
||||||
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
|
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1195,4 +858,106 @@ export const toolbarButtons: Button[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default toolbarButtons;
|
/**
|
||||||
|
* The toolbox / utilities section wiring for segmentation editing. These are
|
||||||
|
* the sections rendered by the `panelSegmentationWithTools*` panels, so any
|
||||||
|
* mode that shows those panels can merge this block into its toolbar sections.
|
||||||
|
*/
|
||||||
|
export const segmentationToolboxSections: Record<string, string[]> = {
|
||||||
|
[TOOLBAR_SECTIONS.labelMapSegmentationToolbox]: ['LabelMapTools'],
|
||||||
|
[TOOLBAR_SECTIONS.contourSegmentationToolbox]: ['ContourTools'],
|
||||||
|
[TOOLBAR_SECTIONS.labelMapSegmentationUtilities]: ['LabelMapUtilities'],
|
||||||
|
[TOOLBAR_SECTIONS.contourSegmentationUtilities]: ['ContourUtilities'],
|
||||||
|
|
||||||
|
LabelMapTools: [
|
||||||
|
'LabelmapSlicePropagation',
|
||||||
|
'BrushTools',
|
||||||
|
'MarkerLabelmap',
|
||||||
|
'ClickSegment',
|
||||||
|
'Shapes',
|
||||||
|
'LabelMapEditWithContour',
|
||||||
|
],
|
||||||
|
ContourTools: [
|
||||||
|
'PlanarFreehandContourSegmentationTool',
|
||||||
|
'SculptorTool',
|
||||||
|
'SplineContourSegmentationTool',
|
||||||
|
'LivewireContourSegmentationTool',
|
||||||
|
],
|
||||||
|
|
||||||
|
LabelMapUtilities: ['InterpolateLabelmap', 'SegmentBidirectional'],
|
||||||
|
ContourUtilities: ['LogicalContourOperations', 'SimplifyContours', 'SmoothContours'],
|
||||||
|
|
||||||
|
BrushTools: ['Brush', 'Eraser', 'Threshold'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The segmentation mode's main toolbar layout (primary bar and viewport
|
||||||
|
* action corners). Kept separate from the toolbox wiring above so other modes
|
||||||
|
* can adopt segmentation editing without adopting this mode layout.
|
||||||
|
*/
|
||||||
|
export const segmentationModeToolbarSections: Record<string, string[]> = {
|
||||||
|
[TOOLBAR_SECTIONS.primary]: [
|
||||||
|
'WindowLevel',
|
||||||
|
'Pan',
|
||||||
|
'Zoom',
|
||||||
|
'TrackballRotate',
|
||||||
|
'Capture',
|
||||||
|
'Layout',
|
||||||
|
'Crosshairs',
|
||||||
|
'MoreTools',
|
||||||
|
],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
|
||||||
|
|
||||||
|
AdvancedRenderingControls: [
|
||||||
|
'windowLevelMenuEmbedded',
|
||||||
|
'voiManualControlMenu',
|
||||||
|
'Colorbar',
|
||||||
|
'opacityMenu',
|
||||||
|
'thresholdMenu',
|
||||||
|
],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
|
||||||
|
'modalityLoadBadge',
|
||||||
|
'trackingStatus',
|
||||||
|
'navigationComponent',
|
||||||
|
],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
|
||||||
|
|
||||||
|
MoreTools: [
|
||||||
|
'Reset',
|
||||||
|
'rotate-right',
|
||||||
|
'flipHorizontal',
|
||||||
|
'ReferenceLines',
|
||||||
|
'ImageOverlayViewer',
|
||||||
|
'StackScroll',
|
||||||
|
'invert',
|
||||||
|
'Cine',
|
||||||
|
'Magnify',
|
||||||
|
'TagBrowser',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segmentation capability packs registered (at default scope) by the
|
||||||
|
* cornerstone extension. These are pure "what can exist" packs and carry no
|
||||||
|
* mode identity:
|
||||||
|
* - `cornerstone.segmentationToolbarButtons` – segmentation editing button definitions
|
||||||
|
* - `cornerstone.segmentationToolbarSections` – toolbox/utilities section wiring
|
||||||
|
* - `cornerstone.segmentationModeToolbarSections` – a reusable segmentation-mode toolbar layout
|
||||||
|
*
|
||||||
|
* Modes compose these by name in their own `toolbarButtons` /
|
||||||
|
* `toolbarSections` instance arrays; `?customization=` modules extend the
|
||||||
|
* result through the `mode` phase.
|
||||||
|
*/
|
||||||
|
const segmentationToolbarCustomization = {
|
||||||
|
'cornerstone.segmentationToolbarButtons': segmentationToolbarButtons,
|
||||||
|
'cornerstone.segmentationToolbarSections': segmentationToolboxSections,
|
||||||
|
'cornerstone.segmentationModeToolbarSections': segmentationModeToolbarSections,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { segmentationToolbarButtons };
|
||||||
|
export default segmentationToolbarCustomization;
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
import { toolNames } from '../initCornerstoneTools';
|
||||||
|
import {
|
||||||
|
MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
} from './segmentationToolbarCustomization';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable tool group "capability blocks", registered as default
|
||||||
|
* customizations so modes and `?customization=` JSON modules can add them to
|
||||||
|
* a tool group by name via a mode's `toolGroupAdditions` customization, e.g.
|
||||||
|
*
|
||||||
|
* "mode": {
|
||||||
|
* "basic": {
|
||||||
|
* "toolGroupAdditions": {
|
||||||
|
* "default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Each block is a `{ active/passive/enabled/disabled }` object suitable for
|
||||||
|
* `toolGroupService.addToolsToToolGroup`.
|
||||||
|
*/
|
||||||
|
function getToolGroupToolsCustomization({ commandsManager }) {
|
||||||
|
const brushInstances = [
|
||||||
|
{
|
||||||
|
toolName: 'CircularBrush',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'FILL_INSIDE_CIRCLE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'CircularEraser',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'ERASE_INSIDE_CIRCLE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'SphereBrush',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'FILL_INSIDE_SPHERE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'SphereEraser',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'ERASE_INSIDE_SPHERE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'ThresholdCircularBrush',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'THRESHOLD_INSIDE_CIRCLE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'ThresholdSphereBrush',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'THRESHOLD_INSIDE_SPHERE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'ThresholdCircularBrushDynamic',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'THRESHOLD_INSIDE_CIRCLE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
threshold: {
|
||||||
|
isDynamic: true,
|
||||||
|
dynamicRadius: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'ThresholdSphereBrushDynamic',
|
||||||
|
parentTool: 'Brush',
|
||||||
|
configuration: {
|
||||||
|
activeStrategy: 'THRESHOLD_INSIDE_SPHERE',
|
||||||
|
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
|
||||||
|
threshold: {
|
||||||
|
isDynamic: true,
|
||||||
|
dynamicRadius: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const splineInstances = [
|
||||||
|
{
|
||||||
|
toolName: 'CatmullRomSplineROI',
|
||||||
|
parentTool: toolNames.SplineContourSegmentation,
|
||||||
|
configuration: {
|
||||||
|
spline: {
|
||||||
|
type: 'CATMULLROM',
|
||||||
|
enableTwoPointPreview: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'LinearSplineROI',
|
||||||
|
parentTool: toolNames.SplineContourSegmentation,
|
||||||
|
configuration: {
|
||||||
|
spline: {
|
||||||
|
type: 'LINEAR',
|
||||||
|
enableTwoPointPreview: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'BSplineROI',
|
||||||
|
parentTool: toolNames.SplineContourSegmentation,
|
||||||
|
configuration: {
|
||||||
|
spline: {
|
||||||
|
type: 'BSPLINE',
|
||||||
|
enableTwoPointPreview: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* The segmentation editing tools (labelmap brushes/scissors and contour
|
||||||
|
* segmentation tools), matching the buttons in
|
||||||
|
* `cornerstone.segmentationToolbarButtons`.
|
||||||
|
*/
|
||||||
|
'cornerstone.segmentationTools': {
|
||||||
|
passive: [
|
||||||
|
...brushInstances,
|
||||||
|
{ toolName: toolNames.LabelmapSlicePropagation },
|
||||||
|
{ toolName: toolNames.MarkerLabelmap },
|
||||||
|
{ toolName: toolNames.ClickSegment },
|
||||||
|
{ toolName: toolNames.LabelMapEditWithContourTool },
|
||||||
|
{ toolName: toolNames.SegmentSelect },
|
||||||
|
{ toolName: toolNames.CircleScissors },
|
||||||
|
{ toolName: toolNames.RectangleScissors },
|
||||||
|
{ toolName: toolNames.SphereScissors },
|
||||||
|
{ toolName: toolNames.LivewireContourSegmentation },
|
||||||
|
{ toolName: toolNames.SculptorTool },
|
||||||
|
...splineInstances,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The measurement/annotation tools, matching the `MeasurementTools`
|
||||||
|
* buttons in `cornerstone.toolbarButtons`. Useful for adding annotations
|
||||||
|
* to modes (such as segmentation) whose tool groups omit them.
|
||||||
|
*/
|
||||||
|
'cornerstone.annotationTools': {
|
||||||
|
passive: [
|
||||||
|
{ toolName: toolNames.Length },
|
||||||
|
{
|
||||||
|
toolName: toolNames.ArrowAnnotate,
|
||||||
|
configuration: {
|
||||||
|
getTextCallback: (callback, eventDetails) => {
|
||||||
|
commandsManager.runCommand('arrowTextCallback', {
|
||||||
|
callback,
|
||||||
|
eventDetails,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
changeTextCallback: (data, eventDetails, callback) => {
|
||||||
|
commandsManager.runCommand('arrowTextCallback', {
|
||||||
|
callback,
|
||||||
|
data,
|
||||||
|
eventDetails,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ toolName: toolNames.Bidirectional },
|
||||||
|
{ toolName: toolNames.Probe },
|
||||||
|
{ toolName: toolNames.DragProbe },
|
||||||
|
{ toolName: toolNames.EllipticalROI },
|
||||||
|
{ toolName: toolNames.CircleROI },
|
||||||
|
{ toolName: toolNames.RectangleROI },
|
||||||
|
{ toolName: toolNames.Angle },
|
||||||
|
{ toolName: toolNames.CobbAngle },
|
||||||
|
{ toolName: toolNames.SplineROI },
|
||||||
|
{ toolName: toolNames.LivewireContour },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getToolGroupToolsCustomization;
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import type { Button } from '@ohif/core/types';
|
import type { Button } from '@ohif/core/types';
|
||||||
|
|
||||||
import { EVENTS } from '@cornerstonejs/core';
|
import { EVENTS } from '@cornerstonejs/core';
|
||||||
import { ViewportGridService } from '@ohif/core';
|
import { ViewportGridService, ToolbarService } from '@ohif/core';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
const { TOOLBAR_SECTIONS } = ToolbarService;
|
||||||
|
|
||||||
const callbacks = (toolName: string) => [
|
const callbacks = (toolName: string) => [
|
||||||
{
|
{
|
||||||
commandName: 'setViewportForToolConfiguration',
|
commandName: 'setViewportForToolConfiguration',
|
||||||
@ -708,4 +710,95 @@ const toolbarButtons: Button[] = [
|
|||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default toolbarButtons;
|
/**
|
||||||
|
* Default toolbar layout: which buttons appear in each toolbar section.
|
||||||
|
* Registered as a customization so modes (and `?customization=` modules) can
|
||||||
|
* append/replace entries without rebuilding.
|
||||||
|
*/
|
||||||
|
export const toolbarSections = {
|
||||||
|
[TOOLBAR_SECTIONS.primary]: [
|
||||||
|
'MeasurementTools',
|
||||||
|
'Zoom',
|
||||||
|
'Pan',
|
||||||
|
'TrackballRotate',
|
||||||
|
'WindowLevel',
|
||||||
|
'Capture',
|
||||||
|
'Layout',
|
||||||
|
'Crosshairs',
|
||||||
|
'MoreTools',
|
||||||
|
],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
|
||||||
|
|
||||||
|
AdvancedRenderingControls: [
|
||||||
|
'windowLevelMenuEmbedded',
|
||||||
|
'voiManualControlMenu',
|
||||||
|
'Colorbar',
|
||||||
|
'opacityMenu',
|
||||||
|
'thresholdMenu',
|
||||||
|
],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
|
||||||
|
'modalityLoadBadge',
|
||||||
|
'trackingStatus',
|
||||||
|
'navigationComponent',
|
||||||
|
],
|
||||||
|
|
||||||
|
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
|
||||||
|
|
||||||
|
MeasurementTools: [
|
||||||
|
'Length',
|
||||||
|
'Bidirectional',
|
||||||
|
'ArrowAnnotate',
|
||||||
|
'EllipticalROI',
|
||||||
|
'RectangleROI',
|
||||||
|
'CircleROI',
|
||||||
|
'PlanarFreehandROI',
|
||||||
|
'SplineROI',
|
||||||
|
'LivewireContour',
|
||||||
|
],
|
||||||
|
|
||||||
|
MoreTools: [
|
||||||
|
'Reset',
|
||||||
|
'rotate-right',
|
||||||
|
'flipHorizontal',
|
||||||
|
'ImageSliceSync',
|
||||||
|
'ReferenceLines',
|
||||||
|
'ImageOverlayViewer',
|
||||||
|
'StackScroll',
|
||||||
|
'invert',
|
||||||
|
'Probe',
|
||||||
|
'Cine',
|
||||||
|
'Angle',
|
||||||
|
'CobbAngle',
|
||||||
|
'Magnify',
|
||||||
|
'CalibrationLine',
|
||||||
|
'TagBrowser',
|
||||||
|
'AdvancedMagnify',
|
||||||
|
'UltrasoundDirectionalTool',
|
||||||
|
'WindowLevelRegion',
|
||||||
|
'SegmentLabelTool',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capability packs registered (at default scope) by the cornerstone extension:
|
||||||
|
* - `cornerstone.toolbarButtons` – the general toolbar button definitions
|
||||||
|
* - `cornerstone.toolbarSections` – the general toolbar layout (section -> button ids)
|
||||||
|
*
|
||||||
|
* These are pure "what can exist" packs — they carry no mode identity. Modes
|
||||||
|
* compose them with `{ $reference }` markers in their own `toolbarButtons` /
|
||||||
|
* `toolbarSections` instance arrays, which the mode route seeds into the Mode
|
||||||
|
* customization scope on enter; URL `?customization=` modules extend the result
|
||||||
|
* through the `mode` phase (e.g.
|
||||||
|
* `mode.basic.toolbarButtons: { $push: [{ $reference: '...' }] }`).
|
||||||
|
*/
|
||||||
|
const toolbarButtonsCustomization = {
|
||||||
|
'cornerstone.toolbarButtons': toolbarButtons,
|
||||||
|
'cornerstone.toolbarSections': toolbarSections,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { toolbarButtons };
|
||||||
|
export default toolbarButtonsCustomization;
|
||||||
@ -8,6 +8,9 @@ import volumeRenderingCustomization from './customizations/volumeRenderingCustom
|
|||||||
import colorbarCustomization from './customizations/colorbarCustomization';
|
import colorbarCustomization from './customizations/colorbarCustomization';
|
||||||
import modalityColorMapCustomization from './customizations/modalityColorMapCustomization';
|
import modalityColorMapCustomization from './customizations/modalityColorMapCustomization';
|
||||||
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
|
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
|
||||||
|
import toolbarButtonsCustomization from './customizations/toolbarButtonsCustomization';
|
||||||
|
import segmentationToolbarCustomization from './customizations/segmentationToolbarCustomization';
|
||||||
|
import getToolGroupToolsCustomization from './customizations/toolGroupToolsCustomization';
|
||||||
import miscCustomization from './customizations/miscCustomization';
|
import miscCustomization from './customizations/miscCustomization';
|
||||||
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
|
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
|
||||||
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
|
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
|
||||||
@ -32,6 +35,9 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan
|
|||||||
...colorbarCustomization,
|
...colorbarCustomization,
|
||||||
...modalityColorMapCustomization,
|
...modalityColorMapCustomization,
|
||||||
...windowLevelPresetsCustomization,
|
...windowLevelPresetsCustomization,
|
||||||
|
...toolbarButtonsCustomization,
|
||||||
|
...segmentationToolbarCustomization,
|
||||||
|
...getToolGroupToolsCustomization({ commandsManager }),
|
||||||
...miscCustomization,
|
...miscCustomization,
|
||||||
...captureViewportModalCustomization,
|
...captureViewportModalCustomization,
|
||||||
...viewportDownloadWarningCustomization,
|
...viewportDownloadWarningCustomization,
|
||||||
|
|||||||
@ -8,7 +8,11 @@ import { buildEcgModule } from './utils/ecgMetadata';
|
|||||||
const { MetadataModules } = csEnums;
|
const { MetadataModules } = csEnums;
|
||||||
const { utils } = OHIF;
|
const { utils } = OHIF;
|
||||||
const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary;
|
const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary;
|
||||||
const { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils;
|
// NOTE: access transferDenaturalizedDataset / fixMultiValueKeys lazily (at call
|
||||||
|
// time) rather than destructuring here. This module and @ohif/extension-default
|
||||||
|
// form a circular import, so dicomWebUtils can be undefined at module-eval time
|
||||||
|
// depending on bundler eval order; a top-level destructure then throws and
|
||||||
|
// crashes app boot.
|
||||||
|
|
||||||
const SOP_CLASS_UIDS = {
|
const SOP_CLASS_UIDS = {
|
||||||
VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6',
|
VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6',
|
||||||
@ -139,8 +143,8 @@ function getDICOMwebMetadata(instanceMap, imageId) {
|
|||||||
console.warn('Metadata not already found for', imageId, 'in', instanceMap);
|
console.warn('Metadata not already found for', imageId, 'in', instanceMap);
|
||||||
return this.super.getDICOMwebMetadata(imageId);
|
return this.super.getDICOMwebMetadata(imageId);
|
||||||
}
|
}
|
||||||
return transferDenaturalizedDataset(
|
return dicomWebUtils.transferDenaturalizedDataset(
|
||||||
denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId)))
|
denaturalizeDataset(dicomWebUtils.fixMultiValueKeys(instanceMap.get(imageId)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Enums } from '@cornerstonejs/tools';
|
import { Enums } from '@cornerstonejs/tools';
|
||||||
import i18n from '@ohif/i18n';
|
import i18n from '@ohif/i18n';
|
||||||
|
import { getViewportAdapter, isVolumeRenderingViewport } from './services/ViewportService/adapter';
|
||||||
import { utils } from '@ohif/ui-next';
|
import { utils } from '@ohif/ui-next';
|
||||||
import { ViewportDataOverlayMenuWrapper } from './components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper';
|
import { ViewportDataOverlayMenuWrapper } from './components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper';
|
||||||
import { ViewportOrientationMenuWrapper } from './components/ViewportOrientationMenu/ViewportOrientationMenuWrapper';
|
import { ViewportOrientationMenuWrapper } from './components/ViewportOrientationMenu/ViewportOrientationMenuWrapper';
|
||||||
@ -309,7 +310,11 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewport.type !== 'orthographic') {
|
// Recognize native "next" volume viewports too. A next MPR/volume viewport
|
||||||
|
// runs as PLANAR_NEXT (requestedType PLANAR_NEXT, not ORTHOGRAPHIC), so this
|
||||||
|
// checks volume content via getCurrentMode(). Without it the PT threshold
|
||||||
|
// control stayed disabled on the next backend (e.g. TMTV fusion/PT).
|
||||||
|
if (!isVolumeRenderingViewport(viewport)) {
|
||||||
return {
|
return {
|
||||||
disabled: true,
|
disabled: true,
|
||||||
};
|
};
|
||||||
@ -332,7 +337,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
|||||||
evaluate: ({ viewportId }) => {
|
evaluate: ({ viewportId }) => {
|
||||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||||
|
|
||||||
if (!viewport || viewport.type !== 'orthographic') {
|
if (!viewport || !isVolumeRenderingViewport(viewport)) {
|
||||||
return {
|
return {
|
||||||
disabled: true,
|
disabled: true,
|
||||||
};
|
};
|
||||||
@ -449,8 +454,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
|||||||
mode === Enums.ToolModes.Enabled;
|
mode === Enums.ToolModes.Enabled;
|
||||||
|
|
||||||
const toolBindings = toolGroupService.getToolBindings(toolGroup.id, toolName);
|
const toolBindings = toolGroupService.getToolBindings(toolGroup.id, toolName);
|
||||||
const hasModifierKey =
|
const hasModifierKey = toolBindings?.some(binding => binding.modifierKey != null) ?? false;
|
||||||
toolBindings?.some(binding => binding.modifierKey != null) ?? false;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@ -459,7 +463,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
|||||||
icon:
|
icon:
|
||||||
isToggled && hasModifierKey && toggledOnIcon
|
isToggled && hasModifierKey && toggledOnIcon
|
||||||
? toggledOnIcon
|
? toggledOnIcon
|
||||||
: defaultIcon ?? button.props.icon,
|
: (defaultIcon ?? button.props.icon),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -543,8 +547,9 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
|||||||
|
|
||||||
const propId = button.id;
|
const propId = button.id;
|
||||||
|
|
||||||
const properties = viewport.getProperties();
|
const adapter = getViewportAdapter(viewport);
|
||||||
const camera = viewport.getCamera();
|
const properties = adapter.getPresentation();
|
||||||
|
const camera = adapter.getViewState();
|
||||||
|
|
||||||
const prop = camera?.[propId] || properties?.[propId];
|
const prop = camera?.[propId] || properties?.[propId];
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
import { useSystem } from '@ohif/core';
|
import { useSystem } from '@ohif/core';
|
||||||
import { useViewportDisplaySets } from './useViewportDisplaySets';
|
import { useViewportDisplaySets } from './useViewportDisplaySets';
|
||||||
import { Types, utilities, Enums, cache } from '@cornerstonejs/core';
|
import { Types, utilities, Enums } from '@cornerstonejs/core';
|
||||||
import { getDataIdForViewport } from '../utils/getDataIdForViewport';
|
import { isVolume3DViewportType } from '../utils/getLegacyViewportType';
|
||||||
import {
|
import { getViewportAdapter, LEGACY_OPACITY_GAMMA } from '../services/ViewportService/adapter';
|
||||||
isStackViewportType,
|
|
||||||
isVolumeViewportType,
|
|
||||||
isVolume3DViewportType,
|
|
||||||
} from '../utils/getLegacyViewportType';
|
|
||||||
import { WindowLevelPreset } from '../types/WindowLevel';
|
import { WindowLevelPreset } from '../types/WindowLevel';
|
||||||
import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar';
|
import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar';
|
||||||
import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig';
|
import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig';
|
||||||
@ -92,14 +88,26 @@ const getPosition = (location: number): ColorbarPositionType => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const GAMMA = 1 / 5;
|
/**
|
||||||
|
* Normalizes a colormap opacity value to a single 0..1 scalar for the opacity
|
||||||
|
* slider. `colormap.opacity` may be a plain number or an array of
|
||||||
|
* `{ value, opacity }` points (e.g. the HP fusion opacity ramp); for the array
|
||||||
|
* case we represent it by its maximum opacity. (A prior reduce ran over the point
|
||||||
|
* objects directly, producing NaN and a mispositioned slider.)
|
||||||
|
*/
|
||||||
|
const resolveOpacityScalar = (opacityVal: unknown): number | undefined => {
|
||||||
|
if (opacityVal === undefined || opacityVal === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const linearToOpacity = (linearValue: number): number => {
|
if (Array.isArray(opacityVal)) {
|
||||||
return Math.pow(linearValue, GAMMA);
|
return opacityVal.reduce((max: number, point) => {
|
||||||
};
|
const value = typeof point === 'number' ? point : (point?.opacity ?? 0);
|
||||||
|
return Math.max(max, value);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
const opacityToLinear = (opacityValue: number): number => {
|
return opacityVal as number;
|
||||||
return Math.pow(opacityValue, 1.0 / GAMMA);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -128,12 +136,28 @@ export function useViewportRendering(
|
|||||||
viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null
|
viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null
|
||||||
);
|
);
|
||||||
const [is3DVolume, setIs3DVolume] = useState(isVolume3DViewportType(viewport));
|
const [is3DVolume, setIs3DVolume] = useState(isVolume3DViewportType(viewport));
|
||||||
|
|
||||||
|
// The opacity slider gamma follows the rendering path (linear on native,
|
||||||
|
// the historical 1/5 curve on legacy), so the slider feel and its initial
|
||||||
|
// position match what is rendered.
|
||||||
|
const opacityGamma = viewport
|
||||||
|
? getViewportAdapter(viewport).getOpacityGamma()
|
||||||
|
: LEGACY_OPACITY_GAMMA;
|
||||||
|
const linearToOpacity = useCallback(
|
||||||
|
(linearValue: number): number => Math.pow(linearValue, opacityGamma),
|
||||||
|
[opacityGamma]
|
||||||
|
);
|
||||||
|
const opacityToLinear = useCallback(
|
||||||
|
(opacityValue: number): number => Math.pow(opacityValue, 1.0 / opacityGamma),
|
||||||
|
[opacityGamma]
|
||||||
|
);
|
||||||
|
|
||||||
const [opacity, setOpacityState] = useState<number | undefined>();
|
const [opacity, setOpacityState] = useState<number | undefined>();
|
||||||
const [opacityLinear, setOpacityLinearState] = useState<number | undefined>();
|
const [opacityLinear, setOpacityLinearState] = useState<number | undefined>();
|
||||||
const [threshold, setThresholdState] = useState<number | undefined>();
|
const [threshold, setThresholdState] = useState<number | undefined>();
|
||||||
const [pixelValueRange, setPixelValueRange] = useState<PixelValueRange>({ min: 0, max: 255 });
|
const [pixelValueRange, setPixelValueRange] = useState<PixelValueRange>({ min: 0, max: 255 });
|
||||||
|
|
||||||
const { viewportDisplaySets } = useViewportDisplaySets(viewportId);
|
const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId);
|
||||||
const { displaySetService } = servicesManager.services;
|
const { displaySetService } = servicesManager.services;
|
||||||
|
|
||||||
// Determine the active display set instance UID (internal only, not exposed)
|
// Determine the active display set instance UID (internal only, not exposed)
|
||||||
@ -142,12 +166,21 @@ export function useViewportRendering(
|
|||||||
return options.displaySetInstanceUID;
|
return options.displaySetInstanceUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window-level / colormap / threshold controls operate on the foreground
|
||||||
|
// layer (e.g. the PT in a PET/CT fusion), not the grayscale background (CT).
|
||||||
|
// Use the topmost foreground display set when present; otherwise fall back to
|
||||||
|
// the (single) primary display set. SEG/derived overlays are already excluded
|
||||||
|
// from foregroundDisplaySets.
|
||||||
|
if (foregroundDisplaySets && foregroundDisplaySets.length > 0) {
|
||||||
|
return foregroundDisplaySets[foregroundDisplaySets.length - 1].displaySetInstanceUID;
|
||||||
|
}
|
||||||
|
|
||||||
if (viewportDisplaySets && viewportDisplaySets.length > 0) {
|
if (viewportDisplaySets && viewportDisplaySets.length > 0) {
|
||||||
return viewportDisplaySets[0].displaySetInstanceUID;
|
return viewportDisplaySets[0].displaySetInstanceUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [options?.displaySetInstanceUID, viewportDisplaySets]);
|
}, [options?.displaySetInstanceUID, viewportDisplaySets, foregroundDisplaySets]);
|
||||||
|
|
||||||
const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null;
|
const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null;
|
||||||
|
|
||||||
@ -216,28 +249,14 @@ export function useViewportRendering(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVolumeViewportType(viewport)) {
|
const voxelManager = getViewportAdapter(viewport).getVoxelManagerForDisplaySet(
|
||||||
|
activeDisplaySetInstanceUID
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!voxelManager?.getRange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const volumeIds = viewport.getAllVolumeIds();
|
|
||||||
const volumeId = volumeIds.find(id => id.includes(activeDisplaySetInstanceUID));
|
|
||||||
|
|
||||||
if (!volumeId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// only handle volume viewports for now
|
|
||||||
const imageData = viewport.getImageData(volumeId);
|
|
||||||
|
|
||||||
if (!imageData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageDataVtk = imageData.imageData;
|
|
||||||
|
|
||||||
const { voxelManager } = imageDataVtk.get('voxelManager');
|
|
||||||
|
|
||||||
const range = voxelManager.getRange();
|
const range = voxelManager.getRange();
|
||||||
|
|
||||||
setPixelValueRange({ min: range[0], max: range[1] });
|
setPixelValueRange({ min: range[0], max: range[1] });
|
||||||
@ -267,12 +286,9 @@ export function useViewportRendering(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const dataId = getDataIdForViewport(viewport as unknown, activeDisplaySetInstanceUID);
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
const dataId = adapter.getDataIdForDisplaySet(activeDisplaySetInstanceUID);
|
||||||
const properties =
|
const properties = adapter.getPresentation(dataId ?? activeDisplaySetInstanceUID);
|
||||||
dataId != null
|
|
||||||
? (viewport as Types.IBaseVolumeViewport).getProperties(dataId)
|
|
||||||
: viewport.getProperties();
|
|
||||||
|
|
||||||
if (!properties) {
|
if (!properties) {
|
||||||
return;
|
return;
|
||||||
@ -281,18 +297,26 @@ export function useViewportRendering(
|
|||||||
if (properties.voiRange) {
|
if (properties.voiRange) {
|
||||||
setVoiRange(properties.voiRange);
|
setVoiRange(properties.voiRange);
|
||||||
voiRangeRef.current = properties.voiRange;
|
voiRangeRef.current = properties.voiRange;
|
||||||
|
} else {
|
||||||
|
// Native ("next") viewports store only explicit VOI overrides in the
|
||||||
|
// per-display-set presentation; a freshly shown series has none, so fall
|
||||||
|
// back to its computed default VOI (undefined on legacy, whose
|
||||||
|
// getProperties always returns the applied VOI). Without this, changing
|
||||||
|
// the series left the overlay showing the previous series' window level.
|
||||||
|
const defaultVOIRange = adapter.getDefaultVOIRange(dataId ?? activeDisplaySetInstanceUID);
|
||||||
|
|
||||||
|
if (defaultVOIRange) {
|
||||||
|
setVoiRange(defaultVOIRange);
|
||||||
|
voiRangeRef.current = defaultVOIRange;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (properties.colormap?.opacity !== undefined) {
|
if (properties.colormap?.opacity !== undefined) {
|
||||||
const opacityVal = properties.colormap.opacity;
|
const opacity = resolveOpacityScalar(properties.colormap.opacity);
|
||||||
const opacity = Array.isArray(opacityVal)
|
if (opacity !== undefined) {
|
||||||
? (opacityVal as unknown as number[]).reduce(
|
setOpacityState(opacity);
|
||||||
(max, current) => Math.max(max, current),
|
setOpacityLinearState(opacityToLinear(opacity));
|
||||||
0
|
}
|
||||||
)
|
|
||||||
: opacityVal;
|
|
||||||
setOpacityState(opacity);
|
|
||||||
setOpacityLinearState(opacityToLinear(opacity));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (properties.colormap?.threshold !== undefined) {
|
if (properties.colormap?.threshold !== undefined) {
|
||||||
@ -362,8 +386,11 @@ export function useViewportRendering(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (colormap.opacity !== undefined) {
|
if (colormap.opacity !== undefined) {
|
||||||
setOpacityState(colormap.opacity);
|
const opacity = resolveOpacityScalar(colormap.opacity);
|
||||||
setOpacityLinearState(opacityToLinear(colormap.opacity));
|
if (opacity !== undefined) {
|
||||||
|
setOpacityState(opacity);
|
||||||
|
setOpacityLinearState(opacityToLinear(opacity));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -570,7 +597,7 @@ export function useViewportRendering(
|
|||||||
|
|
||||||
const setOpacity = useCallback(
|
const setOpacity = useCallback(
|
||||||
(opacityValue: number) => {
|
(opacityValue: number) => {
|
||||||
if (!viewport || !isVolumeViewportType(viewport)) {
|
if (!viewport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -580,32 +607,10 @@ export function useViewportRendering(
|
|||||||
setOpacityLinearState(opacityToLinear(opacityValue));
|
setOpacityLinearState(opacityToLinear(opacityValue));
|
||||||
|
|
||||||
const displaySetInstanceUID = validateActiveDisplaySet();
|
const displaySetInstanceUID = validateActiveDisplaySet();
|
||||||
const volumeIds = viewport.getAllVolumeIds();
|
|
||||||
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
|
|
||||||
|
|
||||||
if (!volumeId) {
|
if (getViewportAdapter(viewport).setLayerOpacity(displaySetInstanceUID, opacityValue)) {
|
||||||
return;
|
viewport.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current properties including colormap
|
|
||||||
const properties = viewport.getProperties(volumeId);
|
|
||||||
const currentColormap = properties.colormap || {};
|
|
||||||
|
|
||||||
// Update colormap with new opacity
|
|
||||||
const updatedColormap = {
|
|
||||||
...currentColormap,
|
|
||||||
opacity: opacityValue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply updated colormap
|
|
||||||
viewport.setProperties(
|
|
||||||
{
|
|
||||||
colormap: updatedColormap,
|
|
||||||
},
|
|
||||||
volumeId
|
|
||||||
);
|
|
||||||
|
|
||||||
viewport.render();
|
|
||||||
},
|
},
|
||||||
[validateActiveDisplaySet, opacityToLinear, viewport]
|
[validateActiveDisplaySet, opacityToLinear, viewport]
|
||||||
);
|
);
|
||||||
@ -621,32 +626,16 @@ export function useViewportRendering(
|
|||||||
|
|
||||||
const setThreshold = useCallback(
|
const setThreshold = useCallback(
|
||||||
(thresholdValue: number) => {
|
(thresholdValue: number) => {
|
||||||
if (!viewport || !isVolumeViewportType(viewport)) {
|
if (!viewport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setThresholdState(thresholdValue);
|
|
||||||
|
|
||||||
const displaySetInstanceUID = validateActiveDisplaySet();
|
const displaySetInstanceUID = validateActiveDisplaySet();
|
||||||
const volumeIds = viewport.getAllVolumeIds();
|
setThresholdState(thresholdValue);
|
||||||
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
|
|
||||||
|
|
||||||
if (!volumeId) {
|
if (getViewportAdapter(viewport).setLayerThreshold(displaySetInstanceUID, thresholdValue)) {
|
||||||
return;
|
viewport.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug('🚀 ~ thresholdValue:', thresholdValue);
|
|
||||||
|
|
||||||
viewport.setProperties(
|
|
||||||
{
|
|
||||||
colormap: {
|
|
||||||
threshold: thresholdValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
volumeId
|
|
||||||
);
|
|
||||||
|
|
||||||
viewport.render();
|
|
||||||
},
|
},
|
||||||
[validateActiveDisplaySet, viewport]
|
[validateActiveDisplaySet, viewport]
|
||||||
);
|
);
|
||||||
@ -662,41 +651,13 @@ export function useViewportRendering(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStackViewportType(viewport)) {
|
const colormap = getViewportAdapter(viewport).getColormap(activeDisplaySetInstanceUID);
|
||||||
const { colormap } = viewport.getProperties();
|
|
||||||
if (!colormap) {
|
|
||||||
return (
|
|
||||||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
|
||||||
colorbarProperties?.colormaps?.[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return colormap;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actorEntries = viewport.getActors();
|
return (
|
||||||
const actorEntry = actorEntries?.find(entry =>
|
colormap ||
|
||||||
entry.referencedId?.includes(activeDisplaySetInstanceUID)
|
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
||||||
|
colorbarProperties?.colormaps?.[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!actorEntry) {
|
|
||||||
return (
|
|
||||||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
|
||||||
colorbarProperties?.colormaps?.[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { colormap } = (viewport as Types.IVolumeViewport).getProperties(
|
|
||||||
actorEntry.referencedId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!colormap) {
|
|
||||||
return (
|
|
||||||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
|
||||||
colorbarProperties?.colormaps?.[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return colormap;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting viewport colormap:', error);
|
console.error('Error getting viewport colormap:', error);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import {
|
|||||||
imageRetrievalPoolManager,
|
imageRetrievalPoolManager,
|
||||||
} from '@cornerstonejs/core';
|
} from '@cornerstonejs/core';
|
||||||
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
||||||
import { Types } from '@ohif/core';
|
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
|
||||||
|
import { Types, AnnotationPersistenceService } from '@ohif/core';
|
||||||
import Enums from './enums';
|
import Enums from './enums';
|
||||||
|
|
||||||
import init from './init';
|
import init from './init';
|
||||||
@ -36,6 +37,18 @@ import RectangleROI from './utils/measurementServiceMappings/RectangleROI';
|
|||||||
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
|
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
|
||||||
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
|
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
|
||||||
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
|
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
|
||||||
|
import {
|
||||||
|
getViewportAdapter,
|
||||||
|
getViewportFocalPoint,
|
||||||
|
isNextViewport,
|
||||||
|
isVolumeRenderingViewport,
|
||||||
|
} from './services/ViewportService/adapter';
|
||||||
|
import { isNextViewportsEnabled } from './utils/nextViewports';
|
||||||
|
import {
|
||||||
|
NEXT_FUSION_PT_OPACITY,
|
||||||
|
NEXT_OVERLAY_OPACITY,
|
||||||
|
getHydrationViewportTypeForModality,
|
||||||
|
} from './utils/nextViewportPolicies';
|
||||||
import { findNearbyToolData } from './utils/findNearbyToolData';
|
import { findNearbyToolData } from './utils/findNearbyToolData';
|
||||||
import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer';
|
import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer';
|
||||||
import { getSopClassHandlerModule } from './getSopClassHandlerModule';
|
import { getSopClassHandlerModule } from './getSopClassHandlerModule';
|
||||||
@ -102,11 +115,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
*/
|
*/
|
||||||
id,
|
id,
|
||||||
|
|
||||||
onModeEnter: ({
|
onModeEnter: ({ servicesManager, commandsManager, extensionManager }: withAppTypes): void => {
|
||||||
servicesManager,
|
|
||||||
commandsManager,
|
|
||||||
extensionManager,
|
|
||||||
}: withAppTypes): void => {
|
|
||||||
const { cornerstoneViewportService, toolbarService, segmentationService } =
|
const { cornerstoneViewportService, toolbarService, segmentationService } =
|
||||||
servicesManager.services;
|
servicesManager.services;
|
||||||
|
|
||||||
@ -152,7 +161,10 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
*/
|
*/
|
||||||
const sourceConfig = extensionManager?.getActiveDataSource?.()?.[0]?.getConfig?.() ?? {};
|
const sourceConfig = extensionManager?.getActiveDataSource?.()?.[0]?.getConfig?.() ?? {};
|
||||||
const config = sourceConfig.stackRetrieveOptions ?? {};
|
const config = sourceConfig.stackRetrieveOptions ?? {};
|
||||||
const stackOptions = update(DEFAULT_STACK_RETRIEVE_OPTIONS, toUpdateSpec(config)) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS;
|
const stackOptions = update(
|
||||||
|
DEFAULT_STACK_RETRIEVE_OPTIONS,
|
||||||
|
toUpdateSpec(config)
|
||||||
|
) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS;
|
||||||
imageRetrieveMetadataProvider.add('stack', stackOptions);
|
imageRetrieveMetadataProvider.add('stack', stackOptions);
|
||||||
},
|
},
|
||||||
getPanelModule,
|
getPanelModule,
|
||||||
@ -169,6 +181,11 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
imageRetrievalPoolManager.clearRequestStack(type);
|
imageRetrievalPoolManager.clearRequestStack(type);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Release the typed metadata registry (NATURALIZED instances registered via
|
||||||
|
// prefetchPart10Instance hold full compressed Part 10 buffers that live
|
||||||
|
// outside the size-capped cornerstone image cache)
|
||||||
|
csMetadataUtilities.clearCacheData();
|
||||||
|
|
||||||
cineService.setIsCineEnabled(false);
|
cineService.setIsCineEnabled(false);
|
||||||
|
|
||||||
enabledElementReset();
|
enabledElementReset();
|
||||||
@ -199,6 +216,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
|||||||
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
|
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
|
||||||
servicesManager.registerService(ColorbarService.REGISTRATION);
|
servicesManager.registerService(ColorbarService.REGISTRATION);
|
||||||
servicesManager.registerService(ViewedDataService.REGISTRATION);
|
servicesManager.registerService(ViewedDataService.REGISTRATION);
|
||||||
|
servicesManager.registerService(AnnotationPersistenceService.REGISTRATION);
|
||||||
|
|
||||||
const { syncGroupService } = servicesManager.services;
|
const { syncGroupService } = servicesManager.services;
|
||||||
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
|
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
|
||||||
@ -280,6 +298,14 @@ export {
|
|||||||
getEnabledElement,
|
getEnabledElement,
|
||||||
ImageOverlayViewerTool,
|
ImageOverlayViewerTool,
|
||||||
getSOPInstanceAttributes,
|
getSOPInstanceAttributes,
|
||||||
|
getViewportAdapter,
|
||||||
|
getViewportFocalPoint,
|
||||||
|
isNextViewport,
|
||||||
|
isVolumeRenderingViewport,
|
||||||
|
isNextViewportsEnabled,
|
||||||
|
NEXT_FUSION_PT_OPACITY,
|
||||||
|
NEXT_OVERLAY_OPACITY,
|
||||||
|
getHydrationViewportTypeForModality,
|
||||||
dicomLoaderService,
|
dicomLoaderService,
|
||||||
// Export all stores
|
// Export all stores
|
||||||
useLutPresentationStore,
|
useLutPresentationStore,
|
||||||
|
|||||||
@ -27,6 +27,12 @@ import initCornerstoneTools from './initCornerstoneTools';
|
|||||||
import { connectToolsToMeasurementService } from './initMeasurementService';
|
import { connectToolsToMeasurementService } from './initMeasurementService';
|
||||||
import initCineService from './initCineService';
|
import initCineService from './initCineService';
|
||||||
import initStudyPrefetcherService from './initStudyPrefetcherService';
|
import initStudyPrefetcherService from './initStudyPrefetcherService';
|
||||||
|
import {
|
||||||
|
setNextViewportsEnabled,
|
||||||
|
resolveNextViewportsEnabled,
|
||||||
|
resolveViewportRendering,
|
||||||
|
setViewportRenderingOverrides,
|
||||||
|
} from './utils/nextViewports';
|
||||||
import interleaveCenterLoader from './utils/interleaveCenterLoader';
|
import interleaveCenterLoader from './utils/interleaveCenterLoader';
|
||||||
import nthLoader from './utils/nthLoader';
|
import nthLoader from './utils/nthLoader';
|
||||||
import interleaveTopToBottom from './utils/interleaveTopToBottom';
|
import interleaveTopToBottom from './utils/interleaveTopToBottom';
|
||||||
@ -62,13 +68,52 @@ export default async function init({
|
|||||||
// Note: this should run first before initializing the cornerstone
|
// Note: this should run first before initializing the cornerstone
|
||||||
// DO NOT CHANGE THE ORDER
|
// DO NOT CHANGE THE ORDER
|
||||||
|
|
||||||
|
// Enable cornerstone's stats/debug overlay when `?debug=true` is in the URL.
|
||||||
|
// Mirrors the cornerstone demo trigger so the same overlay is available inside
|
||||||
|
// OHIF: FPS / MS / MB panels plus the per-viewport actor & mapper bindings.
|
||||||
|
const statsOverlay =
|
||||||
|
new URLSearchParams(window.location.search).get('debug') === 'true';
|
||||||
|
|
||||||
await cs3DInit({
|
await cs3DInit({
|
||||||
peerImport: appConfig.peerImport,
|
peerImport: appConfig.peerImport,
|
||||||
|
debug: { statsOverlay },
|
||||||
});
|
});
|
||||||
|
|
||||||
// For debugging e2e tests that are failing on CI
|
|
||||||
cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering));
|
cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering));
|
||||||
|
|
||||||
|
// All native ("next") Generic Viewport settings live under one config object:
|
||||||
|
// appConfig.genericViewports = { enabled, viewportRendering }.
|
||||||
|
const genericViewportsConfig = appConfig.genericViewports ?? {};
|
||||||
|
|
||||||
|
// viewportRendering selects the render backend per-session:
|
||||||
|
// `?viewportRendering=cpu|webgl|webgpu|auto` for all viewports, plus
|
||||||
|
// `?<viewportType>.viewportRendering=<backend>` (e.g.
|
||||||
|
// `?orthographic.viewportRendering=cpu`) to override a single viewport type
|
||||||
|
// via the per-mount renderBackend option. The global value maps to
|
||||||
|
// cornerstone's setRenderBackend; 'cpu'/'gpu' additionally drive the legacy
|
||||||
|
// useCPURendering flag so pre-generic viewports follow the same selection
|
||||||
|
// (letting a session force GPU when the deployed config defaults to CPU).
|
||||||
|
const { renderBackend, renderBackendByViewportType } = resolveViewportRendering(
|
||||||
|
genericViewportsConfig.viewportRendering
|
||||||
|
);
|
||||||
|
if (renderBackend) {
|
||||||
|
if (renderBackend === 'cpu') {
|
||||||
|
cornerstone.setUseCPURendering(true);
|
||||||
|
} else if (renderBackend === 'gpu') {
|
||||||
|
cornerstone.setUseCPURendering(false);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
cornerstone.setRenderBackend(renderBackend as cornerstone.RenderBackendValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`viewportRendering: "${renderBackend}" is not a registered render backend; ` +
|
||||||
|
`keeping "${cornerstone.getRenderBackend()}".`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setViewportRenderingOverrides(renderBackendByViewportType);
|
||||||
|
|
||||||
cornerstone.setConfiguration({
|
cornerstone.setConfiguration({
|
||||||
...cornerstone.getConfiguration(),
|
...cornerstone.getConfiguration(),
|
||||||
rendering: {
|
rendering: {
|
||||||
@ -81,6 +126,14 @@ export default async function init({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Opt-in: drive viewports through the DIRECT native GenericViewport ("next")
|
||||||
|
// API (PLANAR_NEXT, setDisplaySets, ...). Read by getCornerstoneViewportType
|
||||||
|
// and the CornerstoneViewportService backend split. Distinct from
|
||||||
|
// useGenericViewport above (which only enables cornerstone's compat remap).
|
||||||
|
// resolveNextViewportsEnabled lets a `?useNextViewports=true` URL param opt in
|
||||||
|
// per-session; when the param is absent, appConfig.genericViewports.enabled wins.
|
||||||
|
setNextViewportsEnabled(resolveNextViewportsEnabled(genericViewportsConfig.enabled));
|
||||||
|
|
||||||
// For debugging large datasets, otherwise prefer the defaults
|
// For debugging large datasets, otherwise prefer the defaults
|
||||||
const { maxCacheSize } = appConfig;
|
const { maxCacheSize } = appConfig;
|
||||||
if (maxCacheSize) {
|
if (maxCacheSize) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
WindowLevelTool,
|
WindowLevelTool,
|
||||||
SegmentBidirectionalTool,
|
SegmentBidirectionalTool,
|
||||||
StackScrollTool,
|
StackScrollTool,
|
||||||
|
PlanarRotateTool,
|
||||||
VolumeRotateTool,
|
VolumeRotateTool,
|
||||||
ZoomTool,
|
ZoomTool,
|
||||||
MIPJumpToClickTool,
|
MIPJumpToClickTool,
|
||||||
@ -39,7 +40,7 @@ import {
|
|||||||
OrientationMarkerTool,
|
OrientationMarkerTool,
|
||||||
WindowLevelRegionTool,
|
WindowLevelRegionTool,
|
||||||
SegmentSelectTool,
|
SegmentSelectTool,
|
||||||
RegionSegmentPlusTool,
|
ClickSegmentTool,
|
||||||
SegmentLabelTool,
|
SegmentLabelTool,
|
||||||
LivewireContourSegmentationTool,
|
LivewireContourSegmentationTool,
|
||||||
SculptorTool,
|
SculptorTool,
|
||||||
@ -74,6 +75,7 @@ export default function initCornerstoneTools(configuration = {}) {
|
|||||||
addTool(SegmentBidirectionalTool);
|
addTool(SegmentBidirectionalTool);
|
||||||
addTool(WindowLevelTool);
|
addTool(WindowLevelTool);
|
||||||
addTool(StackScrollTool);
|
addTool(StackScrollTool);
|
||||||
|
addTool(PlanarRotateTool);
|
||||||
addTool(VolumeRotateTool);
|
addTool(VolumeRotateTool);
|
||||||
addTool(ZoomTool);
|
addTool(ZoomTool);
|
||||||
addTool(ProbeTool);
|
addTool(ProbeTool);
|
||||||
@ -112,7 +114,7 @@ export default function initCornerstoneTools(configuration = {}) {
|
|||||||
addTool(SegmentLabelTool);
|
addTool(SegmentLabelTool);
|
||||||
addTool(LabelmapSlicePropagationTool);
|
addTool(LabelmapSlicePropagationTool);
|
||||||
addTool(MarkerLabelmapTool);
|
addTool(MarkerLabelmapTool);
|
||||||
addTool(RegionSegmentPlusTool);
|
addTool(ClickSegmentTool);
|
||||||
addTool(LivewireContourSegmentationTool);
|
addTool(LivewireContourSegmentationTool);
|
||||||
addTool(SculptorTool);
|
addTool(SculptorTool);
|
||||||
addTool(SplineContourSegmentationTool);
|
addTool(SplineContourSegmentationTool);
|
||||||
@ -138,6 +140,7 @@ const toolNames = {
|
|||||||
WindowLevel: WindowLevelTool.toolName,
|
WindowLevel: WindowLevelTool.toolName,
|
||||||
StackScroll: StackScrollTool.toolName,
|
StackScroll: StackScrollTool.toolName,
|
||||||
Zoom: ZoomTool.toolName,
|
Zoom: ZoomTool.toolName,
|
||||||
|
PlanarRotate: PlanarRotateTool.toolName,
|
||||||
VolumeRotate: VolumeRotateTool.toolName,
|
VolumeRotate: VolumeRotateTool.toolName,
|
||||||
MipJumpToClick: MIPJumpToClickTool.toolName,
|
MipJumpToClick: MIPJumpToClickTool.toolName,
|
||||||
Length: LengthTool.toolName,
|
Length: LengthTool.toolName,
|
||||||
@ -175,7 +178,7 @@ const toolNames = {
|
|||||||
SegmentLabel: SegmentLabelTool.toolName,
|
SegmentLabel: SegmentLabelTool.toolName,
|
||||||
LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName,
|
LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName,
|
||||||
MarkerLabelmap: MarkerLabelmapTool.toolName,
|
MarkerLabelmap: MarkerLabelmapTool.toolName,
|
||||||
RegionSegmentPlus: RegionSegmentPlusTool.toolName,
|
ClickSegment: ClickSegmentTool.toolName,
|
||||||
LivewireContourSegmentation: LivewireContourSegmentationTool.toolName,
|
LivewireContourSegmentation: LivewireContourSegmentationTool.toolName,
|
||||||
SculptorTool: SculptorTool.toolName,
|
SculptorTool: SculptorTool.toolName,
|
||||||
SplineContourSegmentation: SplineContourSegmentationTool.toolName,
|
SplineContourSegmentation: SplineContourSegmentationTool.toolName,
|
||||||
|
|||||||
@ -462,6 +462,12 @@ const connectMeasurementServiceToTools = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID, metadata } = measurement;
|
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID, metadata } = measurement;
|
||||||
|
const persistedAnnotation = data?.annotation || {};
|
||||||
|
const persistedData = persistedAnnotation.data || {};
|
||||||
|
const persistedHandles = persistedData.handles || {};
|
||||||
|
const handlePoints = Array.isArray(persistedHandles.points)
|
||||||
|
? persistedHandles.points.filter(point => Array.isArray(point) && point.length >= 2)
|
||||||
|
: [];
|
||||||
|
|
||||||
const instance = DicomMetadataStore.getInstance(
|
const instance = DicomMetadataStore.getInstance(
|
||||||
referenceStudyUID,
|
referenceStudyUID,
|
||||||
@ -506,11 +512,11 @@ const connectMeasurementServiceToTools = ({
|
|||||||
* Don't remove this destructuring of data here.
|
* Don't remove this destructuring of data here.
|
||||||
* This is used to pass annotation specific data forward e.g. contour
|
* This is used to pass annotation specific data forward e.g. contour
|
||||||
*/
|
*/
|
||||||
...(data.annotation.data || {}),
|
...(persistedData || {}),
|
||||||
text: data.annotation.data.text,
|
text: persistedData.text,
|
||||||
handles: { ...data.annotation.data.handles },
|
handles: { ...persistedHandles, points: handlePoints },
|
||||||
cachedStats: { ...data.annotation.data.cachedStats },
|
cachedStats: { ...(persistedData.cachedStats || {}) },
|
||||||
label: data.annotation.data.label,
|
label: persistedData.label,
|
||||||
frameNumber,
|
frameNumber,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -531,12 +537,12 @@ const connectMeasurementServiceToTools = ({
|
|||||||
}
|
}
|
||||||
// Cancel any active tool manipulation (e.g., Spline/Livewire) to avoid leaving the tool
|
// Cancel any active tool manipulation (e.g., Spline/Livewire) to avoid leaving the tool
|
||||||
// in a drawing state after deleting a not completed measurement, which can block viewport interactivity.
|
// in a drawing state after deleting a not completed measurement, which can block viewport interactivity.
|
||||||
const element = getActiveViewportEnabledElement(viewportGridService)?.viewport?.element;
|
commandsManager.run('cancelMeasurement');
|
||||||
if (element) {
|
|
||||||
cancelActiveManipulations(element);
|
|
||||||
}
|
|
||||||
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
|
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
|
||||||
removeAnnotation(removedMeasurementId);
|
if (removedAnnotation) {
|
||||||
|
removeAnnotation(removedMeasurementId);
|
||||||
|
}
|
||||||
// Ensure `removedAnnotation` is available before triggering the memo,
|
// Ensure `removedAnnotation` is available before triggering the memo,
|
||||||
// as it can be undefined during an undo operation
|
// as it can be undefined during an undo operation
|
||||||
if (removedAnnotation) {
|
if (removedAnnotation) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { PubSubService, Types as OhifTypes } from '@ohif/core';
|
import { PubSubService, Types as OhifTypes } from '@ohif/core';
|
||||||
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
|
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
|
||||||
import { getRenderingEngine } from '@cornerstonejs/core';
|
import { getRenderingEngine } from '@cornerstonejs/core';
|
||||||
import { getDataIdForViewport } from '../../utils/getDataIdForViewport';
|
import { getViewportAdapter } from '../ViewportService/adapter';
|
||||||
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
|
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
|
||||||
|
|
||||||
export default class ColorbarService extends PubSubService {
|
export default class ColorbarService extends PubSubService {
|
||||||
@ -60,8 +60,8 @@ export default class ColorbarService extends PubSubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actorEntries = viewport.getActors();
|
const adapter = getViewportAdapter(viewport);
|
||||||
if (!actorEntries || actorEntries.length === 0) {
|
if (!adapter.hasContent()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,8 +74,8 @@ export default class ColorbarService extends PubSubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
|
const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID);
|
||||||
const properties = dataId ? viewport.getProperties(dataId) : viewport.getProperties();
|
const properties = adapter.getPresentation(dataId);
|
||||||
const colormap = properties?.colormap;
|
const colormap = properties?.colormap;
|
||||||
|
|
||||||
if (activeColormapName && !colormap) {
|
if (activeColormapName && !colormap) {
|
||||||
@ -222,16 +222,18 @@ export default class ColorbarService extends PubSubService {
|
|||||||
private setViewportColormap(viewportId, displaySetInstanceUID, colormap, immediate = false) {
|
private setViewportColormap(viewportId, displaySetInstanceUID, colormap, immediate = false) {
|
||||||
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
|
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
|
||||||
const viewport = renderingEngine.getViewport(viewportId);
|
const viewport = renderingEngine.getViewport(viewportId);
|
||||||
const actorEntries = viewport?.getActors();
|
if (!viewport) {
|
||||||
if (!viewport || !actorEntries || actorEntries.length === 0) {
|
return;
|
||||||
|
}
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
if (!adapter.hasContent()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate dataId for this viewport/displaySet combination
|
// Address the display set's binding (volumeId on legacy multi-volume, bare
|
||||||
const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
|
// UID on native, active binding otherwise)
|
||||||
|
const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID);
|
||||||
// Set properties with or without dataId based on what the viewport supports
|
adapter.setPresentation({ colormap }, dataId);
|
||||||
viewport.setProperties({ colormap }, dataId);
|
|
||||||
|
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
viewport.render();
|
viewport.render();
|
||||||
|
|||||||
@ -41,12 +41,39 @@ class CornerstoneCacheService {
|
|||||||
const cs3DViewportType = getCornerstoneViewportType(viewportType, displaySets);
|
const cs3DViewportType = getCornerstoneViewportType(viewportType, displaySets);
|
||||||
let viewportData: StackViewportData | VolumeViewportData;
|
let viewportData: StackViewportData | VolumeViewportData;
|
||||||
|
|
||||||
|
// Native Generic ("next") viewport types (e.g. PLANAR_NEXT) intentionally
|
||||||
|
// collapse the stack/volume distinction into a single type, so they cannot
|
||||||
|
// drive the stack-vs-volume data-builder decision below. Resolve the data
|
||||||
|
// shape from the legacy mapping (which preserves that distinction) and keep
|
||||||
|
// the resolved native type as the produced viewportData's viewportType.
|
||||||
|
let dataShapeType = getCornerstoneViewportType(viewportType, displaySets, false);
|
||||||
|
|
||||||
|
// A data overlay (fusion) of two or more reconstructable image display sets
|
||||||
|
// must render as a volume viewport so the source and overlay share one
|
||||||
|
// representation (volume slice). Without this, a next (PLANAR_NEXT) viewport
|
||||||
|
// keeps the source in vtkImage (stack) mode while the added overlay is a
|
||||||
|
// vtkVolumeSlice, producing the broken/unstable fusion. SEG/RT overlays are
|
||||||
|
// non-reconstructable, so they are not affected.
|
||||||
|
//
|
||||||
|
// Scoped to the native (PLANAR_NEXT) path via cs3DViewportType — NOT the flag, and
|
||||||
|
// NOT the legacy lane: a legacy stack-shaped reconstructable overlay must keep its
|
||||||
|
// existing stack build so the flag-off path stays byte-identical.
|
||||||
|
const isReconstructableFusion =
|
||||||
|
displaySets.length > 1 && displaySets.every(ds => ds.isReconstructable);
|
||||||
if (
|
if (
|
||||||
cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC ||
|
isReconstructableFusion &&
|
||||||
cs3DViewportType === Enums.ViewportType.VOLUME_3D
|
dataShapeType === Enums.ViewportType.STACK &&
|
||||||
|
cs3DViewportType === Enums.ViewportType.PLANAR_NEXT
|
||||||
|
) {
|
||||||
|
dataShapeType = Enums.ViewportType.ORTHOGRAPHIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dataShapeType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||||
|
dataShapeType === Enums.ViewportType.VOLUME_3D
|
||||||
) {
|
) {
|
||||||
viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType);
|
viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType);
|
||||||
} else if (cs3DViewportType === Enums.ViewportType.STACK) {
|
} else if (dataShapeType === Enums.ViewportType.STACK) {
|
||||||
// Everything else looks like a stack
|
// Everything else looks like a stack
|
||||||
viewportData = await this._getStackViewportData(
|
viewportData = await this._getStackViewportData(
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -64,6 +91,9 @@ class CornerstoneCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewportData.viewportType = cs3DViewportType;
|
viewportData.viewportType = cs3DViewportType;
|
||||||
|
// Persist the legacy stack/volume shape so consumers can distinguish stack from
|
||||||
|
// volume content even when viewportType is a native Generic type (PLANAR_NEXT).
|
||||||
|
viewportData.dataShapeType = dataShapeType;
|
||||||
|
|
||||||
return viewportData;
|
return viewportData;
|
||||||
}
|
}
|
||||||
@ -74,7 +104,13 @@ class CornerstoneCacheService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
displaySetService
|
displaySetService
|
||||||
): Promise<VolumeViewportData | StackViewportData> {
|
): Promise<VolumeViewportData | StackViewportData> {
|
||||||
if (viewportData.viewportType === Enums.ViewportType.STACK) {
|
// Decide stack-vs-volume rebuild from the persisted data shape, NOT viewportType:
|
||||||
|
// native viewports collapse both onto PLANAR_NEXT, so a native stack would
|
||||||
|
// otherwise fall through to the volume rebuild and re-mount as volume data.
|
||||||
|
// Falls back to viewportType for legacy/older viewportData (byte-identical off-path).
|
||||||
|
const dataShapeType = viewportData.dataShapeType ?? viewportData.viewportType;
|
||||||
|
|
||||||
|
if (dataShapeType === Enums.ViewportType.STACK) {
|
||||||
const displaySet = displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID);
|
const displaySet = displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID);
|
||||||
const imageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
|
const imageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
|
||||||
|
|
||||||
@ -86,7 +122,10 @@ class CornerstoneCacheService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
viewportType: Enums.ViewportType.STACK,
|
// Preserve the original viewportType (legacy STACK or native PLANAR_NEXT);
|
||||||
|
// the rebuilt data shape, not this field, drives the native re-mount dispatch.
|
||||||
|
viewportType: viewportData.viewportType,
|
||||||
|
dataShapeType: Enums.ViewportType.STACK,
|
||||||
data: {
|
data: {
|
||||||
StudyInstanceUID: displaySet.StudyInstanceUID,
|
StudyInstanceUID: displaySet.StudyInstanceUID,
|
||||||
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
|
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
|
||||||
@ -128,6 +167,7 @@ class CornerstoneCacheService {
|
|||||||
displaySets,
|
displaySets,
|
||||||
viewportData.viewportType
|
viewportData.viewportType
|
||||||
);
|
);
|
||||||
|
newViewportData.dataShapeType = dataShapeType;
|
||||||
|
|
||||||
return newViewportData;
|
return newViewportData;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions';
|
import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions';
|
||||||
import * as MapROIContoursToRTStructData from './RTSTRUCT/mapROIContoursToRTStructData';
|
import * as MapROIContoursToRTStructData from './RTSTRUCT/mapROIContoursToRTStructData';
|
||||||
import SegmentationServiceClass, { SegmentationRepresentation } from './SegmentationService';
|
import SegmentationServiceClass, { SegmentationRepresentation } from './SegmentationService';
|
||||||
|
import { setNextViewportsEnabled } from '../../utils/nextViewports';
|
||||||
|
|
||||||
jest.mock('@cornerstonejs/core', () => ({
|
jest.mock('@cornerstonejs/core', () => ({
|
||||||
...jest.requireActual('@cornerstonejs/core'),
|
...jest.requireActual('@cornerstonejs/core'),
|
||||||
@ -914,6 +915,101 @@ describe('SegmentationService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('next (generic) viewport', () => {
|
||||||
|
// A native GenericViewport (raw PlanarViewport) exposes setDisplaySets /
|
||||||
|
// setDisplaySetPresentation / setViewState, so csUtils.isGenericViewport is
|
||||||
|
// true and addSegmentationRepresentation routes to NextSegmentationBackend.
|
||||||
|
// It deliberately has NO getViewPresentation: the native path must never reach
|
||||||
|
// convertStackToVolumeViewport (the source of the observed
|
||||||
|
// "getViewPresentation is not a function" / silent ORTHOGRAPHIC flip).
|
||||||
|
const makeNextViewport = () => ({
|
||||||
|
element: { addEventListener: jest.fn(), removeEventListener: jest.fn() },
|
||||||
|
id: viewportId,
|
||||||
|
type: ViewportType.PLANAR_NEXT,
|
||||||
|
setDisplaySets: jest.fn(),
|
||||||
|
setDisplaySetPresentation: jest.fn(),
|
||||||
|
setViewState: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the labelmap in place and never promotes the viewport (resolver maps in place)', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(cstSegmentation.state, 'getSegmentation')
|
||||||
|
.mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation);
|
||||||
|
jest
|
||||||
|
.spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport')
|
||||||
|
.mockReturnValue(makeNextViewport() as unknown as csTypes.IStackViewport);
|
||||||
|
jest
|
||||||
|
.spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences')
|
||||||
|
.mockReturnValue('labelmapImageId');
|
||||||
|
jest
|
||||||
|
.spyOn(cstSegmentation, 'addSegmentationRepresentations')
|
||||||
|
.mockReturnValueOnce(undefined);
|
||||||
|
const convertSpy = jest.spyOn(service, 'convertStackToVolumeViewport');
|
||||||
|
|
||||||
|
const callback = jest.fn();
|
||||||
|
service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback);
|
||||||
|
|
||||||
|
await service.addSegmentationRepresentation(viewportId, {
|
||||||
|
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||||
|
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||||
|
});
|
||||||
|
|
||||||
|
// the native in-place resolver is consulted...
|
||||||
|
expect(
|
||||||
|
cstSegmentation.state.updateLabelmapSegmentationImageReferences
|
||||||
|
).toHaveBeenCalledWith(viewportId, mockCornerstoneSegmentation.segmentationId);
|
||||||
|
|
||||||
|
// ...but the viewport is NEVER promoted to an ORTHOGRAPHIC volume viewport
|
||||||
|
expect(convertSpy).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// the representation is added in place, synchronously (isConverted === false)
|
||||||
|
expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [
|
||||||
|
{
|
||||||
|
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||||
|
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||||
|
config: { colorLUTOrIndex: undefined },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith({
|
||||||
|
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never promotes on native even when the in-place resolver cannot map (returns isConverted:false unconditionally)', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(cstSegmentation.state, 'getSegmentation')
|
||||||
|
.mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation);
|
||||||
|
jest
|
||||||
|
.spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport')
|
||||||
|
.mockReturnValue(makeNextViewport() as unknown as csTypes.IStackViewport);
|
||||||
|
// resolver fails (FrameOfReference mismatch / mount-timing race): on legacy this
|
||||||
|
// would fall through to convertStackToVolumeViewport; on native it must not.
|
||||||
|
jest
|
||||||
|
.spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences')
|
||||||
|
.mockReturnValue(undefined);
|
||||||
|
jest
|
||||||
|
.spyOn(cstSegmentation, 'addSegmentationRepresentations')
|
||||||
|
.mockReturnValueOnce(undefined);
|
||||||
|
const convertSpy = jest.spyOn(service, 'convertStackToVolumeViewport');
|
||||||
|
|
||||||
|
await service.addSegmentationRepresentation(viewportId, {
|
||||||
|
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||||
|
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(convertSpy).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('volume viewport', () => {
|
describe('volume viewport', () => {
|
||||||
it('should add a segmentation representation to volume viewport without need for handling', async () => {
|
it('should add a segmentation representation to volume viewport without need for handling', async () => {
|
||||||
jest
|
jest
|
||||||
@ -1471,6 +1567,136 @@ describe('SegmentationService', () => {
|
|||||||
|
|
||||||
expect(retrievedSegmentationId).toEqual(segmentationId);
|
expect(retrievedSegmentationId).toEqual(segmentationId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('overlap / useSliceRendering (next backend)', () => {
|
||||||
|
const segmentationId = 'segmentationId';
|
||||||
|
|
||||||
|
// The session flag selects the seg-backend lane at SEG-load; reset it so it
|
||||||
|
// never leaks into the legacy-path tests that follow.
|
||||||
|
afterEach(() => {
|
||||||
|
setNextViewportsEnabled(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const segMetadata = {
|
||||||
|
data: [
|
||||||
|
{},
|
||||||
|
{ SegmentNumber: '1', SegmentLabel: 'Segment 1', rgba: [255, 0, 0, 255] },
|
||||||
|
{ SegmentNumber: '2', SegmentLabel: 'Segment 2', rgba: [0, 255, 0, 255] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const centroids = new Map([
|
||||||
|
[1, { image: { x: 0, y: 0, z: 0 }, world: { x: 0, y: 0, z: 0 } }],
|
||||||
|
[2, { image: { x: 1, y: 1, z: 1 }, world: { x: 1, y: 1, z: 1 } }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// labelMapImages is the adapter's array-of-GROUPS (one conflict-free group per
|
||||||
|
// overlap layer). Two groups whose voxels carry values {1} and {2} respectively.
|
||||||
|
const makeOverlapGroups = () => {
|
||||||
|
const vm0 = { getScalarData: jest.fn().mockReturnValue([1, 0]), setScalarData: jest.fn() };
|
||||||
|
const vm1 = { getScalarData: jest.fn().mockReturnValue([0, 2]), setScalarData: jest.fn() };
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{ imageId: 'g0i1', referencedImageId: 'r1', voxelManager: vm0 },
|
||||||
|
{ imageId: 'g0i2', referencedImageId: 'r2', voxelManager: vm0 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ imageId: 'g1i1', referencedImageId: 'r1', voxelManager: vm1 },
|
||||||
|
{ imageId: 'g1i2', referencedImageId: 'r2', voxelManager: vm1 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSegDisplaySet = (labelMapImages, overlappingSegments) => ({
|
||||||
|
centroids,
|
||||||
|
displaySetInstanceUID: 'display-set-uid',
|
||||||
|
referencedDisplaySetInstanceUID: 'existent-display-set-uid',
|
||||||
|
labelMapImages,
|
||||||
|
overlappingSegments,
|
||||||
|
segMetadata,
|
||||||
|
SeriesDate: '2025-01-01',
|
||||||
|
SeriesDescription: 'Series Description',
|
||||||
|
Modality: 'SEG',
|
||||||
|
SeriesNumber: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const primeMocks = () => {
|
||||||
|
jest
|
||||||
|
.spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID')
|
||||||
|
.mockReturnValue({ instances: [{ imageId: 'r1' }, { imageId: 'r2' }] });
|
||||||
|
jest.spyOn(metaData, 'get').mockReturnValue({});
|
||||||
|
jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('flag ON + overlapping SEG builds one labelmap layer per group + segmentBindings', async () => {
|
||||||
|
setNextViewportsEnabled(true);
|
||||||
|
primeMocks();
|
||||||
|
|
||||||
|
await service.createSegmentationForSEGDisplaySet(
|
||||||
|
makeSegDisplaySet(makeOverlapGroups(), true),
|
||||||
|
{ type: csToolsEnums.SegmentationRepresentations.Labelmap, segmentationId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const seg = jest.mocked(service.addOrUpdateSegmentation).mock.calls[0][0];
|
||||||
|
const data = seg.representation.data as Record<string, any>;
|
||||||
|
|
||||||
|
// one labelmap layer per conflict-free group, under ONE segmentationId
|
||||||
|
expect(Object.keys(data.labelmaps)).toEqual([
|
||||||
|
'segmentationId-storage-0',
|
||||||
|
'segmentationId-storage-1',
|
||||||
|
]);
|
||||||
|
expect(data.labelmaps['segmentationId-storage-0'].imageIds).toEqual(['g0i1', 'g0i2']);
|
||||||
|
expect(data.labelmaps['segmentationId-storage-1'].imageIds).toEqual(['g1i1', 'g1i2']);
|
||||||
|
expect(data.labelmaps['segmentationId-storage-0'].storageKind).toBe('stack');
|
||||||
|
|
||||||
|
// segment->layer bindings recovered from the distinct non-zero voxel values
|
||||||
|
expect(data.segmentBindings).toEqual({
|
||||||
|
1: { labelmapId: 'segmentationId-storage-0', labelValue: 1 },
|
||||||
|
2: { labelmapId: 'segmentationId-storage-1', labelValue: 2 },
|
||||||
|
});
|
||||||
|
expect(data.primaryLabelmapId).toBe('segmentationId-storage-0');
|
||||||
|
// flattened list retained for legacy singular readers
|
||||||
|
expect(data.imageIds).toEqual(['g0i1', 'g0i2', 'g1i1', 'g1i2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flag ON + non-overlapping SEG stays a single layer (no multi-layer map)', async () => {
|
||||||
|
setNextViewportsEnabled(true);
|
||||||
|
primeMocks();
|
||||||
|
|
||||||
|
const vm = { getScalarData: jest.fn().mockReturnValue([1, 0]), setScalarData: jest.fn() };
|
||||||
|
const singleGroup = [
|
||||||
|
[
|
||||||
|
{ imageId: 'i1', referencedImageId: 'r1', voxelManager: vm },
|
||||||
|
{ imageId: 'i2', referencedImageId: 'r2', voxelManager: vm },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
await service.createSegmentationForSEGDisplaySet(makeSegDisplaySet(singleGroup, false), {
|
||||||
|
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||||
|
segmentationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const seg = jest.mocked(service.addOrUpdateSegmentation).mock.calls[0][0];
|
||||||
|
const data = seg.representation.data as Record<string, any>;
|
||||||
|
expect(data.labelmaps).toBeUndefined();
|
||||||
|
expect(data.segmentBindings).toBeUndefined();
|
||||||
|
expect(data.imageIds).toEqual(['i1', 'i2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flag OFF + overlapping SEG collapses to a single flattened layer (legacy byte-identical)', async () => {
|
||||||
|
// flag stays off (default)
|
||||||
|
primeMocks();
|
||||||
|
|
||||||
|
await service.createSegmentationForSEGDisplaySet(
|
||||||
|
makeSegDisplaySet(makeOverlapGroups(), true),
|
||||||
|
{ type: csToolsEnums.SegmentationRepresentations.Labelmap, segmentationId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const seg = jest.mocked(service.addOrUpdateSegmentation).mock.calls[0][0];
|
||||||
|
const data = seg.representation.data as Record<string, any>;
|
||||||
|
expect(data.labelmaps).toBeUndefined();
|
||||||
|
expect(data.imageIds).toEqual(['g0i1', 'g0i2', 'g1i1', 'g1i2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createSegmentationForRTDisplaySet', () => {
|
describe('createSegmentationForRTDisplaySet', () => {
|
||||||
@ -1799,6 +2025,10 @@ describe('SegmentationService', () => {
|
|||||||
const segmentationId = 'segmentationId';
|
const segmentationId = 'segmentationId';
|
||||||
const segmentationData = {
|
const segmentationData = {
|
||||||
segmentationId,
|
segmentationId,
|
||||||
|
representation: {
|
||||||
|
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
label: 'Segmentation 1',
|
label: 'Segmentation 1',
|
||||||
},
|
},
|
||||||
@ -2694,6 +2924,47 @@ describe('SegmentationService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('navigates a native viewport via setViewReference (no jumpToWorld) and still highlights', () => {
|
||||||
|
const segmentationId = 'segmentationId';
|
||||||
|
const segmentIndex = 1;
|
||||||
|
const viewportId = 'viewportId';
|
||||||
|
// A native PlanarViewport: csUtils.isGenericViewport is true (setDisplaySets +
|
||||||
|
// setDisplaySetPresentation + setViewState) and it has setViewReference but NO
|
||||||
|
// jumpToWorld, so jumpToSegmentCenter routes to the next twin.
|
||||||
|
const viewport = {
|
||||||
|
setDisplaySets: jest.fn(),
|
||||||
|
setDisplaySetPresentation: jest.fn(),
|
||||||
|
setViewState: jest.fn(),
|
||||||
|
setViewReference: jest.fn(),
|
||||||
|
render: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const segmentationWithCenter = {
|
||||||
|
...mockCornerstoneSegmentation,
|
||||||
|
segments: {
|
||||||
|
...mockCornerstoneSegmentation.segments,
|
||||||
|
[segmentIndex]: {
|
||||||
|
...mockCornerstoneSegmentation.segments[segmentIndex],
|
||||||
|
cachedStats: { center: { image: [1, 1, 1], world: [10, 10, 10] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(cstSegmentation.state, 'getSegmentation').mockReturnValue(segmentationWithCenter);
|
||||||
|
// @ts-expect-error - mock only needed properties
|
||||||
|
getEnabledElementByViewportId.mockReturnValue({ viewport });
|
||||||
|
jest.spyOn(service, 'highlightSegment').mockReturnValue(undefined);
|
||||||
|
|
||||||
|
service.jumpToSegmentCenter(segmentationId, segmentIndex, viewportId);
|
||||||
|
|
||||||
|
// native jump: a view reference centered on the segment world point, then render
|
||||||
|
expect(viewport.setViewReference).toHaveBeenCalledTimes(1);
|
||||||
|
expect(viewport.setViewReference).toHaveBeenCalledWith({ cameraFocalPoint: [10, 10, 10] });
|
||||||
|
expect(viewport.render).toHaveBeenCalledTimes(1);
|
||||||
|
// the recenter happened, so the highlight still runs
|
||||||
|
expect(service.highlightSegment).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should correctly handle custom animation parameters', () => {
|
it('should correctly handle custom animation parameters', () => {
|
||||||
const segmentationId = 'segmentationId';
|
const segmentationId = 'segmentationId';
|
||||||
const segmentIndex = 1;
|
const segmentIndex = 1;
|
||||||
|
|||||||
@ -10,10 +10,7 @@ import {
|
|||||||
metaData,
|
metaData,
|
||||||
} from '@cornerstonejs/core';
|
} from '@cornerstonejs/core';
|
||||||
import { ViewportType } from '@cornerstonejs/core/enums';
|
import { ViewportType } from '@cornerstonejs/core/enums';
|
||||||
import {
|
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
|
||||||
isVolume3DViewportType,
|
|
||||||
isVolumeViewportType,
|
|
||||||
} from '../../utils/getLegacyViewportType';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Enums as csToolsEnums,
|
Enums as csToolsEnums,
|
||||||
@ -30,6 +27,17 @@ import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStruc
|
|||||||
import { SegmentationPresentation, SegmentationPresentationItem } from '../../types/Presentation';
|
import { SegmentationPresentation, SegmentationPresentationItem } from '../../types/Presentation';
|
||||||
import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions';
|
import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions';
|
||||||
import { ViewReference } from '@cornerstonejs/core/types';
|
import { ViewReference } from '@cornerstonejs/core/types';
|
||||||
|
import {
|
||||||
|
LegacySegmentationBackend,
|
||||||
|
NextSegmentationBackend,
|
||||||
|
type ISegmentationBackend,
|
||||||
|
type ISegmentationServiceInternals,
|
||||||
|
} from './backends';
|
||||||
|
// Sanctioned flag read: the SEG data shape (single- vs multi-layer) is fixed at
|
||||||
|
// load time, before any target viewport exists, so this one seg-backend dispatch
|
||||||
|
// cannot use a per-viewport capability check and reads the session flag instead.
|
||||||
|
import { isNextViewportsEnabled } from '../../utils/nextViewports';
|
||||||
|
import { isNextViewport } from '../ViewportService/adapter';
|
||||||
|
|
||||||
const { DefaultHistoryMemo } = csUtils.HistoryMemo;
|
const { DefaultHistoryMemo } = csUtils.HistoryMemo;
|
||||||
|
|
||||||
@ -42,7 +50,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
getLabelmapImageIds,
|
getLabelmapImageIds,
|
||||||
helpers: { convertStackToVolumeLabelmap },
|
helpers: { convertStackToVolumeLabelmap },
|
||||||
state: { addColorLUT, updateLabelmapSegmentationImageReferences },
|
state: { addColorLUT },
|
||||||
triggerSegmentationEvents: { triggerSegmentationRepresentationModified },
|
triggerSegmentationEvents: { triggerSegmentationRepresentationModified },
|
||||||
} = cstSegmentation;
|
} = cstSegmentation;
|
||||||
|
|
||||||
@ -98,7 +106,7 @@ const EVENTS = {
|
|||||||
|
|
||||||
const VALUE_TYPES = {};
|
const VALUE_TYPES = {};
|
||||||
|
|
||||||
class SegmentationService extends PubSubService {
|
class SegmentationService extends PubSubService implements ISegmentationServiceInternals {
|
||||||
static REGISTRATION = {
|
static REGISTRATION = {
|
||||||
name: 'segmentationService',
|
name: 'segmentationService',
|
||||||
altName: 'SegmentationService',
|
altName: 'SegmentationService',
|
||||||
@ -109,6 +117,8 @@ class SegmentationService extends PubSubService {
|
|||||||
|
|
||||||
private _segmentationIdToColorLUTIndexMap: Map<string, number>;
|
private _segmentationIdToColorLUTIndexMap: Map<string, number>;
|
||||||
private _segmentationGroupStatsMap: Map<string, any>;
|
private _segmentationGroupStatsMap: Map<string, any>;
|
||||||
|
private readonly _legacySegBackend: ISegmentationBackend;
|
||||||
|
private readonly _nextSegBackend: ISegmentationBackend;
|
||||||
readonly servicesManager: AppTypes.ServicesManager;
|
readonly servicesManager: AppTypes.ServicesManager;
|
||||||
highlightIntervalId = null;
|
highlightIntervalId = null;
|
||||||
readonly EVENTS = EVENTS;
|
readonly EVENTS = EVENTS;
|
||||||
@ -121,6 +131,24 @@ class SegmentationService extends PubSubService {
|
|||||||
this.servicesManager = servicesManager;
|
this.servicesManager = servicesManager;
|
||||||
|
|
||||||
this._segmentationGroupStatsMap = new Map();
|
this._segmentationGroupStatsMap = new Map();
|
||||||
|
|
||||||
|
// Segmentation backend twins (mirror the viewport backend family). Routed PER
|
||||||
|
// VIEWPORT via _segBackend() using the adapter's isNextViewport predicate,
|
||||||
|
// because a flag-on session can mix native and legacy viewports. Both are
|
||||||
|
// constructed eagerly:
|
||||||
|
// per-viewport dispatch has no per-session flag to defer on, and the twins read
|
||||||
|
// state at call time (post-init), not at construction.
|
||||||
|
this._legacySegBackend = new LegacySegmentationBackend(this);
|
||||||
|
this._nextSegBackend = new NextSegmentationBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the segmentation backend lane for a specific viewport: the native
|
||||||
|
* ("next") twin for a raw GenericViewport (PlanarViewport), the legacy twin
|
||||||
|
* otherwise. Mirrors viewportOperations' per-viewport dispatch.
|
||||||
|
*/
|
||||||
|
private _segBackend(viewport: csTypes.IViewport): ISegmentationBackend {
|
||||||
|
return isNextViewport(viewport) ? this._nextSegBackend : this._legacySegBackend;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onModeEnter(): void {
|
public onModeEnter(): void {
|
||||||
@ -297,12 +325,21 @@ class SegmentationService extends PubSubService {
|
|||||||
type?: csToolsEnums.SegmentationRepresentations;
|
type?: csToolsEnums.SegmentationRepresentations;
|
||||||
config?: {
|
config?: {
|
||||||
blendMode?: csEnums.BlendModes;
|
blendMode?: csEnums.BlendModes;
|
||||||
|
useSliceRendering?: boolean;
|
||||||
};
|
};
|
||||||
suppressEvents?: boolean;
|
suppressEvents?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const segmentation = this.getSegmentation(segmentationId);
|
const segmentation = this.getSegmentation(segmentationId);
|
||||||
if (segmentation && !segmentation.predecessorImageId && predecessorImageId) {
|
|
||||||
|
if (!segmentation) {
|
||||||
|
console.warn(
|
||||||
|
`addSegmentationRepresentation: segmentation "${segmentationId}" is not in state yet`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!segmentation.predecessorImageId && predecessorImageId) {
|
||||||
segmentation.predecessorImageId = predecessorImageId;
|
segmentation.predecessorImageId = predecessorImageId;
|
||||||
}
|
}
|
||||||
const csViewport = this.getAndValidateViewport(viewportId);
|
const csViewport = this.getAndValidateViewport(viewportId);
|
||||||
@ -311,6 +348,15 @@ class SegmentationService extends PubSubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stale/invalid segmentationId yields no segmentation; fail fast with a clear
|
||||||
|
// message instead of dereferencing representationData deep inside the backend
|
||||||
|
// classification below.
|
||||||
|
if (!segmentation) {
|
||||||
|
throw new Error(
|
||||||
|
`SegmentationService: cannot add representation - segmentation "${segmentationId}" not found.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const colorLUTIndex = this._segmentationIdToColorLUTIndexMap.get(segmentationId);
|
const colorLUTIndex = this._segmentationIdToColorLUTIndexMap.get(segmentationId);
|
||||||
|
|
||||||
let isConverted = false;
|
let isConverted = false;
|
||||||
@ -320,20 +366,35 @@ class SegmentationService extends PubSubService {
|
|||||||
let representationTypeToUse = type || defaultRepresentationType;
|
let representationTypeToUse = type || defaultRepresentationType;
|
||||||
|
|
||||||
if (representationTypeToUse === LABELMAP) {
|
if (representationTypeToUse === LABELMAP) {
|
||||||
const { isVolumeViewport, isVolumeSegmentation } = this.determineViewportAndSegmentationType(
|
({ representationTypeToUse, isConverted } = await this._segBackend(
|
||||||
csViewport,
|
csViewport
|
||||||
segmentation
|
).classifyAndPrepareLabelmapAdd(
|
||||||
) || { isVolumeViewport: false, isVolumeSegmentation: false };
|
|
||||||
|
|
||||||
({ representationTypeToUse, isConverted } = await this.handleViewportConversion(
|
|
||||||
isVolumeViewport,
|
|
||||||
isVolumeSegmentation,
|
|
||||||
csViewport,
|
csViewport,
|
||||||
segmentation,
|
segmentation,
|
||||||
viewportId,
|
viewportId,
|
||||||
segmentationId,
|
segmentationId,
|
||||||
representationTypeToUse
|
representationTypeToUse
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Overlap precondition: an overlapping SEG is registered as multiple labelmap
|
||||||
|
// layers, but cornerstone only stacks them (slice rendering) when the viewport
|
||||||
|
// renders as a volume slice (VTK_VOLUME_SLICE) — i.e. an MPR/volume viewport. On
|
||||||
|
// a stack/acquisition viewport the render plan falls back to a single layer, so
|
||||||
|
// only the primary group is visible. Warn rather than fail silently.
|
||||||
|
const labelmapLayers = segmentation?.representationData?.[LABELMAP]?.labelmaps;
|
||||||
|
const isOverlapping = labelmapLayers && Object.keys(labelmapLayers).length > 1;
|
||||||
|
if (
|
||||||
|
isOverlapping &&
|
||||||
|
isNextViewport(csViewport) &&
|
||||||
|
!csUtils.viewportIsInVolumeMode(csViewport)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Overlapping segmentation ${segmentationId} has multiple labelmap layers, but ` +
|
||||||
|
`viewport ${viewportId} does not render as a volume slice (VTK_VOLUME_SLICE); ` +
|
||||||
|
`only the primary layer will be visible. Display the segmentation in an ` +
|
||||||
|
`MPR/volume layout to see all overlapping segments.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._addSegmentationRepresentation(
|
await this._addSegmentationRepresentation(
|
||||||
@ -488,10 +549,22 @@ class SegmentationService extends PubSubService {
|
|||||||
throw new Error('No instances were provided for the referenced display set of the SEG');
|
throw new Error('No instances were provided for the referenced display set of the SEG');
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageIds = images.map(image => image.imageId);
|
// Use the same imageIds as SEG parse (_loadSegments stores these on segDisplaySet).
|
||||||
|
const imageIds =
|
||||||
|
segDisplaySet.referencedImageIds ||
|
||||||
|
(referencedDisplaySet.imageIds as string[] | undefined) ||
|
||||||
|
images.map(image => image.imageId);
|
||||||
|
|
||||||
|
if (!imageIds?.length) {
|
||||||
|
throw new Error('referencedDisplaySet has no imageIds for SEG');
|
||||||
|
}
|
||||||
const derivedImages = labelMapImages?.flat();
|
const derivedImages = labelMapImages?.flat();
|
||||||
const derivedImageIds = derivedImages.map(image => image.imageId);
|
const derivedImageIds = derivedImages.map(image => image.imageId);
|
||||||
|
|
||||||
|
// Note: instance runtime props (frameNumber, imageId, url, ...) are
|
||||||
|
// intentionally non-enumerable, so this spread deliberately does NOT copy
|
||||||
|
// them — frameNumber must not be carried onto these derived image entries.
|
||||||
|
// Read such props off the original instance, never off a copy.
|
||||||
segDisplaySet.images = derivedImages.map(image => ({
|
segDisplaySet.images = derivedImages.map(image => ({
|
||||||
...image,
|
...image,
|
||||||
...metaData.get('instance', image.referencedImageId),
|
...metaData.get('instance', image.referencedImageId),
|
||||||
@ -572,32 +645,36 @@ class SegmentationService extends PubSubService {
|
|||||||
const colorLUTIndex = addColorLUT(colorLUT);
|
const colorLUTIndex = addColorLUT(colorLUT);
|
||||||
this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex);
|
this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex);
|
||||||
|
|
||||||
|
// Build the segmentation input via the backend twin. At SEG-load there is no
|
||||||
|
// target viewport yet, so the lane is chosen by the session flag (the one
|
||||||
|
// viewport-less seg-backend dispatch): the next twin registers overlapping SEGs
|
||||||
|
// as multiple labelmap layers (slice rendering); the legacy twin keeps the single
|
||||||
|
// flattened layer (byte-identical).
|
||||||
|
const segBackend = isNextViewportsEnabled() ? this._nextSegBackend : this._legacySegBackend;
|
||||||
|
const seg = segBackend.assembleSegmentationDataForSEG({
|
||||||
|
segmentationId,
|
||||||
|
segDisplaySet,
|
||||||
|
derivedImageIds,
|
||||||
|
referencedImageIds: imageIds as string[],
|
||||||
|
label: segDisplaySet.SeriesDescription,
|
||||||
|
fallbackLabel: `S:${segDisplaySet.SeriesNumber} ${segDisplaySet.Modality}`,
|
||||||
|
segments,
|
||||||
|
});
|
||||||
|
|
||||||
|
segDisplaySet.isLoaded = true;
|
||||||
|
|
||||||
|
// Add the segmentation to cornerstone state BEFORE broadcasting that loading is
|
||||||
|
// complete. Subscribers (e.g. CornerstoneViewportService) react synchronously and
|
||||||
|
// call addSegmentationRepresentation, which now early-returns when the segmentation
|
||||||
|
// is not yet in cornerstone state. Broadcasting first would make that guard always
|
||||||
|
// fire on initial load, silently preventing the representation from being attached.
|
||||||
|
this.addOrUpdateSegmentation(seg);
|
||||||
|
|
||||||
this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, {
|
this._broadcastEvent(EVENTS.SEGMENTATION_LOADING_COMPLETE, {
|
||||||
segmentationId,
|
segmentationId,
|
||||||
segDisplaySet,
|
segDisplaySet,
|
||||||
});
|
});
|
||||||
|
|
||||||
const seg: cstTypes.SegmentationPublicInput = {
|
|
||||||
segmentationId,
|
|
||||||
representation: {
|
|
||||||
type: LABELMAP,
|
|
||||||
data: {
|
|
||||||
imageIds: derivedImageIds,
|
|
||||||
// referencedVolumeId: this._getVolumeIdForDisplaySet(referencedDisplaySet),
|
|
||||||
referencedImageIds: imageIds as string[],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
label: segDisplaySet.SeriesDescription,
|
|
||||||
fallbackLabel: `S:${segDisplaySet.SeriesNumber} ${segDisplaySet.Modality}`,
|
|
||||||
segments,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
segDisplaySet.isLoaded = true;
|
|
||||||
|
|
||||||
this.addOrUpdateSegmentation(seg);
|
|
||||||
|
|
||||||
return segmentationId;
|
return segmentationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -772,9 +849,16 @@ class SegmentationService extends PubSubService {
|
|||||||
if (existingSegmentation) {
|
if (existingSegmentation) {
|
||||||
// Update the existing segmentation
|
// Update the existing segmentation
|
||||||
this.updateSegmentationInSource(segmentationId, data as Partial<cstTypes.Segmentation>);
|
this.updateSegmentationInSource(segmentationId, data as Partial<cstTypes.Segmentation>);
|
||||||
} else {
|
} else if (
|
||||||
|
'representation' in data &&
|
||||||
|
(data as cstTypes.SegmentationPublicInput).representation
|
||||||
|
) {
|
||||||
// Add a new segmentation
|
// Add a new segmentation
|
||||||
this.addSegmentationToSource(data as cstTypes.SegmentationPublicInput);
|
this.addSegmentationToSource(data as cstTypes.SegmentationPublicInput);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`addOrUpdateSegmentation: skipping add for ${segmentationId} — missing representation`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1483,13 +1567,17 @@ class SegmentationService extends PubSubService {
|
|||||||
|
|
||||||
viewportIds.forEach(viewportId => {
|
viewportIds.forEach(viewportId => {
|
||||||
const { viewport } = getEnabledElementByViewportId(viewportId);
|
const { viewport } = getEnabledElementByViewportId(viewportId);
|
||||||
if (!viewport?.jumpToWorld) {
|
if (!viewport) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
viewport.jumpToWorld(world);
|
// Recenter via the backend twin: legacy jumpToWorld, or native setViewReference
|
||||||
|
// (a native PlanarViewport has no jumpToWorld). Skip the highlight when the
|
||||||
|
// recenter did not happen, matching the previous guarded behavior.
|
||||||
|
const didJump = this._segBackend(viewport).jumpToSegmentCenter(viewport, world);
|
||||||
|
|
||||||
highlightSegment &&
|
didJump &&
|
||||||
|
highlightSegment &&
|
||||||
this.highlightSegment(
|
this.highlightSegment(
|
||||||
segmentationId,
|
segmentationId,
|
||||||
segmentIndex,
|
segmentIndex,
|
||||||
@ -1594,81 +1682,11 @@ class SegmentationService extends PubSubService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private determineViewportAndSegmentationType(csViewport, segmentation) {
|
// Labelmap-add classification (determineViewportAndSegmentationType +
|
||||||
const isVolumeViewport = isVolumeViewportType(csViewport);
|
// handleViewportConversion + the stack/volume case handlers) now lives in the
|
||||||
const isVolumeSegmentation = 'volumeId' in segmentation.representationData[LABELMAP];
|
// segmentation backend twins (backends/{Legacy,Next}SegmentationBackend), routed
|
||||||
return { isVolumeViewport, isVolumeSegmentation };
|
// per viewport via _segBackend(). The legacy twin reaches the viewport-recreation
|
||||||
}
|
// and data-volume-conversion helpers below through ISegmentationServiceInternals.
|
||||||
|
|
||||||
private async handleViewportConversion(
|
|
||||||
isVolumeViewport: boolean,
|
|
||||||
isVolumeSegmentation: boolean,
|
|
||||||
csViewport: csTypes.IViewport,
|
|
||||||
segmentation: cstTypes.Segmentation,
|
|
||||||
viewportId: string,
|
|
||||||
segmentationId: string,
|
|
||||||
representationType: csToolsEnums.SegmentationRepresentations
|
|
||||||
) {
|
|
||||||
let representationTypeToUse = representationType;
|
|
||||||
let isConverted = false;
|
|
||||||
|
|
||||||
const handler = isVolumeViewport ? this.handleVolumeViewportCase : this.handleStackViewportCase;
|
|
||||||
|
|
||||||
({ representationTypeToUse, isConverted } = await handler.apply(this, [
|
|
||||||
csViewport,
|
|
||||||
segmentation,
|
|
||||||
isVolumeSegmentation,
|
|
||||||
viewportId,
|
|
||||||
segmentationId,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return { representationTypeToUse, isConverted };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleVolumeViewportCase(csViewport, segmentation, isVolumeSegmentation) {
|
|
||||||
if (isVolume3DViewportType(csViewport)) {
|
|
||||||
return {
|
|
||||||
representationTypeToUse: SURFACE,
|
|
||||||
isConverted: false,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
await this.handleVolumeViewport(
|
|
||||||
csViewport as csTypes.IVolumeViewport,
|
|
||||||
segmentation,
|
|
||||||
isVolumeSegmentation
|
|
||||||
);
|
|
||||||
return { representationTypeToUse: LABELMAP, isConverted: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStackViewportCase(
|
|
||||||
csViewport: csTypes.IViewport,
|
|
||||||
segmentation: cstTypes.Segmentation,
|
|
||||||
isVolumeSegmentation: boolean,
|
|
||||||
viewportId: string,
|
|
||||||
segmentationId: string
|
|
||||||
): Promise<{
|
|
||||||
representationTypeToUse: csToolsEnums.SegmentationRepresentations;
|
|
||||||
isConverted: boolean;
|
|
||||||
}> {
|
|
||||||
if (isVolumeSegmentation) {
|
|
||||||
const isConverted = await this.convertStackToVolumeViewport(csViewport);
|
|
||||||
return { representationTypeToUse: LABELMAP, isConverted };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateLabelmapSegmentationImageReferences(viewportId, segmentationId)) {
|
|
||||||
return { representationTypeToUse: LABELMAP, isConverted: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isConverted = await this.attemptStackToVolumeConversion(
|
|
||||||
csViewport as csTypes.IStackViewport,
|
|
||||||
segmentation,
|
|
||||||
viewportId,
|
|
||||||
segmentationId
|
|
||||||
);
|
|
||||||
|
|
||||||
return { representationTypeToUse: LABELMAP, isConverted };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _addSegmentationRepresentation(
|
private async _addSegmentationRepresentation(
|
||||||
viewportId: string,
|
viewportId: string,
|
||||||
@ -1678,6 +1696,7 @@ class SegmentationService extends PubSubService {
|
|||||||
isConverted: boolean,
|
isConverted: boolean,
|
||||||
config?: {
|
config?: {
|
||||||
blendMode?: csEnums.BlendModes;
|
blendMode?: csEnums.BlendModes;
|
||||||
|
useSliceRendering?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const representation = {
|
const representation = {
|
||||||
@ -1705,7 +1724,7 @@ class SegmentationService extends PubSubService {
|
|||||||
addRepresentation();
|
addRepresentation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private async handleVolumeViewport(
|
public async handleVolumeViewport(
|
||||||
viewport: csTypes.IVolumeViewport,
|
viewport: csTypes.IVolumeViewport,
|
||||||
segmentation: SegmentationData,
|
segmentation: SegmentationData,
|
||||||
isVolumeSegmentation: boolean
|
isVolumeSegmentation: boolean
|
||||||
@ -1723,7 +1742,7 @@ class SegmentationService extends PubSubService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean> {
|
public async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean> {
|
||||||
const { viewportGridService, cornerstoneViewportService } = this.servicesManager.services;
|
const { viewportGridService, cornerstoneViewportService } = this.servicesManager.services;
|
||||||
const state = viewportGridService.getState();
|
const state = viewportGridService.getState();
|
||||||
const gridViewport = state.viewports.get(viewport.id);
|
const gridViewport = state.viewports.get(viewport.id);
|
||||||
@ -1762,7 +1781,7 @@ class SegmentationService extends PubSubService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async attemptStackToVolumeConversion(
|
public async attemptStackToVolumeConversion(
|
||||||
viewport: csTypes.IStackViewport,
|
viewport: csTypes.IStackViewport,
|
||||||
segmentation: SegmentationData,
|
segmentation: SegmentationData,
|
||||||
viewportId: string,
|
viewportId: string,
|
||||||
@ -1782,6 +1801,10 @@ class SegmentationService extends PubSubService {
|
|||||||
|
|
||||||
return isConverted;
|
return isConverted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frame-of-reference mismatch (or missing): no conversion happened. Return an
|
||||||
|
// explicit boolean so the Promise<boolean> contract holds for callers.
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private addSegmentationToSource(segmentationPublicInput: cstTypes.SegmentationPublicInput) {
|
private addSegmentationToSource(segmentationPublicInput: cstTypes.SegmentationPublicInput) {
|
||||||
|
|||||||
@ -0,0 +1,102 @@
|
|||||||
|
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||||
|
import type { Enums as csToolsEnums, Types as cstTypes } from '@cornerstonejs/tools';
|
||||||
|
|
||||||
|
/** A derived labelmap image produced by the SEG adapter (one per referenced image,
|
||||||
|
* per overlap group). `voxelManager.getScalarData()` yields the slice's label values. */
|
||||||
|
export interface SegLabelmapImage {
|
||||||
|
imageId: string;
|
||||||
|
voxelManager?: { getScalarData: () => ArrayLike<number> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inputs for assembling the cornerstone SegmentationPublicInput from a loaded SEG
|
||||||
|
* display set. `segDisplaySet.labelMapImages` is the adapter's array-of-groups (one
|
||||||
|
* conflict-free group per overlap layer); `overlappingSegments` flags whether the
|
||||||
|
* SEG actually has overlap. `derivedImageIds` is the flattened image-id list. */
|
||||||
|
export interface AssembleSegmentationForSEGParams {
|
||||||
|
segmentationId: string;
|
||||||
|
segDisplaySet: {
|
||||||
|
labelMapImages?: SegLabelmapImage[][];
|
||||||
|
overlappingSegments?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
derivedImageIds: string[];
|
||||||
|
referencedImageIds: string[];
|
||||||
|
label: string;
|
||||||
|
fallbackLabel: string;
|
||||||
|
segments: { [segmentIndex: string]: cstTypes.Segment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of classifying a labelmap add: the representation type to actually use
|
||||||
|
* (LABELMAP, or SURFACE for a 3D viewport) and whether the viewport was promoted
|
||||||
|
* stack -> volume (ORTHOGRAPHIC). When `isConverted` is true the caller defers the
|
||||||
|
* representation add until the grid re-mounts the recreated viewport.
|
||||||
|
*/
|
||||||
|
export interface LabelmapAddClassification {
|
||||||
|
representationTypeToUse: csToolsEnums.SegmentationRepresentations;
|
||||||
|
isConverted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segmentation backend twin (mirrors the viewport backend family at
|
||||||
|
* `ViewportService/backends/`). One implementation per lane:
|
||||||
|
* - LegacySegmentationBackend: today's behavior (may promote a stack viewport to
|
||||||
|
* an ORTHOGRAPHIC volume viewport via the host's convertStackToVolumeViewport).
|
||||||
|
* - NextSegmentationBackend: the native GenericViewport ("next") path — renders
|
||||||
|
* the labelmap IN PLACE on the raw PlanarViewport and never promotes.
|
||||||
|
*
|
||||||
|
* DISPATCH (deliberately diverges from IViewportBackend): unlike the viewport
|
||||||
|
* lifecycle backend, which is selected ONCE by the appConfig flag, the segmentation
|
||||||
|
* twin is routed PER VIEWPORT via `isNextViewport(viewport)` (the same
|
||||||
|
* runtime predicate used by `viewportOperations`). A flag-on session can hold both
|
||||||
|
* legacy and native viewports, and every viewport-bearing method already has an
|
||||||
|
* already-resolved, self-describing viewport in hand, so per-viewport routing is the
|
||||||
|
* runtime truth. `isNextViewport` is true for the native raw PlanarViewport and
|
||||||
|
* false for legacy StackViewport/VolumeViewport.
|
||||||
|
*
|
||||||
|
* BOUNDARY: viewport (re)creation is NOT a segmentation concern. The Next twin never
|
||||||
|
* calls `convertStackToVolumeViewport` (that recreates the viewport as ORTHOGRAPHIC
|
||||||
|
* and is owned by CornerstoneViewportService); the Legacy twin reaches it through
|
||||||
|
* `ISegmentationServiceInternals`, so the off path stays byte-identical.
|
||||||
|
*/
|
||||||
|
export interface ISegmentationBackend {
|
||||||
|
/**
|
||||||
|
* Decide how a LABELMAP representation is added for `csViewport`, performing any
|
||||||
|
* stack->volume promotion the lane requires. Called only inside the LABELMAP gate
|
||||||
|
* of `addSegmentationRepresentation` (CONTOUR/SURFACE never reach here).
|
||||||
|
*/
|
||||||
|
classifyAndPrepareLabelmapAdd(
|
||||||
|
csViewport: csTypes.IViewport,
|
||||||
|
segmentation: cstTypes.Segmentation,
|
||||||
|
viewportId: string,
|
||||||
|
segmentationId: string,
|
||||||
|
representationType: csToolsEnums.SegmentationRepresentations
|
||||||
|
): Promise<LabelmapAddClassification>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the cornerstone SegmentationPublicInput for a loaded DICOM SEG display set.
|
||||||
|
*
|
||||||
|
* Dispatched by the session flag at SEG-load (NOT per viewport): no target viewport
|
||||||
|
* exists yet, and the data shape (single- vs multi-layer) is fixed at creation.
|
||||||
|
*
|
||||||
|
* Legacy: a single flattened labelmap layer (today's behavior, byte-identical).
|
||||||
|
* Next: when the SEG has overlapping segments, register each conflict-free group as
|
||||||
|
* its own labelmap layer under one segmentationId (+ segmentBindings) so cornerstone
|
||||||
|
* renders all overlapping segments via the slice path; otherwise identical to Legacy.
|
||||||
|
*/
|
||||||
|
assembleSegmentationDataForSEG(
|
||||||
|
params: AssembleSegmentationForSEGParams
|
||||||
|
): cstTypes.SegmentationPublicInput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recenter a viewport on a segment's world-space center point. Returns whether it
|
||||||
|
* actually recentered, so the caller can skip the segment highlight when it did not
|
||||||
|
* (preserving today's "no jump -> no highlight" behavior).
|
||||||
|
*
|
||||||
|
* Legacy: `viewport.jumpToWorld(world)` (guarded; absent -> false no-op, as today).
|
||||||
|
* Next: a native PlanarViewport has no jumpToWorld -> navigate via a view reference
|
||||||
|
* centered on `world` (setViewReference), turning today's silent native no-op into a
|
||||||
|
* working jump-to-slice. (In-plane pan-to-center is a separate, deferred refinement.)
|
||||||
|
*/
|
||||||
|
jumpToSegmentCenter(viewport: csTypes.IViewport, world: csTypes.Point3): boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||||
|
import type { Types as cstTypes } from '@cornerstonejs/tools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrow slice of SegmentationService that a segmentation backend is allowed to
|
||||||
|
* reach (mirrors `IViewportServiceInternals`). The service `implements` this and
|
||||||
|
* passes `this` to each backend, so the legacy twin can delegate the viewport
|
||||||
|
* (re)creation / data-volume conversion work back to the shared service methods
|
||||||
|
* (which touch servicesManager-owned services) without the backends reaching into
|
||||||
|
* unrelated internals. Keeping this surface narrow is what stops the legacy path
|
||||||
|
* from drifting as the next backend grows.
|
||||||
|
*
|
||||||
|
* Only the LEGACY twin uses these; the NEXT twin renders in place and needs none of
|
||||||
|
* them (it never converts).
|
||||||
|
*/
|
||||||
|
export interface ISegmentationServiceInternals {
|
||||||
|
/**
|
||||||
|
* Recreate the stack viewport as an ORTHOGRAPHIC volume viewport (legacy
|
||||||
|
* promotion). Owned by the service because it drives viewportGridService /
|
||||||
|
* cornerstoneViewportService. Returns true (converted).
|
||||||
|
*/
|
||||||
|
convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote a stack viewport to volume only when the segmentation's
|
||||||
|
* FrameOfReference matches the viewport's. Returns whether it converted.
|
||||||
|
*/
|
||||||
|
attemptStackToVolumeConversion(
|
||||||
|
viewport: csTypes.IStackViewport,
|
||||||
|
segmentation: cstTypes.Segmentation,
|
||||||
|
viewportId: string,
|
||||||
|
segmentationId: string
|
||||||
|
): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the segmentation DATA to a volume labelmap when its FrameOfReference
|
||||||
|
* matches a volume viewport (pure data; no viewport recreation).
|
||||||
|
*/
|
||||||
|
handleVolumeViewport(
|
||||||
|
viewport: csTypes.IVolumeViewport,
|
||||||
|
segmentation: cstTypes.Segmentation,
|
||||||
|
isVolumeSegmentation: boolean
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||||
|
import {
|
||||||
|
Enums as csToolsEnums,
|
||||||
|
segmentation as cstSegmentation,
|
||||||
|
type Types as cstTypes,
|
||||||
|
} from '@cornerstonejs/tools';
|
||||||
|
import { isVolume3DViewportType, isVolumeViewportType } from '../../../utils/getLegacyViewportType';
|
||||||
|
import type {
|
||||||
|
AssembleSegmentationForSEGParams,
|
||||||
|
ISegmentationBackend,
|
||||||
|
LabelmapAddClassification,
|
||||||
|
} from './ISegmentationBackend';
|
||||||
|
import type { ISegmentationServiceInternals } from './ISegmentationServiceInternals';
|
||||||
|
|
||||||
|
const { Labelmap: LABELMAP, Surface: SURFACE } = csToolsEnums.SegmentationRepresentations;
|
||||||
|
const {
|
||||||
|
state: { updateLabelmapSegmentationImageReferences },
|
||||||
|
} = cstSegmentation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy (default) segmentation backend. Selected when the target viewport is NOT a
|
||||||
|
* native GenericViewport (the off path / legacy StackViewport / VolumeViewport).
|
||||||
|
* Holds the labelmap-add decision tree verbatim (determine + handleViewportConversion
|
||||||
|
* + the stack/volume case handlers) and delegates the servicesManager-coupled work
|
||||||
|
* (convertStackToVolumeViewport / attemptStackToVolumeConversion / handleVolumeViewport)
|
||||||
|
* back to the service via ISegmentationServiceInternals, so behavior is byte-identical
|
||||||
|
* to before the backend split.
|
||||||
|
*/
|
||||||
|
export class LegacySegmentationBackend implements ISegmentationBackend {
|
||||||
|
constructor(private readonly service: ISegmentationServiceInternals) {}
|
||||||
|
|
||||||
|
async classifyAndPrepareLabelmapAdd(
|
||||||
|
csViewport: csTypes.IViewport,
|
||||||
|
segmentation: cstTypes.Segmentation,
|
||||||
|
viewportId: string,
|
||||||
|
segmentationId: string,
|
||||||
|
// The case handlers return LABELMAP/SURFACE directly (byte-identical to the
|
||||||
|
// pre-split handleViewportConversion), so the incoming type is unused here.
|
||||||
|
_representationType: csToolsEnums.SegmentationRepresentations
|
||||||
|
): Promise<LabelmapAddClassification> {
|
||||||
|
const isVolumeViewport = isVolumeViewportType(csViewport);
|
||||||
|
// A missing labelmap representation (stale/partial segmentation state) must not
|
||||||
|
// throw on the `'volumeId' in ...` probe; treat it as a non-volume segmentation.
|
||||||
|
const labelmapData = segmentation?.representationData?.[LABELMAP];
|
||||||
|
const isVolumeSegmentation = !!labelmapData && 'volumeId' in labelmapData;
|
||||||
|
|
||||||
|
return isVolumeViewport
|
||||||
|
? this.handleVolumeViewportCase(csViewport, segmentation, isVolumeSegmentation)
|
||||||
|
: this.handleStackViewportCase(
|
||||||
|
csViewport,
|
||||||
|
segmentation,
|
||||||
|
isVolumeSegmentation,
|
||||||
|
viewportId,
|
||||||
|
segmentationId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleVolumeViewportCase(
|
||||||
|
csViewport: csTypes.IViewport,
|
||||||
|
segmentation: cstTypes.Segmentation,
|
||||||
|
isVolumeSegmentation: boolean
|
||||||
|
): Promise<LabelmapAddClassification> {
|
||||||
|
if (isVolume3DViewportType(csViewport)) {
|
||||||
|
return { representationTypeToUse: SURFACE, isConverted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.service.handleVolumeViewport(
|
||||||
|
csViewport as csTypes.IVolumeViewport,
|
||||||
|
segmentation,
|
||||||
|
isVolumeSegmentation
|
||||||
|
);
|
||||||
|
return { representationTypeToUse: LABELMAP, isConverted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStackViewportCase(
|
||||||
|
csViewport: csTypes.IViewport,
|
||||||
|
segmentation: cstTypes.Segmentation,
|
||||||
|
isVolumeSegmentation: boolean,
|
||||||
|
viewportId: string,
|
||||||
|
segmentationId: string
|
||||||
|
): Promise<LabelmapAddClassification> {
|
||||||
|
if (isVolumeSegmentation) {
|
||||||
|
const isConverted = await this.service.convertStackToVolumeViewport(csViewport);
|
||||||
|
return { representationTypeToUse: LABELMAP, isConverted };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateLabelmapSegmentationImageReferences(viewportId, segmentationId)) {
|
||||||
|
return { representationTypeToUse: LABELMAP, isConverted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConverted = await this.service.attemptStackToVolumeConversion(
|
||||||
|
csViewport as csTypes.IStackViewport,
|
||||||
|
segmentation,
|
||||||
|
viewportId,
|
||||||
|
segmentationId
|
||||||
|
);
|
||||||
|
|
||||||
|
return { representationTypeToUse: LABELMAP, isConverted };
|
||||||
|
}
|
||||||
|
|
||||||
|
assembleSegmentationDataForSEG(
|
||||||
|
params: AssembleSegmentationForSEGParams
|
||||||
|
): cstTypes.SegmentationPublicInput {
|
||||||
|
const { segmentationId, derivedImageIds, referencedImageIds, label, fallbackLabel, segments } =
|
||||||
|
params;
|
||||||
|
|
||||||
|
// Single flattened labelmap layer — byte-identical to the pre-split builder in
|
||||||
|
// createSegmentationForSEGDisplaySet. Overlap is collapsed (one voxel = one id).
|
||||||
|
return {
|
||||||
|
segmentationId,
|
||||||
|
representation: {
|
||||||
|
type: LABELMAP,
|
||||||
|
data: {
|
||||||
|
imageIds: derivedImageIds,
|
||||||
|
referencedImageIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
label,
|
||||||
|
fallbackLabel,
|
||||||
|
segments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToSegmentCenter(viewport: csTypes.IViewport, world: csTypes.Point3): boolean {
|
||||||
|
// Byte-identical to the pre-split guarded recenter: legacy stack/volume viewports
|
||||||
|
// have jumpToWorld; if absent (e.g. a native viewport reaching the legacy twin),
|
||||||
|
// no-op and report it so the caller skips the highlight, exactly as before.
|
||||||
|
const legacyViewport = viewport as csTypes.IViewport & {
|
||||||
|
jumpToWorld?: (world: csTypes.Point3) => void;
|
||||||
|
};
|
||||||
|
if (!legacyViewport?.jumpToWorld) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
legacyViewport.jumpToWorld(world);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||||
|
import {
|
||||||
|
Enums as csToolsEnums,
|
||||||
|
segmentation as cstSegmentation,
|
||||||
|
type Types as cstTypes,
|
||||||
|
} from '@cornerstonejs/tools';
|
||||||
|
import type {
|
||||||
|
AssembleSegmentationForSEGParams,
|
||||||
|
ISegmentationBackend,
|
||||||
|
LabelmapAddClassification,
|
||||||
|
} from './ISegmentationBackend';
|
||||||
|
|
||||||
|
const { Labelmap: LABELMAP } = csToolsEnums.SegmentationRepresentations;
|
||||||
|
const {
|
||||||
|
state: { updateLabelmapSegmentationImageReferences },
|
||||||
|
} = cstSegmentation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native GenericViewport ("next") segmentation backend. Selected when the target
|
||||||
|
* viewport is a native generic viewport (raw PlanarViewport;
|
||||||
|
* `isNextViewport(viewport)` is true).
|
||||||
|
*
|
||||||
|
* The keystone of the native migration: a native PlanarViewport renders a labelmap
|
||||||
|
* IN PLACE — cornerstone's `resolveLabelmapRenderPlan` picks `legacy-stack-image`
|
||||||
|
* for a stack labelmap (no volumeId, no VTK_VOLUME_SLICE precondition) and the
|
||||||
|
* duck-typed image-reference resolver maps it onto the viewport's current image. So
|
||||||
|
* this twin NEVER promotes the viewport to an ORTHOGRAPHIC volume viewport: the
|
||||||
|
* legacy `convertStackToVolumeViewport` calls `getViewPresentation` /
|
||||||
|
* `setViewPresentation`, which the raw PlanarViewport does not implement — it throws
|
||||||
|
* (the observed `getViewPresentation is not a function`) and would recreate the
|
||||||
|
* viewport, defeating `useNextViewports`.
|
||||||
|
*
|
||||||
|
* Needs nothing from the host service (it never converts / never touches
|
||||||
|
* servicesManager), so unlike the legacy twin it takes no internals handle.
|
||||||
|
*/
|
||||||
|
export class NextSegmentationBackend implements ISegmentationBackend {
|
||||||
|
async classifyAndPrepareLabelmapAdd(
|
||||||
|
_csViewport: csTypes.IViewport,
|
||||||
|
_segmentation: cstTypes.Segmentation,
|
||||||
|
viewportId: string,
|
||||||
|
segmentationId: string,
|
||||||
|
representationType: csToolsEnums.SegmentationRepresentations
|
||||||
|
): Promise<LabelmapAddClassification> {
|
||||||
|
// Try the duck-typed in-place resolver so the labelmap's images map onto the
|
||||||
|
// viewport's current image when the FrameOfReference matches. Its return value
|
||||||
|
// is intentionally ignored: we return isConverted:false UNCONDITIONALLY so we
|
||||||
|
// never promote, even on a mount-timing race where the resolver cannot map yet.
|
||||||
|
updateLabelmapSegmentationImageReferences(viewportId, segmentationId);
|
||||||
|
|
||||||
|
return { representationTypeToUse: representationType, isConverted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
assembleSegmentationDataForSEG(
|
||||||
|
params: AssembleSegmentationForSEGParams
|
||||||
|
): cstTypes.SegmentationPublicInput {
|
||||||
|
const {
|
||||||
|
segmentationId,
|
||||||
|
segDisplaySet,
|
||||||
|
derivedImageIds,
|
||||||
|
referencedImageIds,
|
||||||
|
label,
|
||||||
|
fallbackLabel,
|
||||||
|
segments,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const groups = segDisplaySet.labelMapImages ?? [];
|
||||||
|
const config = { label, fallbackLabel, segments };
|
||||||
|
|
||||||
|
// Non-overlapping (or a single group): identical to the legacy single-layer build.
|
||||||
|
if (!segDisplaySet.overlappingSegments || groups.length <= 1) {
|
||||||
|
return {
|
||||||
|
segmentationId,
|
||||||
|
representation: {
|
||||||
|
type: LABELMAP,
|
||||||
|
data: { imageIds: derivedImageIds, referencedImageIds },
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlapping SEG: register each conflict-free group as its OWN labelmap layer
|
||||||
|
// under one segmentationId. cornerstone's slice path auto-fires for >1 stack layer
|
||||||
|
// (shouldUseSliceRendering) and stacks one depth-offset vtkImageSlice actor per
|
||||||
|
// layer, so all overlapping segments stay simultaneously visible.
|
||||||
|
// ensureLabelmapState preserves a supplied labelmaps/segmentBindings/
|
||||||
|
// primaryLabelmapId map via its `||=` guards, so this needs NO cornerstone change.
|
||||||
|
const labelmaps: Record<
|
||||||
|
string,
|
||||||
|
{ labelmapId: string; storageKind: 'stack'; imageIds: string[]; referencedImageIds: string[] }
|
||||||
|
> = {};
|
||||||
|
const segmentBindings: Record<number, { labelmapId: string; labelValue: number }> = {};
|
||||||
|
|
||||||
|
groups.forEach((group, index) => {
|
||||||
|
const labelmapId = `${segmentationId}-storage-${index}`;
|
||||||
|
labelmaps[labelmapId] = {
|
||||||
|
labelmapId,
|
||||||
|
storageKind: 'stack',
|
||||||
|
imageIds: group.map(image => image.imageId),
|
||||||
|
referencedImageIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The adapter bin-packs non-overlapping segments into each group and writes each
|
||||||
|
// segment's index as its label value (the colorLUT is segment-indexed, so
|
||||||
|
// labelValue === segmentIndex). Group membership is implicit in the pixel data,
|
||||||
|
// so recover it by collecting the distinct non-zero values present in the group;
|
||||||
|
// those segment indices bind to this layer (so ensureLabelmapState does not
|
||||||
|
// default them all onto the primary layer, which would hide layers 1..N-1).
|
||||||
|
const valuesInGroup = new Set<number>();
|
||||||
|
for (const image of group) {
|
||||||
|
const scalarData = image.voxelManager?.getScalarData();
|
||||||
|
if (!scalarData) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < scalarData.length; i++) {
|
||||||
|
const value = scalarData[i];
|
||||||
|
if (value !== 0) {
|
||||||
|
valuesInGroup.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
valuesInGroup.forEach(value => {
|
||||||
|
segmentBindings[value] = { labelmapId, labelValue: value };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
segmentationId,
|
||||||
|
representation: {
|
||||||
|
type: LABELMAP,
|
||||||
|
data: {
|
||||||
|
// Keep the flattened list for legacy singular readers (getLabelmapImageIds,
|
||||||
|
// SEG export); the per-layer truth lives in `labelmaps`.
|
||||||
|
imageIds: derivedImageIds,
|
||||||
|
referencedImageIds,
|
||||||
|
labelmaps,
|
||||||
|
segmentBindings,
|
||||||
|
primaryLabelmapId: `${segmentationId}-storage-0`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
} as cstTypes.SegmentationPublicInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToSegmentCenter(viewport: csTypes.IViewport, world: csTypes.Point3): boolean {
|
||||||
|
// A native PlanarViewport has no jumpToWorld; navigate via a view reference
|
||||||
|
// centered on the segment's world point. This snaps to the slice containing
|
||||||
|
// `world` along the viewport's current view normal (the core of jump-to-segment).
|
||||||
|
// In-plane pan-to-center is a separate, deferred refinement.
|
||||||
|
const nativeViewport = viewport as csTypes.IViewport & {
|
||||||
|
setViewReference?: (ref: csTypes.ViewReference) => void;
|
||||||
|
};
|
||||||
|
if (typeof nativeViewport.setViewReference !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
nativeViewport.setViewReference({ cameraFocalPoint: world } as csTypes.ViewReference);
|
||||||
|
viewport.render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export type { ISegmentationBackend, LabelmapAddClassification } from './ISegmentationBackend';
|
||||||
|
export type { ISegmentationServiceInternals } from './ISegmentationServiceInternals';
|
||||||
|
export { LegacySegmentationBackend } from './LegacySegmentationBackend';
|
||||||
|
export { NextSegmentationBackend } from './NextSegmentationBackend';
|
||||||
@ -88,12 +88,17 @@ const segmentationRepresentationModifiedCallback = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the segmentation representation aligns with the target viewport type.
|
// Ensure the segmentation representation aligns with the target viewport type
|
||||||
const type: Enums.SegmentationRepresentations =
|
const is3D = isVolume3DViewportType(viewport);
|
||||||
isVolume3DViewportType(viewport)
|
const requestedRepresentation =
|
||||||
? Enums.SegmentationRepresentations.Surface
|
segmentationRepresentationType as Enums.SegmentationRepresentations;
|
||||||
: ((segmentationRepresentationType as Enums.SegmentationRepresentations) ??
|
const { Surface, Labelmap } = Enums.SegmentationRepresentations;
|
||||||
Enums.SegmentationRepresentations.Labelmap);
|
|
||||||
|
const type: Enums.SegmentationRepresentations = is3D
|
||||||
|
? Surface
|
||||||
|
: requestedRepresentation && requestedRepresentation !== Surface
|
||||||
|
? requestedRepresentation
|
||||||
|
: Labelmap;
|
||||||
|
|
||||||
await segmentationService.addSegmentationRepresentation(targetViewportId, {
|
await segmentationService.addSegmentationRepresentation(targetViewportId, {
|
||||||
segmentationId,
|
segmentationId,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
utilities as csUtils,
|
utilities as csUtils,
|
||||||
cache,
|
cache,
|
||||||
Enums as csEnums,
|
Enums as csEnums,
|
||||||
|
metaData,
|
||||||
} from '@cornerstonejs/core';
|
} from '@cornerstonejs/core';
|
||||||
|
|
||||||
import { utilities as csToolsUtils, Enums as csToolsEnums } from '@cornerstonejs/tools';
|
import { utilities as csToolsUtils, Enums as csToolsEnums } from '@cornerstonejs/tools';
|
||||||
@ -34,13 +35,19 @@ import { usePositionPresentationStore } from '../../stores/usePositionPresentati
|
|||||||
import { useSynchronizersStore } from '../../stores/useSynchronizersStore';
|
import { useSynchronizersStore } from '../../stores/useSynchronizersStore';
|
||||||
import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore';
|
import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore';
|
||||||
import getClosestOrientationFromIOP from '../../utils/isReferenceViewable';
|
import getClosestOrientationFromIOP from '../../utils/isReferenceViewable';
|
||||||
|
import { getViewportAdapter } from './adapter';
|
||||||
|
import { viewportOperations } from './backends/viewportOperations';
|
||||||
import {
|
import {
|
||||||
getLegacyViewportType,
|
getLegacyViewportType,
|
||||||
isStackViewportType,
|
|
||||||
isVolume3DViewportType,
|
isVolume3DViewportType,
|
||||||
isVolumeViewportType,
|
isVolumeViewportType,
|
||||||
} from '../../utils/getLegacyViewportType';
|
} from '../../utils/getLegacyViewportType';
|
||||||
import { BlendModes } from '@cornerstonejs/core/enums';
|
import { BlendModes } from '@cornerstonejs/core/enums';
|
||||||
|
import { isNextViewportsEnabled } from '../../utils/nextViewports';
|
||||||
|
import type { IViewportBackend } from './backends/IViewportBackend';
|
||||||
|
import type { IViewportServiceInternals } from './backends/IViewportServiceInternals';
|
||||||
|
import { LegacyViewportBackend } from './backends/LegacyViewportBackend';
|
||||||
|
import { NextViewportBackend } from './backends/NextViewportBackend';
|
||||||
|
|
||||||
const EVENTS = {
|
const EVENTS = {
|
||||||
VIEWPORT_DATA_CHANGED: 'event::cornerstoneViewportService:viewportDataChanged',
|
VIEWPORT_DATA_CHANGED: 'event::cornerstoneViewportService:viewportDataChanged',
|
||||||
@ -107,7 +114,10 @@ export const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
|
|||||||
* Handles cornerstone viewport logic including enabling, disabling, and
|
* Handles cornerstone viewport logic including enabling, disabling, and
|
||||||
* updating the viewport.
|
* updating the viewport.
|
||||||
*/
|
*/
|
||||||
class CornerstoneViewportService extends PubSubService implements IViewportService {
|
class CornerstoneViewportService
|
||||||
|
extends PubSubService
|
||||||
|
implements IViewportService, IViewportServiceInternals
|
||||||
|
{
|
||||||
static REGISTRATION = {
|
static REGISTRATION = {
|
||||||
name: 'cornerstoneViewportService',
|
name: 'cornerstoneViewportService',
|
||||||
altName: 'CornerstoneViewportService',
|
altName: 'CornerstoneViewportService',
|
||||||
@ -132,12 +142,32 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
gridResizeDelay = 50;
|
gridResizeDelay = 50;
|
||||||
gridResizeTimeOut = null;
|
gridResizeTimeOut = null;
|
||||||
|
|
||||||
|
// Resolved once, lazily, on first use. Forked viewport concerns (mount dispatch +
|
||||||
|
// native dataId lifecycle) route through it.
|
||||||
|
private _backend: IViewportBackend | null = null;
|
||||||
|
|
||||||
constructor(servicesManager: AppTypes.ServicesManager) {
|
constructor(servicesManager: AppTypes.ServicesManager) {
|
||||||
super(EVENTS);
|
super(EVENTS);
|
||||||
this.renderingEngine = null;
|
this.renderingEngine = null;
|
||||||
this.viewportGridResizeObserver = null;
|
this.viewportGridResizeObserver = null;
|
||||||
this.servicesManager = servicesManager;
|
this.servicesManager = servicesManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanctioned flag read (the exhaustive list lives in backends/README.md): pick
|
||||||
|
* the viewport backend once, on first use. Resolved lazily (not in the
|
||||||
|
* constructor) because the service singleton is constructed during extension
|
||||||
|
* registration, BEFORE init.tsx runs setNextViewportsEnabled — the first mount
|
||||||
|
* (when this is first read) always happens after init, so the flag is settled.
|
||||||
|
*/
|
||||||
|
private get backend(): IViewportBackend {
|
||||||
|
if (!this._backend) {
|
||||||
|
this._backend = isNextViewportsEnabled()
|
||||||
|
? new NextViewportBackend(this)
|
||||||
|
: new LegacyViewportBackend(this);
|
||||||
|
}
|
||||||
|
return this._backend;
|
||||||
|
}
|
||||||
hangingProtocolService: unknown;
|
hangingProtocolService: unknown;
|
||||||
viewportsInfo: unknown;
|
viewportsInfo: unknown;
|
||||||
sceneVolumeInputs: unknown;
|
sceneVolumeInputs: unknown;
|
||||||
@ -235,6 +265,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
public destroy() {
|
public destroy() {
|
||||||
this._removeResizeObserver();
|
this._removeResizeObserver();
|
||||||
this.viewportGridResizeObserver = null;
|
this.viewportGridResizeObserver = null;
|
||||||
|
// Flush any native dataId registrations the backend owns (§4.7); no-op for legacy.
|
||||||
|
this.backend.destroy();
|
||||||
try {
|
try {
|
||||||
this.renderingEngine?.destroy?.();
|
this.renderingEngine?.destroy?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -256,6 +288,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
* @param viewportId - The viewportId to disable
|
* @param viewportId - The viewportId to disable
|
||||||
*/
|
*/
|
||||||
public disableElement(viewportId: string): void {
|
public disableElement(viewportId: string): void {
|
||||||
|
// Release native dataId registrations BEFORE the viewport bookkeeping is
|
||||||
|
// deleted (§4.7 ref-counted GC); no-op for the legacy backend.
|
||||||
|
this.backend.onViewportDisabled(viewportId);
|
||||||
|
|
||||||
this.renderingEngine?.disableElement(viewportId);
|
this.renderingEngine?.disableElement(viewportId);
|
||||||
|
|
||||||
// clean up
|
// clean up
|
||||||
@ -271,6 +307,12 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
* @param presentations - The presentations to apply to the viewport.
|
* @param presentations - The presentations to apply to the viewport.
|
||||||
* @param viewportInfo - Contains a view reference for immediate application
|
* @param viewportInfo - Contains a view reference for immediate application
|
||||||
*/
|
*/
|
||||||
|
// Public so the viewport backends (IViewportServiceInternals) can record which
|
||||||
|
// display sets a viewport shows from their mount bodies.
|
||||||
|
_trackViewportDisplaySets(viewportId: string, displaySetInstanceUIDs: string[]): void {
|
||||||
|
this.viewportsDisplaySets.set(viewportId, displaySetInstanceUIDs);
|
||||||
|
}
|
||||||
|
|
||||||
public setPresentations(viewportId: string, presentations: Presentations): void {
|
public setPresentations(viewportId: string, presentations: Presentations): void {
|
||||||
const viewport = this.getCornerstoneViewport(viewportId) as
|
const viewport = this.getCornerstoneViewport(viewportId) as
|
||||||
| Types.IStackViewport
|
| Types.IStackViewport
|
||||||
@ -378,12 +420,9 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
|
|
||||||
const viewportInfo = this.viewportsById.get(viewportId);
|
const viewportInfo = this.viewportsById.get(viewportId);
|
||||||
|
|
||||||
return {
|
// Forked per backend (§4.3 presentation read): legacy reads getViewPresentation
|
||||||
viewportType: viewportInfo.getViewportType(),
|
// (pan/zoom); native omits it (a PLANAR_NEXT viewport has no getViewPresentation).
|
||||||
viewReference: isVolume3DViewportType(csViewport) ? null : csViewport.getViewReference(),
|
return this.backend.getPositionPresentation(csViewport, viewportInfo, viewportId);
|
||||||
viewPresentation: csViewport.getViewPresentation({ pan: true, zoom: true }),
|
|
||||||
viewportId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getLutPresentation(viewportId: string): LutPresentation {
|
private _getLutPresentation(viewportId: string): LutPresentation {
|
||||||
@ -410,7 +449,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
|
|
||||||
const properties = isVolumeViewportType(csViewport)
|
const properties = isVolumeViewportType(csViewport)
|
||||||
? new Map()
|
? new Map()
|
||||||
: cleanProperties(csViewport.getProperties());
|
: cleanProperties(getViewportAdapter(csViewport).getPresentation());
|
||||||
|
|
||||||
if (properties instanceof Map) {
|
if (properties instanceof Map) {
|
||||||
const volumeIds = (csViewport as Types.IBaseVolumeViewport).getAllVolumeIds();
|
const volumeIds = (csViewport as Types.IBaseVolumeViewport).getAllVolumeIds();
|
||||||
@ -660,7 +699,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
|
|
||||||
for (const id of this.viewportsById.keys()) {
|
for (const id of this.viewportsById.keys()) {
|
||||||
const viewport = this.getCornerstoneViewport(id);
|
const viewport = this.getCornerstoneViewport(id);
|
||||||
const { viewPlaneNormal } = viewport.getCamera();
|
// Lane-appropriate view-plane normal (legacy getCamera vs native getViewReference).
|
||||||
|
const viewPlaneNormal = viewportOperations.getViewPlaneNormal(viewport);
|
||||||
|
|
||||||
if (!viewPlaneNormal) {
|
if (!viewPlaneNormal) {
|
||||||
continue;
|
continue;
|
||||||
@ -829,7 +869,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
/**
|
/**
|
||||||
* Sets the image data for the given viewport.
|
* Sets the image data for the given viewport.
|
||||||
*/
|
*/
|
||||||
private async _setEcgViewport(
|
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
|
||||||
|
async _setEcgViewport(
|
||||||
viewport: Types.IECGViewport,
|
viewport: Types.IECGViewport,
|
||||||
viewportData: StackViewportData
|
viewportData: StackViewportData
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -839,27 +880,29 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
console.error('[CornerstoneViewportService] ECG display set has no imageId');
|
console.error('[CornerstoneViewportService] ECG display set has no imageId');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return viewport.setEcg(imageId);
|
|
||||||
|
return this.backend.mountEcg(viewport, displaySet, imageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setOtherViewport(
|
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
|
||||||
|
async _setOtherViewport(
|
||||||
viewport: Types.IStackViewport,
|
viewport: Types.IStackViewport,
|
||||||
viewportData: StackViewportData,
|
viewportData: StackViewportData,
|
||||||
viewportInfo: ViewportInfo,
|
viewportInfo: ViewportInfo,
|
||||||
_presentations: Presentations = {}
|
_presentations: Presentations = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [displaySet] = viewportData.data;
|
const [displaySet] = viewportData.data;
|
||||||
// CS3D's "redo viewports" replaced setDataIds with the generic
|
|
||||||
// setDisplaySets({ displaySetId }) API; the legacy adapters key off
|
await this.backend.mountOther(viewport, displaySet);
|
||||||
// imageIds[0] as the displaySetId, so do the same here.
|
|
||||||
await viewport.setDisplaySets({ displaySetId: displaySet.imageIds[0] });
|
|
||||||
const viewReference = viewportInfo.getViewReference();
|
const viewReference = viewportInfo.getViewReference();
|
||||||
if (viewReference) {
|
if (viewReference) {
|
||||||
viewport.setViewReference(viewReference);
|
viewport.setViewReference(viewReference);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setStackViewport(
|
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
|
||||||
|
async _setStackViewport(
|
||||||
viewport: Types.IStackViewport,
|
viewport: Types.IStackViewport,
|
||||||
viewportData: StackViewportData,
|
viewportData: StackViewportData,
|
||||||
viewportInfo: ViewportInfo,
|
viewportInfo: ViewportInfo,
|
||||||
@ -912,7 +955,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport);
|
const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport);
|
||||||
|
|
||||||
const referencedImageId = presentations?.positionPresentation?.viewReference?.referencedImageId;
|
const referencedImageId = presentations?.positionPresentation?.viewReference?.referencedImageId;
|
||||||
if (referencedImageId) {
|
if (referencedImageId && imageIds) {
|
||||||
initialImageIndexToUse = imageIds.indexOf(referencedImageId);
|
initialImageIndexToUse = imageIds.indexOf(referencedImageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -924,21 +967,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
initialImageIndexToUse = this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0;
|
initialImageIndexToUse = this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await viewport.setStack(imageIds, initialImageIndexToUse);
|
// The lane-specific mount (legacy setStack/setProperties vs native
|
||||||
viewport.setProperties({ ...properties });
|
// setDisplaySets/setDisplaySetPresentation/setViewState) lives in the backend.
|
||||||
this.setPresentations(viewport.id, presentations, viewportInfo);
|
return this.backend.mountStack(viewport, {
|
||||||
|
displaySetInstanceUIDs,
|
||||||
await this._addOverlayRepresentations(overlayProcessingResults);
|
imageIds,
|
||||||
|
initialImageIndex: initialImageIndexToUse,
|
||||||
if (displayArea) {
|
properties,
|
||||||
viewport.setDisplayArea(displayArea);
|
displayArea,
|
||||||
}
|
rotation,
|
||||||
if (rotation) {
|
flipHorizontal,
|
||||||
viewport.setProperties({ rotation });
|
presentations,
|
||||||
}
|
viewportInfo,
|
||||||
if (flipHorizontal) {
|
overlayProcessingResults,
|
||||||
viewport.setCamera({ flipHorizontal: true });
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getInitialImageIndexForViewport(
|
private _getInitialImageIndexForViewport(
|
||||||
@ -1137,6 +1179,24 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
|
|
||||||
// For SEG and RT viewports
|
// For SEG and RT viewports
|
||||||
const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport) || [];
|
const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport) || [];
|
||||||
|
|
||||||
|
// Lane-specific volume mount: the native backend mounts registered dataIds via
|
||||||
|
// setDisplaySets + per-binding presentations and reports the mount handled;
|
||||||
|
// legacy reports unhandled and runs the shared setVolumes tail below (which a
|
||||||
|
// native overlay-only mount also traverses — its legacy-surface steps are
|
||||||
|
// lane-guarded via mountOverlayOnlyVolumes).
|
||||||
|
const handledByBackend = await this.backend.mountVolumes(viewport, {
|
||||||
|
filteredVolumeInputArray,
|
||||||
|
volumesProperties,
|
||||||
|
viewportInfo,
|
||||||
|
overlayProcessingResults,
|
||||||
|
presentations,
|
||||||
|
});
|
||||||
|
if (handledByBackend) {
|
||||||
|
this._broadcastEvent(this.EVENTS.VIEWPORT_VOLUMES_CHANGED, { viewportInfo });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!filteredVolumeInputArray.length && overlayProcessingResults?.length) {
|
if (!filteredVolumeInputArray.length && overlayProcessingResults?.length) {
|
||||||
overlayProcessingResults.forEach(({ imageIds, addOverlayFn }) => {
|
overlayProcessingResults.forEach(({ imageIds, addOverlayFn }) => {
|
||||||
if (addOverlayFn) {
|
if (addOverlayFn) {
|
||||||
@ -1201,7 +1261,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
await viewport.setVolumes(baseVolumeInputs);
|
await viewport.setVolumes(baseVolumeInputs);
|
||||||
}
|
}
|
||||||
} else if (volumeInputArray.length) {
|
} else if (volumeInputArray.length) {
|
||||||
await viewport.setVolumes(volumeInputArray);
|
// Every volume input is an overlay display set. Legacy still mounts them via
|
||||||
|
// setVolumes; the native backend no-ops (its overlays are added via
|
||||||
|
// _addOverlayRepresentations below).
|
||||||
|
await this.backend.mountOverlayOnlyVolumes(viewport, volumeInputArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._addOverlayRepresentations(overlayProcessingResults);
|
await this._addOverlayRepresentations(overlayProcessingResults);
|
||||||
@ -1282,27 +1345,127 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
? csToolsEnums.SegmentationRepresentations.Labelmap
|
? csToolsEnums.SegmentationRepresentations.Labelmap
|
||||||
: csToolsEnums.SegmentationRepresentations.Contour;
|
: csToolsEnums.SegmentationRepresentations.Contour;
|
||||||
|
|
||||||
const { predecessorImageId } = displaySet;
|
const applyRepresentation = () => {
|
||||||
const segmentationRepresentationPromise = segmentationService.addSegmentationRepresentation(
|
const { predecessorImageId } = displaySet;
|
||||||
viewport.id,
|
const segmentationRepresentationPromise =
|
||||||
{
|
segmentationService.addSegmentationRepresentation(viewport.id, {
|
||||||
segmentationId,
|
segmentationId,
|
||||||
predecessorImageId,
|
predecessorImageId,
|
||||||
type: representationType,
|
type: representationType,
|
||||||
config: {
|
config: {
|
||||||
blendMode:
|
blendMode:
|
||||||
viewport?.getBlendMode?.() === 1
|
viewport?.getBlendMode?.() === 1
|
||||||
? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND
|
? BlendModes.LABELMAP_EDGE_PROJECTION_BLEND
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
this.storePresentation({ viewportId: viewport.id });
|
||||||
|
return segmentationRepresentationPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// SEG overlay is registered during stack setup, but cornerstone segmentation state
|
||||||
|
// is created in displaySet.load() (async). Wait until it exists before adding representation.
|
||||||
|
if (displaySet.Modality === 'SEG') {
|
||||||
|
if (segmentationService.getSegmentation(segmentationId)) {
|
||||||
|
return applyRepresentation();
|
||||||
}
|
}
|
||||||
);
|
|
||||||
// store the segmentation presentation id in the viewport info
|
// Bound the wait so a failed/aborted SEG load (where SEGMENTATION_LOADING_COMPLETE
|
||||||
this.storePresentation({ viewportId: viewport.id });
|
// never fires) cannot leave this promise — and the viewport setup awaiting it —
|
||||||
return segmentationRepresentationPromise;
|
// hanging indefinitely.
|
||||||
|
const SEG_LOADING_TIMEOUT_MS = 120000;
|
||||||
|
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
let timeoutId;
|
||||||
|
|
||||||
|
// Final resolution — extra calls are harmless (resolve is a no-op after
|
||||||
|
// the first), which lets the timeout stay armed while a representation
|
||||||
|
// apply is in flight and still bound it.
|
||||||
|
const finish = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Give up without applying (load failure / timeout before the event).
|
||||||
|
const settleWithoutApply = () => {
|
||||||
|
settled = true;
|
||||||
|
unsubscribe();
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unsubscribe } = segmentationService.subscribe(
|
||||||
|
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
|
||||||
|
async (evt: { segDisplaySet?: OhifTypes.DisplaySet }) => {
|
||||||
|
if (settled || evt.segDisplaySet?.displaySetInstanceUID !== segmentationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe immediately, but resolve only after the representation is
|
||||||
|
// applied: awaiters continue into rotation/flip/render and broadcast
|
||||||
|
// VIEWPORT_DATA_CHANGED (hanging-protocol callbacks, toolbar, e2e
|
||||||
|
// readiness), which must not observe a viewport whose overlay
|
||||||
|
// doesn't exist yet. applyRepresentation can be genuinely async
|
||||||
|
// (stack→volume viewport conversion). The timeout is deliberately
|
||||||
|
// NOT cleared here — a hung apply stays bounded.
|
||||||
|
settled = true;
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applyRepresentation();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to apply segmentation representation for "${segmentationId}":`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop waiting immediately if the SEG load itself fails — otherwise the
|
||||||
|
// loading-complete event never fires and we would idle until the timeout.
|
||||||
|
const loadingPromise = (displaySet as { loadingPromise?: Promise<unknown> })
|
||||||
|
.loadingPromise;
|
||||||
|
loadingPromise?.catch(error => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`Segmentation "${segmentationId}" failed to load; skipping representation setup.`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
settleWithoutApply();
|
||||||
|
});
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (settled) {
|
||||||
|
// The load completed but applyRepresentation is hung — resolve so
|
||||||
|
// viewport setup is never blocked past the bound (best-effort
|
||||||
|
// semantics; the apply may still land later).
|
||||||
|
console.warn(
|
||||||
|
`Timed out applying segmentation representation for "${segmentationId}"; resolving viewport readiness anyway.`
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`Timed out waiting for segmentation "${segmentationId}" to load; skipping representation setup.`
|
||||||
|
);
|
||||||
|
settleWithoutApply();
|
||||||
|
}, SEG_LOADING_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyRepresentation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _addOverlayRepresentations(
|
// Public so the viewport backends (IViewportServiceInternals) can run the
|
||||||
|
// pending overlay adds from their mount bodies.
|
||||||
|
async _addOverlayRepresentations(
|
||||||
overlayProcessingResults?: Array<{ addOverlayFn?: () => Promise<void> }>
|
overlayProcessingResults?: Array<{ addOverlayFn?: () => Promise<void> }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!overlayProcessingResults?.length) {
|
if (!overlayProcessingResults?.length) {
|
||||||
@ -1320,21 +1483,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
public updateViewport(viewportId: string, viewportData, keepCamera = false) {
|
public updateViewport(viewportId: string, viewportData, keepCamera = false) {
|
||||||
const viewportInfo = this.getViewportInfo(viewportId);
|
const viewportInfo = this.getViewportInfo(viewportId);
|
||||||
const viewport = this.getCornerstoneViewport(viewportId);
|
const viewport = this.getCornerstoneViewport(viewportId);
|
||||||
const viewportCamera = viewport.getCamera();
|
|
||||||
|
|
||||||
let displaySetPromise;
|
// The camera snapshot/restore surface is forked per lane (legacy
|
||||||
|
// getCamera/setCamera vs native view state), so the backend owns the re-mount.
|
||||||
|
const displaySetPromise = this.backend.remount(
|
||||||
|
viewport,
|
||||||
|
viewportData,
|
||||||
|
viewportInfo,
|
||||||
|
keepCamera
|
||||||
|
);
|
||||||
|
|
||||||
if (isVolumeViewportType(viewport)) {
|
// remount() returns undefined for viewport families with no re-mount path
|
||||||
displaySetPromise = this._setVolumeViewport(viewport, viewportData, viewportInfo).then(() => {
|
// (matching legacy behavior); nothing changed, so skip the event broadcast.
|
||||||
if (keepCamera) {
|
if (!displaySetPromise) {
|
||||||
viewport.setCamera(viewportCamera);
|
return;
|
||||||
viewport.render();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStackViewportType(viewport)) {
|
|
||||||
displaySetPromise = this._setStackViewport(viewport, viewportData, viewportInfo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
displaySetPromise.then(() => {
|
displaySetPromise.then(() => {
|
||||||
@ -1351,37 +1513,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
viewportInfo: ViewportInfo,
|
viewportInfo: ViewportInfo,
|
||||||
presentations: Presentations = {}
|
presentations: Presentations = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (isStackViewportType(viewport)) {
|
// The backend (legacy vs native, selected once in the constructor) owns the
|
||||||
return this._setStackViewport(
|
// per-family routing: legacy dispatches by the runtime cornerstone viewport
|
||||||
viewport,
|
// type; native dispatches by the bound data shape, because native stack and
|
||||||
viewportData as StackViewportData,
|
// volume content both report a single PLANAR_NEXT type (§4.4).
|
||||||
viewportInfo,
|
return this.backend.dispatchMount(viewport, viewportData, viewportInfo, presentations);
|
||||||
presentations
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVolumeViewportType(viewport)) {
|
|
||||||
return this._setVolumeViewport(
|
|
||||||
viewport as Types.IVolumeViewport,
|
|
||||||
viewportData as VolumeViewportData,
|
|
||||||
viewportInfo,
|
|
||||||
presentations
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getLegacyViewportType(viewport) === csEnums.ViewportType.ECG) {
|
|
||||||
return this._setEcgViewport(
|
|
||||||
viewport as unknown as Types.IECGViewport,
|
|
||||||
viewportData as StackViewportData
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._setOtherViewport(
|
|
||||||
viewport,
|
|
||||||
viewportData as StackViewportData,
|
|
||||||
viewportInfo,
|
|
||||||
presentations
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1516,44 +1652,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
viewport: Types.IStackViewport | Types.IVolumeViewport,
|
viewport: Types.IStackViewport | Types.IVolumeViewport,
|
||||||
lutPresentation: LutPresentation
|
lutPresentation: LutPresentation
|
||||||
): void {
|
): void {
|
||||||
if (!lutPresentation) {
|
// Forked per backend (§4.3 presentation write): legacy applies via setProperties;
|
||||||
return;
|
// native via setDisplaySetPresentation (a PLANAR_NEXT viewport has no setProperties),
|
||||||
}
|
// so setPresentations no longer throws on the native path.
|
||||||
|
this.backend.setLutPresentation(viewport, lutPresentation);
|
||||||
const { properties } = lutPresentation;
|
|
||||||
if (isVolumeViewportType(viewport)) {
|
|
||||||
if (properties instanceof Map) {
|
|
||||||
properties.forEach((propertiesEntry, volumeId) => {
|
|
||||||
viewport.setProperties(propertiesEntry, volumeId);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
viewport.setProperties(properties);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
viewport.setProperties(properties);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setPositionPresentation(
|
private _setPositionPresentation(
|
||||||
viewport: Types.IStackViewport | Types.IVolumeViewport,
|
viewport: Types.IStackViewport | Types.IVolumeViewport,
|
||||||
positionPresentation: PositionPresentation
|
positionPresentation: PositionPresentation
|
||||||
): void {
|
): void {
|
||||||
const viewRef = positionPresentation?.viewReference;
|
// Forked per backend (§4.3 presentation write): both apply the view reference;
|
||||||
if (viewRef) {
|
// legacy then applies getViewPresentation pan/zoom via setViewPresentation, native
|
||||||
// The orientation can be updated here to navigate to the specified
|
// omits it for now (a PLANAR_NEXT viewport has no setViewPresentation).
|
||||||
// measurement or previous item, but this will not switch to volume
|
this.backend.setPositionPresentation(viewport, positionPresentation);
|
||||||
// or to stack from the other type
|
|
||||||
if (viewport.isReferenceViewable(viewRef, WITH_ORIENTATION)) {
|
|
||||||
viewport.setViewReference(viewRef);
|
|
||||||
} else {
|
|
||||||
console.warn('Unable to apply reference viewable', viewRef);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewPresentation = positionPresentation?.viewPresentation;
|
|
||||||
if (viewPresentation) {
|
|
||||||
viewport.setViewPresentation(viewPresentation);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setSegmentationPresentation(
|
private _setSegmentationPresentation(
|
||||||
|
|||||||
@ -227,9 +227,16 @@ class ViewportInfo {
|
|||||||
// via cornerstoneViewportService
|
// via cornerstoneViewportService
|
||||||
let viewportData = this.getViewportData();
|
let viewportData = this.getViewportData();
|
||||||
|
|
||||||
|
// Branch on the persisted data shape, not viewportType: native ("next") volume
|
||||||
|
// viewports carry viewportType === PLANAR_NEXT while their data is still a volume
|
||||||
|
// array, so keying off viewportType alone would treat them as a stack object and
|
||||||
|
// miss the display set — skipping invalidateViewportData() on metadata invalidation.
|
||||||
|
// Falls back to viewportType for legacy viewportData with no dataShapeType.
|
||||||
|
const dataShapeType = viewportData.dataShapeType ?? viewportData.viewportType;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC ||
|
dataShapeType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||||
viewportData.viewportType === Enums.ViewportType.VOLUME_3D
|
dataShapeType === Enums.ViewportType.VOLUME_3D
|
||||||
) {
|
) {
|
||||||
viewportData = viewportData as VolumeViewportData;
|
viewportData = viewportData as VolumeViewportData;
|
||||||
return viewportData.data.some(
|
return viewportData.data.some(
|
||||||
|
|||||||
@ -0,0 +1,443 @@
|
|||||||
|
import { Enums, cache } from '@cornerstonejs/core';
|
||||||
|
import {
|
||||||
|
getViewportAdapter,
|
||||||
|
isNextViewport,
|
||||||
|
isVolumeRenderingViewport,
|
||||||
|
} from './getViewportAdapter';
|
||||||
|
import { LegacyViewportAdapter, LEGACY_OPACITY_GAMMA } from './LegacyViewportAdapter';
|
||||||
|
import { NextViewportAdapter } from './NextViewportAdapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract tests for IViewportAdapter: every behavioral guarantee the UI layer
|
||||||
|
* relies on is asserted against BOTH lane implementations over mock viewports.
|
||||||
|
* If a legacy/native divergence is intentional (e.g. opacity gamma), the
|
||||||
|
* divergent expectations are asserted side by side so the difference is
|
||||||
|
* documented here rather than rediscovered in a viewer session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { ViewportType, OrientationAxis } = Enums;
|
||||||
|
|
||||||
|
/** Minimal native ("next") viewport: satisfies csUtils.isGenericViewport. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function makeNextViewport(overrides: Record<string, unknown> = {}): any {
|
||||||
|
return {
|
||||||
|
id: 'next-viewport',
|
||||||
|
setDisplaySets: jest.fn().mockResolvedValue(undefined),
|
||||||
|
setDisplaySetPresentation: jest.fn(),
|
||||||
|
setViewState: jest.fn(),
|
||||||
|
getViewState: jest.fn().mockReturnValue({ rotation: 90, flipHorizontal: true }),
|
||||||
|
getCurrentMode: jest.fn().mockReturnValue('stack'),
|
||||||
|
getSourceDataId: jest.fn().mockReturnValue('source-uid'),
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({}),
|
||||||
|
getDefaultVOIRange: jest.fn().mockReturnValue(undefined),
|
||||||
|
getViewReference: jest.fn().mockReturnValue({
|
||||||
|
viewPlaneNormal: [0, 0, 1],
|
||||||
|
cameraFocalPoint: [1, 2, 3],
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal legacy stack viewport: no setDisplaySets/setViewState surface. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function makeLegacyStackViewport(overrides: Record<string, unknown> = {}): any {
|
||||||
|
return {
|
||||||
|
id: 'legacy-stack',
|
||||||
|
type: ViewportType.STACK,
|
||||||
|
getProperties: jest.fn().mockReturnValue({ voiRange: { lower: 0, upper: 100 } }),
|
||||||
|
setProperties: jest.fn(),
|
||||||
|
getCamera: jest.fn().mockReturnValue({
|
||||||
|
viewPlaneNormal: [0, 0, 1],
|
||||||
|
focalPoint: [1, 2, 3],
|
||||||
|
rotation: 90,
|
||||||
|
}),
|
||||||
|
setCamera: jest.fn(),
|
||||||
|
getActors: jest.fn().mockReturnValue([{ referencedId: 'imageId:abc' }]),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal legacy orthographic (volume) viewport. */
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function makeLegacyVolumeViewport(overrides: Record<string, unknown> = {}): any {
|
||||||
|
const propertiesByVolumeId = {
|
||||||
|
'volumeId-ds-1': { colormap: { name: 'hsv', opacity: 0.9 } },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: 'legacy-volume',
|
||||||
|
type: ViewportType.ORTHOGRAPHIC,
|
||||||
|
getAllVolumeIds: jest.fn().mockReturnValue(['volumeId-ds-1', 'volumeId-ds-2']),
|
||||||
|
getProperties: jest.fn((volumeId?: string) =>
|
||||||
|
volumeId ? (propertiesByVolumeId[volumeId] ?? {}) : { voiRange: { lower: 5, upper: 50 } }
|
||||||
|
),
|
||||||
|
setProperties: jest.fn(),
|
||||||
|
getCamera: jest.fn().mockReturnValue({ viewPlaneNormal: [1, 0, 0], focalPoint: [4, 5, 6] }),
|
||||||
|
setCamera: jest.fn(),
|
||||||
|
getActors: jest.fn().mockReturnValue([{ referencedId: 'volumeId-ds-1' }]),
|
||||||
|
isInAcquisitionPlane: jest.fn().mockReturnValue(true),
|
||||||
|
getImageData: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getViewportAdapter dispatch', () => {
|
||||||
|
it('routes native viewports to NextViewportAdapter and legacy to LegacyViewportAdapter', () => {
|
||||||
|
expect(getViewportAdapter(makeNextViewport())).toBeInstanceOf(NextViewportAdapter);
|
||||||
|
expect(getViewportAdapter(makeLegacyStackViewport())).toBeInstanceOf(LegacyViewportAdapter);
|
||||||
|
expect(getViewportAdapter(makeLegacyVolumeViewport())).toBeInstanceOf(LegacyViewportAdapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches one adapter per viewport instance', () => {
|
||||||
|
const viewport = makeNextViewport();
|
||||||
|
expect(getViewportAdapter(viewport)).toBe(getViewportAdapter(viewport));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a missing viewport', () => {
|
||||||
|
expect(() => getViewportAdapter(null)).toThrow();
|
||||||
|
expect(() => getViewportAdapter(undefined)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isNextViewport matches the dispatch decision', () => {
|
||||||
|
expect(isNextViewport(makeNextViewport())).toBe(true);
|
||||||
|
expect(isNextViewport(makeLegacyStackViewport())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classification', () => {
|
||||||
|
it('getShape resolves the content shape on both lanes', () => {
|
||||||
|
expect(getViewportAdapter(makeLegacyStackViewport()).getShape()).toBe('stack');
|
||||||
|
expect(getViewportAdapter(makeLegacyVolumeViewport()).getShape()).toBe('volume');
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(makeLegacyVolumeViewport({ type: ViewportType.VOLUME_3D })).getShape()
|
||||||
|
).toBe('volume3d');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('stack') })
|
||||||
|
).getShape()
|
||||||
|
).toBe('stack');
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('volume') })
|
||||||
|
).getShape()
|
||||||
|
).toBe('volume');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isVolumeRendering: legacy ORTHOGRAPHIC / native volume mode only', () => {
|
||||||
|
expect(isVolumeRenderingViewport(makeLegacyVolumeViewport())).toBe(true);
|
||||||
|
expect(isVolumeRenderingViewport(makeLegacyStackViewport())).toBe(false);
|
||||||
|
expect(
|
||||||
|
isVolumeRenderingViewport(
|
||||||
|
makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('volume') })
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isVolumeRenderingViewport(
|
||||||
|
makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('stack') })
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
// Native 3D reorients in place but does NOT support planar volume controls.
|
||||||
|
const next3d = makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('volume3d') });
|
||||||
|
expect(isVolumeRenderingViewport(next3d)).toBe(false);
|
||||||
|
expect(getViewportAdapter(next3d).canReorientInPlace()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isInAcquisitionPlane: legacy asks the viewport; native reads view-state orientation', () => {
|
||||||
|
expect(getViewportAdapter(makeLegacyVolumeViewport()).isInAcquisitionPlane()).toBe(true);
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeLegacyVolumeViewport({ isInAcquisitionPlane: jest.fn().mockReturnValue(false) })
|
||||||
|
).isInAcquisitionPlane()
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// Native default (unset orientation) counts as acquisition.
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeNextViewport({ getViewState: jest.fn().mockReturnValue({}) })
|
||||||
|
).isInAcquisitionPlane()
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeNextViewport({
|
||||||
|
getViewState: jest.fn().mockReturnValue({ orientation: OrientationAxis.SAGITTAL }),
|
||||||
|
})
|
||||||
|
).isInAcquisitionPlane()
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasContent: legacy via actors, native via content mode', () => {
|
||||||
|
expect(getViewportAdapter(makeLegacyStackViewport()).hasContent()).toBe(true);
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeLegacyStackViewport({ getActors: jest.fn().mockReturnValue([]) })
|
||||||
|
).hasContent()
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(getViewportAdapter(makeNextViewport()).hasContent()).toBe(true);
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('empty') })
|
||||||
|
).hasContent()
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('view geometry', () => {
|
||||||
|
it('getViewState/setViewState map to getCamera/setCamera on legacy', () => {
|
||||||
|
const viewport = makeLegacyStackViewport();
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
expect(adapter.getViewState().rotation).toBe(90);
|
||||||
|
adapter.setViewState({ flipHorizontal: true });
|
||||||
|
expect(viewport.setCamera).toHaveBeenCalledWith({ flipHorizontal: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getViewState/setViewState pass through natively', () => {
|
||||||
|
const viewport = makeNextViewport();
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
expect(adapter.getViewState().rotation).toBe(90);
|
||||||
|
adapter.setViewState({ rotation: 180 });
|
||||||
|
expect(viewport.setViewState).toHaveBeenCalledWith({ rotation: 180 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getViewPlaneNormal and getFocalPoint resolve on both lanes', () => {
|
||||||
|
expect(getViewportAdapter(makeLegacyStackViewport()).getViewPlaneNormal()).toEqual([0, 0, 1]);
|
||||||
|
expect(getViewportAdapter(makeLegacyStackViewport()).getFocalPoint()).toEqual([1, 2, 3]);
|
||||||
|
expect(getViewportAdapter(makeNextViewport()).getViewPlaneNormal()).toEqual([0, 0, 1]);
|
||||||
|
expect(getViewportAdapter(makeNextViewport()).getFocalPoint()).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('per-display-set appearance', () => {
|
||||||
|
it('getPresentation: legacy getProperties with/without dataId', () => {
|
||||||
|
const viewport = makeLegacyVolumeViewport();
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
expect(adapter.getPresentation()).toEqual({ voiRange: { lower: 5, upper: 50 } });
|
||||||
|
expect(adapter.getPresentation('volumeId-ds-1').colormap).toEqual({
|
||||||
|
name: 'hsv',
|
||||||
|
opacity: 0.9,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPresentation: native per-binding read, defaulting to the source dataId', () => {
|
||||||
|
const viewport = makeNextViewport({
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({ invert: true }),
|
||||||
|
});
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
expect(adapter.getPresentation()).toEqual({ invert: true });
|
||||||
|
expect(viewport.getDisplaySetPresentation).toHaveBeenCalledWith('source-uid');
|
||||||
|
adapter.getPresentation('ds-2');
|
||||||
|
expect(viewport.getDisplaySetPresentation).toHaveBeenCalledWith('ds-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPresentation: native stamps isComputedVOI when the VOI matches the binding default', () => {
|
||||||
|
const voiRange = { lower: 0, upper: 80 };
|
||||||
|
const stamped = getViewportAdapter(
|
||||||
|
makeNextViewport({
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({ voiRange }),
|
||||||
|
getDefaultVOIRange: jest.fn().mockReturnValue({ lower: 0, upper: 80 }),
|
||||||
|
})
|
||||||
|
).getPresentation();
|
||||||
|
expect(stamped.isComputedVOI).toBe(true);
|
||||||
|
|
||||||
|
const notStamped = getViewportAdapter(
|
||||||
|
makeNextViewport({
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({ voiRange }),
|
||||||
|
getDefaultVOIRange: jest.fn().mockReturnValue({ lower: 10, upper: 90 }),
|
||||||
|
})
|
||||||
|
).getPresentation();
|
||||||
|
expect(notStamped.isComputedVOI).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setPresentation targets setProperties (legacy) / setDisplaySetPresentation (native)', () => {
|
||||||
|
const legacy = makeLegacyVolumeViewport();
|
||||||
|
getViewportAdapter(legacy).setPresentation({ invert: true }, 'volumeId-ds-1');
|
||||||
|
expect(legacy.setProperties).toHaveBeenCalledWith({ invert: true }, 'volumeId-ds-1');
|
||||||
|
|
||||||
|
const next = makeNextViewport();
|
||||||
|
getViewportAdapter(next).setPresentation({ invert: true }, 'ds-1');
|
||||||
|
expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('ds-1', { invert: true });
|
||||||
|
|
||||||
|
// No dataId: native falls back to the source binding.
|
||||||
|
getViewportAdapter(next).setPresentation({ invert: false });
|
||||||
|
expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('source-uid', { invert: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDefaultVOIRange: native binding default; legacy has none', () => {
|
||||||
|
expect(getViewportAdapter(makeLegacyStackViewport()).getDefaultVOIRange()).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(
|
||||||
|
makeNextViewport({ getDefaultVOIRange: jest.fn().mockReturnValue({ lower: 1, upper: 2 }) })
|
||||||
|
).getDefaultVOIRange('ds-1')
|
||||||
|
).toEqual({ lower: 1, upper: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getColormap: legacy stack properties / legacy volume actor lookup / native presentation', () => {
|
||||||
|
const stack = makeLegacyStackViewport({
|
||||||
|
getProperties: jest.fn().mockReturnValue({ colormap: { name: 'gray' } }),
|
||||||
|
});
|
||||||
|
expect(getViewportAdapter(stack).getColormap('anything')).toEqual({ name: 'gray' });
|
||||||
|
|
||||||
|
const volume = makeLegacyVolumeViewport();
|
||||||
|
expect(getViewportAdapter(volume).getColormap('ds-1')).toEqual({ name: 'hsv', opacity: 0.9 });
|
||||||
|
expect(getViewportAdapter(volume).getColormap('ds-unknown')).toBeUndefined();
|
||||||
|
|
||||||
|
const next = makeNextViewport({
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({ colormap: { name: 'jet' } }),
|
||||||
|
});
|
||||||
|
expect(getViewportAdapter(next).getColormap('ds-1')).toEqual({ name: 'jet' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLayerOpacity merges into the existing colormap on both lanes', () => {
|
||||||
|
const volume = makeLegacyVolumeViewport();
|
||||||
|
expect(getViewportAdapter(volume).setLayerOpacity('ds-1', 0.5)).toBe(true);
|
||||||
|
expect(volume.setProperties).toHaveBeenCalledWith(
|
||||||
|
{ colormap: { name: 'hsv', opacity: 0.5 } },
|
||||||
|
'volumeId-ds-1'
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = makeNextViewport({
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({ colormap: { name: 'jet' } }),
|
||||||
|
});
|
||||||
|
expect(getViewportAdapter(next).setLayerOpacity('ds-1', 0.5)).toBe(true);
|
||||||
|
expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('ds-1', {
|
||||||
|
colormap: { name: 'jet', opacity: 0.5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLayerOpacity is unsupported on a legacy stack (caller must not render)', () => {
|
||||||
|
const stack = makeLegacyStackViewport();
|
||||||
|
expect(getViewportAdapter(stack).setLayerOpacity('ds-1', 0.5)).toBe(false);
|
||||||
|
expect(stack.setProperties).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLayerThreshold: legacy historically does NOT merge; native merges', () => {
|
||||||
|
const volume = makeLegacyVolumeViewport();
|
||||||
|
expect(getViewportAdapter(volume).setLayerThreshold('ds-1', 42)).toBe(true);
|
||||||
|
expect(volume.setProperties).toHaveBeenCalledWith(
|
||||||
|
{ colormap: { threshold: 42 } },
|
||||||
|
'volumeId-ds-1'
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = makeNextViewport({
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({ colormap: { name: 'jet' } }),
|
||||||
|
});
|
||||||
|
expect(getViewportAdapter(next).setLayerThreshold('ds-1', 42)).toBe(true);
|
||||||
|
expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('ds-1', {
|
||||||
|
colormap: { name: 'jet', threshold: 42 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOpacityGamma: linear on native, historical 1/5 curve on legacy', () => {
|
||||||
|
expect(getViewportAdapter(makeNextViewport()).getOpacityGamma()).toBe(1);
|
||||||
|
expect(getViewportAdapter(makeLegacyVolumeViewport()).getOpacityGamma()).toBe(
|
||||||
|
LEGACY_OPACITY_GAMMA
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data addressing', () => {
|
||||||
|
it('getDataIdForDisplaySet: bare UID on native; matching volumeId on legacy volume; undefined on legacy stack', () => {
|
||||||
|
expect(getViewportAdapter(makeNextViewport()).getDataIdForDisplaySet('ds-1')).toBe('ds-1');
|
||||||
|
expect(getViewportAdapter(makeLegacyVolumeViewport()).getDataIdForDisplaySet('ds-1')).toBe(
|
||||||
|
'volumeId-ds-1'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(makeLegacyVolumeViewport()).getDataIdForDisplaySet('nope')
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(makeLegacyStackViewport()).getDataIdForDisplaySet('ds-1')
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getVolumeIds: legacy volume list; empty on native and legacy stack', () => {
|
||||||
|
expect(getViewportAdapter(makeLegacyVolumeViewport()).getVolumeIds()).toEqual([
|
||||||
|
'volumeId-ds-1',
|
||||||
|
'volumeId-ds-2',
|
||||||
|
]);
|
||||||
|
expect(getViewportAdapter(makeLegacyStackViewport()).getVolumeIds()).toEqual([]);
|
||||||
|
expect(getViewportAdapter(makeNextViewport()).getVolumeIds()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getVoxelManagerForDisplaySet: native resolves from the cornerstone cache', () => {
|
||||||
|
const voxelManager = { getRange: () => [0, 100] as [number, number] };
|
||||||
|
const derivedVoxelManager = { getRange: () => [0, 1] as [number, number] };
|
||||||
|
const spy = jest.spyOn(cache, 'getVolumes').mockReturnValue([
|
||||||
|
// A derived id that merely EMBEDS the UID must not match (anchored lookup);
|
||||||
|
// real volumeIds are `${volumeLoaderSchema}:${displaySetInstanceUID}`.
|
||||||
|
{ volumeId: 'derived-ds-1-labelmap', voxelManager: derivedVoxelManager },
|
||||||
|
{ volumeId: 'cornerstoneStreamingImageVolume:ds-1', voxelManager },
|
||||||
|
] as never);
|
||||||
|
try {
|
||||||
|
expect(getViewportAdapter(makeNextViewport()).getVoxelManagerForDisplaySet('ds-1')).toBe(
|
||||||
|
voxelManager
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(makeNextViewport()).getVoxelManagerForDisplaySet('missing')
|
||||||
|
).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
spy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getVoxelManagerForDisplaySet: legacy volume reads getImageData(volumeId)', () => {
|
||||||
|
const voxelManager = { getRange: () => [0, 50] as [number, number] };
|
||||||
|
const viewport = makeLegacyVolumeViewport({
|
||||||
|
getImageData: jest.fn().mockReturnValue({
|
||||||
|
imageData: {
|
||||||
|
get: (key: string) => (key === 'voxelManager' ? { voxelManager } : undefined),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(getViewportAdapter(viewport).getVoxelManagerForDisplaySet('ds-1')).toBe(voxelManager);
|
||||||
|
expect(viewport.getImageData).toHaveBeenCalledWith('volumeId-ds-1');
|
||||||
|
expect(
|
||||||
|
getViewportAdapter(makeLegacyStackViewport()).getVoxelManagerForDisplaySet('ds-1')
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capture (copyDisplayedContentTo)', () => {
|
||||||
|
it('legacy: setStack + properties + view presentation + view reference onto the target', async () => {
|
||||||
|
const source = makeLegacyStackViewport({
|
||||||
|
getCurrentImageId: jest.fn().mockReturnValue('imageId:abc'),
|
||||||
|
getViewReference: jest.fn().mockReturnValue({ viewPlaneNormal: [0, 0, 1] }),
|
||||||
|
getViewPresentation: jest.fn().mockReturnValue({ zoom: 2 }),
|
||||||
|
});
|
||||||
|
const target = makeLegacyStackViewport({
|
||||||
|
setStack: jest.fn().mockResolvedValue(undefined),
|
||||||
|
setViewPresentation: jest.fn(),
|
||||||
|
setViewReference: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getViewportAdapter(source).copyDisplayedContentTo(target as never);
|
||||||
|
|
||||||
|
expect(target.setStack).toHaveBeenCalledWith(['imageId:abc']);
|
||||||
|
expect(target.setViewPresentation).toHaveBeenCalledWith({ zoom: 2 });
|
||||||
|
expect(target.setProperties).toHaveBeenCalledWith({ voiRange: { lower: 0, upper: 100 } });
|
||||||
|
expect(target.setViewReference).toHaveBeenCalledWith({ viewPlaneNormal: [0, 0, 1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('native: remounts the source dataId and copies presentation + view state', async () => {
|
||||||
|
const source = makeNextViewport({
|
||||||
|
getDisplaySetPresentation: jest.fn().mockReturnValue({ invert: true }),
|
||||||
|
getViewState: jest.fn().mockReturnValue({ orientation: 'axial', rotation: 45 }),
|
||||||
|
});
|
||||||
|
const target = makeNextViewport({
|
||||||
|
getSourceDataId: jest.fn().mockReturnValue('capture-uid'),
|
||||||
|
setViewReference: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getViewportAdapter(source).copyDisplayedContentTo(target as never);
|
||||||
|
|
||||||
|
expect(target.setDisplaySets).toHaveBeenCalledWith({
|
||||||
|
displaySetId: 'source-uid',
|
||||||
|
options: { orientation: 'axial', role: 'source' },
|
||||||
|
});
|
||||||
|
expect(target.setDisplaySetPresentation).toHaveBeenCalledWith('capture-uid', { invert: true });
|
||||||
|
expect(target.setViewReference).toHaveBeenCalledWith({
|
||||||
|
viewPlaneNormal: [0, 0, 1],
|
||||||
|
cameraFocalPoint: [1, 2, 3],
|
||||||
|
});
|
||||||
|
expect(target.setViewState).toHaveBeenCalledWith({ orientation: 'axial', rotation: 45 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,179 @@
|
|||||||
|
import type { Types as CoreTypes } from '@cornerstonejs/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content shape of a viewport, independent of the runtime cornerstone viewport
|
||||||
|
* type. Native ("next") viewports collapse stack/volume/MPR onto a single
|
||||||
|
* PLANAR_NEXT runtime type, so `viewport.type` checks cannot classify them;
|
||||||
|
* this is the lane-agnostic answer to "what is this viewport showing".
|
||||||
|
*/
|
||||||
|
export type ViewportShape = 'stack' | 'volume' | 'volume3d' | 'unknown';
|
||||||
|
|
||||||
|
export type VOIRange = { lower: number; upper: number };
|
||||||
|
|
||||||
|
export interface ViewportColormap {
|
||||||
|
name?: string;
|
||||||
|
opacity?: number | Array<{ value: number; opacity: number }> | number[];
|
||||||
|
threshold?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-display-set appearance (VOI/colormap/invert). On legacy this is the
|
||||||
|
* getProperties()/setProperties() surface; on native it is the per-binding
|
||||||
|
* display-set presentation keyed by dataId.
|
||||||
|
*/
|
||||||
|
export interface ViewportPresentation {
|
||||||
|
voiRange?: VOIRange;
|
||||||
|
colormap?: ViewportColormap;
|
||||||
|
invert?: boolean;
|
||||||
|
isComputedVOI?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View-level state (rotation, flip, orientation, pan/zoom). On legacy this is
|
||||||
|
* the getCamera()/setCamera() surface; on native it is the semantic
|
||||||
|
* getViewState()/setViewState() surface. Field names follow the native shape
|
||||||
|
* where the two overlap (rotation, flipHorizontal, flipVertical).
|
||||||
|
*/
|
||||||
|
export type ViewportViewState = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single OHIF-facing per-viewport contract over the legacy and native
|
||||||
|
* ("next") cornerstone viewport APIs.
|
||||||
|
*
|
||||||
|
* The contract is NEXT-SHAPED: method names and semantics follow the native
|
||||||
|
* API (view state, per-display-set presentation keyed by dataId, view
|
||||||
|
* reference). `NextViewportAdapter` is a thin pass-through;
|
||||||
|
* `LegacyViewportAdapter` is the side doing the adapting
|
||||||
|
* (getCamera -> view state, volumeId -> dataId). When the legacy path is
|
||||||
|
* eventually removed, the migration ends by deleting the legacy adapter — not
|
||||||
|
* by unwinding call-site ternaries.
|
||||||
|
*
|
||||||
|
* Obtain an instance ONLY via `getViewportAdapter(viewport)` — the one place
|
||||||
|
* allowed to call `csUtils.isGenericViewport`. UI code (hooks, overlays,
|
||||||
|
* components, toolbar evaluators) must consume this interface instead of
|
||||||
|
* probing the raw viewport surface.
|
||||||
|
*/
|
||||||
|
export interface IViewportAdapter {
|
||||||
|
// ---- classification ----
|
||||||
|
|
||||||
|
/** Lane-agnostic content shape (see ViewportShape). */
|
||||||
|
getShape(): ViewportShape;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the viewport renders volume content and supports volume-only
|
||||||
|
* appearance controls (threshold, per-layer opacity): legacy ORTHOGRAPHIC,
|
||||||
|
* or a native viewport whose active binding is a volume.
|
||||||
|
*/
|
||||||
|
isVolumeRendering(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the viewport can be reoriented in place via setOrientation()
|
||||||
|
* without being recreated: legacy ORTHOGRAPHIC, or a native viewport already
|
||||||
|
* rendering volume content (volume slice or 3D). Differs from
|
||||||
|
* isVolumeRendering() on native 3D viewports, which reorient in place but do
|
||||||
|
* not support the planar volume appearance controls.
|
||||||
|
*/
|
||||||
|
canReorientInPlace(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a volume-mode viewport is looking down the acquisition axis
|
||||||
|
* (legacy isInAcquisitionPlane(); native view-state orientation ACQUISITION,
|
||||||
|
* which is also the native default when unset).
|
||||||
|
*/
|
||||||
|
isInAcquisitionPlane(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the viewport has renderable content bound. Legacy reports this
|
||||||
|
* via actors; native (which has no getActors on planar viewports) via its
|
||||||
|
* content mode.
|
||||||
|
*/
|
||||||
|
hasContent(): boolean;
|
||||||
|
|
||||||
|
// ---- view geometry ----
|
||||||
|
|
||||||
|
/** Read view-level state (rotation/flip/orientation/...). Empty object when unavailable. */
|
||||||
|
getViewState(): ViewportViewState;
|
||||||
|
|
||||||
|
/** Apply a partial view-state patch; unspecified fields are preserved. */
|
||||||
|
setViewState(patch: ViewportViewState): void;
|
||||||
|
|
||||||
|
/** Current view-plane normal (legacy camera; native view reference). */
|
||||||
|
getViewPlaneNormal(): CoreTypes.Point3 | undefined;
|
||||||
|
|
||||||
|
/** World-space focal point / slice center (legacy camera; native view reference). */
|
||||||
|
getFocalPoint(): CoreTypes.Point3 | undefined;
|
||||||
|
|
||||||
|
// ---- per-display-set appearance ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read appearance (voiRange/colormap/invert/...) for a display set binding.
|
||||||
|
* Defaults to the active source binding when no dataId is given. On native,
|
||||||
|
* a VOI matching the binding's computed default is stamped
|
||||||
|
* `isComputedVOI: true` so LUT-presentation capture strips it (matching
|
||||||
|
* legacy StackViewport behavior).
|
||||||
|
*/
|
||||||
|
getPresentation(dataId?: string): ViewportPresentation;
|
||||||
|
|
||||||
|
/** Write appearance for a display set binding (active source binding when no dataId). */
|
||||||
|
setPresentation(props: ViewportPresentation, dataId?: string): void;
|
||||||
|
|
||||||
|
/** The binding's computed default VOI (native only; legacy returns undefined). */
|
||||||
|
getDefaultVOIRange(dataId?: string): VOIRange | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colormap currently applied to a display set's layer, or undefined when
|
||||||
|
* none (callers supply their own fallback, e.g. Grayscale).
|
||||||
|
*/
|
||||||
|
getColormap(displaySetInstanceUID: string): ViewportColormap | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an opacity into the display set layer's colormap. Returns true when
|
||||||
|
* applied (caller renders); false when the viewport/layer does not support it.
|
||||||
|
*/
|
||||||
|
setLayerOpacity(displaySetInstanceUID: string, opacity: number): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a threshold to the display set layer's colormap. Returns true when
|
||||||
|
* applied (caller renders); false when unsupported.
|
||||||
|
*/
|
||||||
|
setLayerThreshold(displaySetInstanceUID: string, threshold: number): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gamma applied between the fusion opacity slider position and the rendered
|
||||||
|
* opacity. Native renders a linear blend (gamma 1); legacy rendering expects
|
||||||
|
* its historical 1/5 curve.
|
||||||
|
*/
|
||||||
|
getOpacityGamma(): number;
|
||||||
|
|
||||||
|
// ---- data addressing ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the dataId to address a display set's binding on this viewport:
|
||||||
|
* the bare display set UID on native; the matching volumeId on legacy volume
|
||||||
|
* viewports; undefined on legacy single-actor viewports (callers fall back
|
||||||
|
* to the active binding).
|
||||||
|
*/
|
||||||
|
getDataIdForDisplaySet(displaySetInstanceUID: string): string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy volumeIds bound to this viewport ([] on native and on legacy
|
||||||
|
* stack viewports). For legacy-only features such as per-volume histograms.
|
||||||
|
*/
|
||||||
|
getVolumeIds(): string[];
|
||||||
|
|
||||||
|
/** Voxel data access (getRange etc.) for a display set's volume, when available. */
|
||||||
|
getVoxelManagerForDisplaySet(
|
||||||
|
displaySetInstanceUID: string
|
||||||
|
): { getRange?: () => [number, number]; [key: string]: unknown } | undefined;
|
||||||
|
|
||||||
|
// ---- capture ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount this viewport's currently-displayed data onto another (same-lane)
|
||||||
|
* viewport and copy its appearance + view state. Used by the download/
|
||||||
|
* capture form; the caller renders the target afterwards.
|
||||||
|
*/
|
||||||
|
copyDisplayedContentTo(target: CoreTypes.IViewport): Promise<void>;
|
||||||
|
}
|
||||||
@ -0,0 +1,240 @@
|
|||||||
|
import { Enums, Types as CoreTypes } from '@cornerstonejs/core';
|
||||||
|
import {
|
||||||
|
getLegacyViewportType,
|
||||||
|
isOrthographicViewportType,
|
||||||
|
isStackViewportType,
|
||||||
|
isVolumeViewportType,
|
||||||
|
} from '../../../utils/getLegacyViewportType';
|
||||||
|
import type {
|
||||||
|
IViewportAdapter,
|
||||||
|
ViewportColormap,
|
||||||
|
ViewportPresentation,
|
||||||
|
ViewportShape,
|
||||||
|
ViewportViewState,
|
||||||
|
VOIRange,
|
||||||
|
} from './IViewportAdapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural view of the legacy StackViewport/VolumeViewport surface used by
|
||||||
|
* the adapter. Optional-chained because different legacy families expose
|
||||||
|
* different subsets (e.g. only volume viewports have getAllVolumeIds).
|
||||||
|
* Deliberately NOT an intersection with CoreTypes.IViewport: the adapter
|
||||||
|
* contract types these members with the next-shaped signatures (e.g. getCamera
|
||||||
|
* as a plain record), and IViewport's own declarations would win otherwise.
|
||||||
|
*/
|
||||||
|
type LegacyViewport = {
|
||||||
|
getProperties?: (dataId?: string) => ViewportPresentation | undefined;
|
||||||
|
setProperties?: (props: ViewportPresentation, dataId?: string) => void;
|
||||||
|
getCamera?: () => Record<string, unknown> | undefined;
|
||||||
|
setCamera?: (patch: Record<string, unknown>) => void;
|
||||||
|
getAllVolumeIds?: () => string[];
|
||||||
|
getImageData?: (volumeId?: string) => {
|
||||||
|
imageData?: { get: (key: string) => { voxelManager?: unknown } | undefined };
|
||||||
|
};
|
||||||
|
getActors?: () => Array<{ referencedId?: string }>;
|
||||||
|
isInAcquisitionPlane?: () => boolean;
|
||||||
|
getViewReference?: () => CoreTypes.ViewReference | undefined;
|
||||||
|
setViewReference?: (ref: CoreTypes.ViewReference) => void;
|
||||||
|
getViewPresentation?: () => unknown;
|
||||||
|
setViewPresentation?: (presentation: unknown) => void;
|
||||||
|
getCurrentImageId?: () => string;
|
||||||
|
setStack?: (imageIds: string[]) => Promise<unknown>;
|
||||||
|
setVolumes?: (volumes: Array<{ volumeId: string }>) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opacity slider gamma the legacy fusion rendering expects: the slider value is
|
||||||
|
* applied through a 1/5 curve (native renders a linear blend and uses gamma 1).
|
||||||
|
*/
|
||||||
|
export const LEGACY_OPACITY_GAMMA = 1 / 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy lane of IViewportAdapter — adapts the StackViewport/VolumeViewport
|
||||||
|
* surface (getCamera/getProperties/volumeIds) to the next-shaped contract.
|
||||||
|
* Deleting the legacy path deletes this file. Instantiated only by
|
||||||
|
* `getViewportAdapter`.
|
||||||
|
*/
|
||||||
|
export class LegacyViewportAdapter implements IViewportAdapter {
|
||||||
|
constructor(private readonly viewport: LegacyViewport) {}
|
||||||
|
|
||||||
|
// ---- classification ----
|
||||||
|
|
||||||
|
getShape(): ViewportShape {
|
||||||
|
switch (getLegacyViewportType(this.viewport)) {
|
||||||
|
case Enums.ViewportType.STACK:
|
||||||
|
return 'stack';
|
||||||
|
case Enums.ViewportType.ORTHOGRAPHIC:
|
||||||
|
return 'volume';
|
||||||
|
case Enums.ViewportType.VOLUME_3D:
|
||||||
|
return 'volume3d';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isVolumeRendering(): boolean {
|
||||||
|
return isOrthographicViewportType(this.viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
canReorientInPlace(): boolean {
|
||||||
|
return isOrthographicViewportType(this.viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
isInAcquisitionPlane(): boolean {
|
||||||
|
return !!this.viewport.isInAcquisitionPlane?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasContent(): boolean {
|
||||||
|
const actorEntries = this.viewport.getActors?.();
|
||||||
|
return !!actorEntries && actorEntries.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- view geometry ----
|
||||||
|
|
||||||
|
getViewState(): ViewportViewState {
|
||||||
|
return this.viewport.getCamera?.() ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewState(patch: ViewportViewState): void {
|
||||||
|
this.viewport.setCamera?.(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewPlaneNormal(): CoreTypes.Point3 | undefined {
|
||||||
|
return this.viewport.getCamera?.()?.viewPlaneNormal as CoreTypes.Point3 | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFocalPoint(): CoreTypes.Point3 | undefined {
|
||||||
|
return this.viewport.getCamera?.()?.focalPoint as CoreTypes.Point3 | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-display-set appearance ----
|
||||||
|
|
||||||
|
getPresentation(dataId?: string): ViewportPresentation {
|
||||||
|
return (dataId ? this.viewport.getProperties?.(dataId) : this.viewport.getProperties?.()) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setPresentation(props: ViewportPresentation, dataId?: string): void {
|
||||||
|
this.viewport.setProperties?.(props, dataId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultVOIRange(): VOIRange | undefined {
|
||||||
|
// Legacy getProperties always returns the applied VOI; there is no separate
|
||||||
|
// computed-default accessor.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getColormap(displaySetInstanceUID: string): ViewportColormap | undefined {
|
||||||
|
if (isStackViewportType(this.viewport)) {
|
||||||
|
return this.viewport.getProperties?.()?.colormap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorEntries = this.viewport.getActors?.();
|
||||||
|
const actorEntry = actorEntries?.find(entry =>
|
||||||
|
entry.referencedId?.includes(displaySetInstanceUID)
|
||||||
|
);
|
||||||
|
if (!actorEntry) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.viewport.getProperties?.(actorEntry.referencedId)?.colormap;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayerOpacity(displaySetInstanceUID: string, opacity: number): boolean {
|
||||||
|
if (!isVolumeViewportType(this.viewport)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const volumeId = this.getDataIdForDisplaySet(displaySetInstanceUID);
|
||||||
|
if (!volumeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the opacity into the current colormap so its name/threshold persist.
|
||||||
|
const currentColormap = this.viewport.getProperties?.(volumeId)?.colormap ?? {};
|
||||||
|
this.viewport.setProperties?.({ colormap: { ...currentColormap, opacity } }, volumeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayerThreshold(displaySetInstanceUID: string, threshold: number): boolean {
|
||||||
|
if (!isVolumeViewportType(this.viewport)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const volumeId = this.getDataIdForDisplaySet(displaySetInstanceUID);
|
||||||
|
if (!volumeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.viewport.setProperties?.({ colormap: { threshold } }, volumeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpacityGamma(): number {
|
||||||
|
return LEGACY_OPACITY_GAMMA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- data addressing ----
|
||||||
|
|
||||||
|
getDataIdForDisplaySet(displaySetInstanceUID: string): string | undefined {
|
||||||
|
// Multi-volume viewports address a layer by the volumeId that embeds the
|
||||||
|
// display set UID; single-actor viewports (stack) address the active layer
|
||||||
|
// implicitly (undefined).
|
||||||
|
if (typeof this.viewport.getAllVolumeIds !== 'function') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const volumeIds = this.viewport.getAllVolumeIds() || [];
|
||||||
|
return volumeIds.length > 0
|
||||||
|
? (volumeIds.find(id => id.includes(displaySetInstanceUID)) ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVolumeIds(): string[] {
|
||||||
|
if (typeof this.viewport.getAllVolumeIds !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.viewport.getAllVolumeIds() || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getVoxelManagerForDisplaySet(
|
||||||
|
displaySetInstanceUID: string
|
||||||
|
): { getRange?: () => [number, number]; [key: string]: unknown } | undefined {
|
||||||
|
if (!isVolumeViewportType(this.viewport)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const volumeId = this.getDataIdForDisplaySet(displaySetInstanceUID);
|
||||||
|
if (!volumeId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const imageData = this.viewport.getImageData?.(volumeId);
|
||||||
|
return imageData?.imageData?.get('voxelManager')?.voxelManager as
|
||||||
|
| { getRange?: () => [number, number]; [key: string]: unknown }
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- capture ----
|
||||||
|
|
||||||
|
async copyDisplayedContentTo(target: CoreTypes.IViewport): Promise<void> {
|
||||||
|
const targetViewport = target as unknown as LegacyViewport;
|
||||||
|
const viewRef = this.viewport.getViewReference?.();
|
||||||
|
|
||||||
|
// - properties: VOI, colormap, interpolation, etc.
|
||||||
|
// - viewPresentation: flip/rotate/zoom presentation state (preserves flip/rotate)
|
||||||
|
const properties = this.viewport.getProperties?.();
|
||||||
|
const viewPresentation = this.viewport.getViewPresentation?.();
|
||||||
|
|
||||||
|
if (isStackViewportType(targetViewport)) {
|
||||||
|
const imageId = this.viewport.getCurrentImageId?.();
|
||||||
|
await targetViewport.setStack?.([imageId]);
|
||||||
|
} else if (isVolumeViewportType(targetViewport)) {
|
||||||
|
const volumeIds = this.getVolumeIds();
|
||||||
|
await targetViewport.setVolumes?.([{ volumeId: volumeIds[0] }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewPresentation && targetViewport.setViewPresentation) {
|
||||||
|
targetViewport.setViewPresentation(viewPresentation);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetViewport.setProperties?.(properties);
|
||||||
|
|
||||||
|
if (viewRef && targetViewport.setViewReference) {
|
||||||
|
targetViewport.setViewReference(viewRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
import { Enums, cache, Types as CoreTypes } from '@cornerstonejs/core';
|
||||||
|
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||||
|
import type {
|
||||||
|
IViewportAdapter,
|
||||||
|
ViewportColormap,
|
||||||
|
ViewportPresentation,
|
||||||
|
ViewportShape,
|
||||||
|
ViewportViewState,
|
||||||
|
VOIRange,
|
||||||
|
} from './IViewportAdapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural view of the native ("next") viewport surface used by the adapter.
|
||||||
|
* These accessors live on IGenericViewport (not IViewport), so we cast at this
|
||||||
|
* boundary rather than import core-internal PlanarViewport types.
|
||||||
|
*/
|
||||||
|
type NativeViewport = CoreTypes.IViewport & {
|
||||||
|
getSourceDataId?: () => string | undefined;
|
||||||
|
getDisplaySetPresentation?: (dataId: string) => ViewportPresentation | undefined;
|
||||||
|
getDefaultVOIRange?: (dataId?: string) => VOIRange | undefined;
|
||||||
|
setDisplaySetPresentation?: {
|
||||||
|
(props: ViewportPresentation): void;
|
||||||
|
(dataId: string, props: ViewportPresentation): void;
|
||||||
|
};
|
||||||
|
getViewState?: () => ViewportViewState | undefined;
|
||||||
|
setViewState?: (patch: ViewportViewState) => void;
|
||||||
|
getViewReference?: () => CoreTypes.ViewReference | undefined;
|
||||||
|
setViewReference?: (ref: CoreTypes.ViewReference) => void;
|
||||||
|
getCurrentMode?: () => string;
|
||||||
|
setDisplaySets?: (args: {
|
||||||
|
displaySetId: string;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const voiRangesClose = (a: VOIRange, b: VOIRange, eps = 0.001): boolean =>
|
||||||
|
Math.abs(a.lower - b.lower) < eps && Math.abs(a.upper - b.upper) < eps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native ("next") lane of IViewportAdapter — a thin pass-through to the native
|
||||||
|
* semantic API (view state, per-binding display-set presentation, view
|
||||||
|
* reference). Instantiated only by `getViewportAdapter`.
|
||||||
|
*/
|
||||||
|
export class NextViewportAdapter implements IViewportAdapter {
|
||||||
|
constructor(private readonly viewport: NativeViewport) {}
|
||||||
|
|
||||||
|
// ---- classification ----
|
||||||
|
|
||||||
|
getShape(): ViewportShape {
|
||||||
|
if (isVolume3DViewportType(this.viewport)) {
|
||||||
|
return 'volume3d';
|
||||||
|
}
|
||||||
|
const mode = this.viewport.getCurrentMode?.();
|
||||||
|
if (mode === 'stack') {
|
||||||
|
return 'stack';
|
||||||
|
}
|
||||||
|
if (mode === 'volume') {
|
||||||
|
return 'volume';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
isVolumeRendering(): boolean {
|
||||||
|
return this.viewport.getCurrentMode?.() === 'volume';
|
||||||
|
}
|
||||||
|
|
||||||
|
canReorientInPlace(): boolean {
|
||||||
|
const mode = this.viewport.getCurrentMode?.();
|
||||||
|
return mode === 'volume' || mode === 'volume3d';
|
||||||
|
}
|
||||||
|
|
||||||
|
isInAcquisitionPlane(): boolean {
|
||||||
|
// orientation defaults to ACQUISITION when unset on the native view state.
|
||||||
|
const orientation = this.viewport.getViewState?.()?.orientation;
|
||||||
|
return orientation === Enums.OrientationAxis.ACQUISITION || orientation == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasContent(): boolean {
|
||||||
|
// Planar native viewports have no getActors (it throws); content presence
|
||||||
|
// is reported by the content mode (empty/unknown means nothing bound).
|
||||||
|
const mode = this.viewport.getCurrentMode?.();
|
||||||
|
return !!mode && mode !== 'empty' && mode !== 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- view geometry ----
|
||||||
|
|
||||||
|
getViewState(): ViewportViewState {
|
||||||
|
return this.viewport.getViewState?.() ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewState(patch: ViewportViewState): void {
|
||||||
|
this.viewport.setViewState?.(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewPlaneNormal(): CoreTypes.Point3 | undefined {
|
||||||
|
return this.viewport.getViewReference?.()?.viewPlaneNormal as CoreTypes.Point3 | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFocalPoint(): CoreTypes.Point3 | undefined {
|
||||||
|
// The native view state carries no focalPoint; it comes from the view reference.
|
||||||
|
return this.viewport.getViewReference?.()?.cameraFocalPoint as CoreTypes.Point3 | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-display-set appearance ----
|
||||||
|
|
||||||
|
getPresentation(dataId?: string): ViewportPresentation {
|
||||||
|
const id = dataId ?? this.viewport.getSourceDataId?.();
|
||||||
|
const presentation = (id ? this.viewport.getDisplaySetPresentation?.(id) : undefined) ?? {};
|
||||||
|
|
||||||
|
// A native binding's presentation normally holds only EXPLICIT VOI overrides,
|
||||||
|
// but a computed default VOI can transiently land here during intermediate
|
||||||
|
// mounts (e.g. while a SEG hydrates, the base image briefly mounts as a volume
|
||||||
|
// and its min/max default is stored). Flag such a VOI as computed when it
|
||||||
|
// matches the binding's default so the LUT-presentation capture (cleanProperties)
|
||||||
|
// strips it instead of persisting+restoring it over the real default — matching
|
||||||
|
// legacy StackViewport's isComputedVOI. Stamping is harmless even on a genuine
|
||||||
|
// user VOI that happens to equal the default (stripping it falls back to the
|
||||||
|
// same value).
|
||||||
|
const voiRange = presentation.voiRange;
|
||||||
|
if (voiRange && presentation.isComputedVOI === undefined) {
|
||||||
|
const defaultVOIRange = this.viewport.getDefaultVOIRange?.(id);
|
||||||
|
if (defaultVOIRange && voiRangesClose(voiRange, defaultVOIRange)) {
|
||||||
|
return { ...presentation, isComputedVOI: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return presentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPresentation(props: ViewportPresentation, dataId?: string): void {
|
||||||
|
const id = dataId ?? this.viewport.getSourceDataId?.();
|
||||||
|
if (id) {
|
||||||
|
this.viewport.setDisplaySetPresentation?.(id, props);
|
||||||
|
} else {
|
||||||
|
this.viewport.setDisplaySetPresentation?.(props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultVOIRange(dataId?: string): VOIRange | undefined {
|
||||||
|
return this.viewport.getDefaultVOIRange?.(dataId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getColormap(displaySetInstanceUID: string): ViewportColormap | undefined {
|
||||||
|
return this.getPresentation(displaySetInstanceUID).colormap;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayerOpacity(displaySetInstanceUID: string, opacity: number): boolean {
|
||||||
|
// Merge the opacity into the existing colormap so its name/threshold persist,
|
||||||
|
// targeting the binding by its dataId (the bare display set UID). A uniform
|
||||||
|
// (flat) opacity makes the slider a linear background<->foreground blend,
|
||||||
|
// matching the flat default presentation set by the fusion hanging protocol.
|
||||||
|
const currentColormap = this.getPresentation(displaySetInstanceUID).colormap ?? {};
|
||||||
|
this.setPresentation({ colormap: { ...currentColormap, opacity } }, displaySetInstanceUID);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayerThreshold(displaySetInstanceUID: string, threshold: number): boolean {
|
||||||
|
// Merge the threshold into the existing colormap so its name/opacity persist.
|
||||||
|
// The threshold is an absolute pixel/SUV value, matching the legacy volume path.
|
||||||
|
const currentColormap = this.getPresentation(displaySetInstanceUID).colormap ?? {};
|
||||||
|
this.setPresentation({ colormap: { ...currentColormap, threshold } }, displaySetInstanceUID);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpacityGamma(): number {
|
||||||
|
// Native volume viewports render the fusion as a volume slice whose blend is
|
||||||
|
// linear in the opacity scalar, so the slider maps 1:1.
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- data addressing ----
|
||||||
|
|
||||||
|
getDataIdForDisplaySet(displaySetInstanceUID: string): string | undefined {
|
||||||
|
// Native viewports key their per-display-set presentation by the bare display
|
||||||
|
// set UID (the dataId used by get/setDisplaySetPresentation). Returning it
|
||||||
|
// directly keeps fusion layer reads on the intended binding instead of falling
|
||||||
|
// back to the active source layer.
|
||||||
|
return displaySetInstanceUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVolumeIds(): string[] {
|
||||||
|
// No legacy getAllVolumeIds surface; volume data is addressed by dataId.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getVoxelManagerForDisplaySet(
|
||||||
|
displaySetInstanceUID: string
|
||||||
|
): { getRange?: () => [number, number]; [key: string]: unknown } | undefined {
|
||||||
|
// No getAllVolumeIds/getImageData(volumeId); resolve the display set's volume
|
||||||
|
// from the cornerstone cache instead. Anchored match: volumeIds are built as
|
||||||
|
// `${loaderSchema}:${displaySetInstanceUID}`, and an unanchored includes()
|
||||||
|
// could resolve a different cached volume whose id merely embeds the same
|
||||||
|
// UID (e.g. a derived labelmap id).
|
||||||
|
const volume = cache
|
||||||
|
.getVolumes()
|
||||||
|
.find(
|
||||||
|
v =>
|
||||||
|
v.volumeId === displaySetInstanceUID || v.volumeId?.endsWith(`:${displaySetInstanceUID}`)
|
||||||
|
);
|
||||||
|
return volume?.voxelManager as unknown as
|
||||||
|
| { getRange?: () => [number, number]; [key: string]: unknown }
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- capture ----
|
||||||
|
|
||||||
|
async copyDisplayedContentTo(target: CoreTypes.IViewport): Promise<void> {
|
||||||
|
const targetViewport = target as NativeViewport;
|
||||||
|
const viewRef = this.viewport.getViewReference?.();
|
||||||
|
|
||||||
|
// The source's dataId is already registered in the global GenericViewport
|
||||||
|
// metadata provider, so re-mount it on the capture viewport by id, then copy
|
||||||
|
// per-binding presentation + view state. The native classes have no
|
||||||
|
// setStack/setVolumes/setProperties/setViewPresentation.
|
||||||
|
const sourceDataId = this.viewport.getSourceDataId?.();
|
||||||
|
if (sourceDataId) {
|
||||||
|
const { orientation } = this.getViewState();
|
||||||
|
await targetViewport.setDisplaySets?.({
|
||||||
|
displaySetId: sourceDataId,
|
||||||
|
options: { orientation, role: 'source' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const captureDataId = targetViewport.getSourceDataId?.() ?? sourceDataId;
|
||||||
|
targetViewport.setDisplaySetPresentation?.(captureDataId, this.getPresentation(sourceDataId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice/orientation via the view reference, then pan/zoom/rotate/flip via view state.
|
||||||
|
if (viewRef && targetViewport.setViewReference) {
|
||||||
|
targetViewport.setViewReference(viewRef);
|
||||||
|
}
|
||||||
|
targetViewport.setViewState?.(this.getViewState());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { utilities as csUtils, Types as CoreTypes } from '@cornerstonejs/core';
|
||||||
|
import type { IViewportAdapter } from './IViewportAdapter';
|
||||||
|
import { LegacyViewportAdapter } from './LegacyViewportAdapter';
|
||||||
|
import { NextViewportAdapter } from './NextViewportAdapter';
|
||||||
|
|
||||||
|
const adapterCache = new WeakMap<object, IViewportAdapter>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the IViewportAdapter for a cornerstone viewport. This is THE ONE
|
||||||
|
* place (outside the segmentation backend family) allowed to call
|
||||||
|
* `csUtils.isGenericViewport` — all other code must consume the adapter (or
|
||||||
|
* the `isNextViewport` predicate below) instead of branching on the raw
|
||||||
|
* viewport surface. Enforced by scripts/check-next-viewport-boundaries.sh.
|
||||||
|
*
|
||||||
|
* Adapters are stateless wrappers over the viewport, cached per viewport
|
||||||
|
* instance, so calling this in render paths is cheap.
|
||||||
|
*/
|
||||||
|
export function getViewportAdapter(viewport: unknown): IViewportAdapter {
|
||||||
|
if (!viewport || typeof viewport !== 'object') {
|
||||||
|
throw new Error('getViewportAdapter: a viewport instance is required');
|
||||||
|
}
|
||||||
|
let adapter = adapterCache.get(viewport);
|
||||||
|
if (!adapter) {
|
||||||
|
adapter = csUtils.isGenericViewport(viewport)
|
||||||
|
? new NextViewportAdapter(viewport as ConstructorParameters<typeof NextViewportAdapter>[0])
|
||||||
|
: new LegacyViewportAdapter(
|
||||||
|
viewport as ConstructorParameters<typeof LegacyViewportAdapter>[0]
|
||||||
|
);
|
||||||
|
adapterCache.set(viewport, adapter);
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-viewport lane predicate, for the few dispatchers (viewport
|
||||||
|
* operations, segmentation backend) that hold their own per-lane
|
||||||
|
* implementations. Everyone else should call adapter methods instead of
|
||||||
|
* branching on this.
|
||||||
|
*/
|
||||||
|
export function isNextViewport(viewport: unknown): boolean {
|
||||||
|
return csUtils.isGenericViewport(viewport);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for any viewport that renders volume content and therefore supports
|
||||||
|
* volume-only appearance controls (threshold, per-layer opacity): a legacy
|
||||||
|
* ORTHOGRAPHIC viewport, or a native ("next") viewport whose active binding is
|
||||||
|
* a volume. A native MPR/volume viewport runs as PLANAR_NEXT (not
|
||||||
|
* ORTHOGRAPHIC), so legacy type guards alone miss it.
|
||||||
|
*/
|
||||||
|
export function isVolumeRenderingViewport(viewport: unknown): boolean {
|
||||||
|
return !!viewport && getViewportAdapter(viewport).isVolumeRendering();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* World-space focal point (current slice center) for any viewport. Exposed as
|
||||||
|
* a free function because it is part of the extension's public API (consumed
|
||||||
|
* by tmtv).
|
||||||
|
*/
|
||||||
|
export function getViewportFocalPoint(viewport: unknown): CoreTypes.Point3 | undefined {
|
||||||
|
return viewport ? getViewportAdapter(viewport).getFocalPoint() : undefined;
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
export type {
|
||||||
|
IViewportAdapter,
|
||||||
|
ViewportColormap,
|
||||||
|
ViewportPresentation,
|
||||||
|
ViewportShape,
|
||||||
|
ViewportViewState,
|
||||||
|
VOIRange,
|
||||||
|
} from './IViewportAdapter';
|
||||||
|
export {
|
||||||
|
getViewportAdapter,
|
||||||
|
getViewportFocalPoint,
|
||||||
|
isNextViewport,
|
||||||
|
isVolumeRenderingViewport,
|
||||||
|
} from './getViewportAdapter';
|
||||||
|
export { LegacyViewportAdapter, LEGACY_OPACITY_GAMMA } from './LegacyViewportAdapter';
|
||||||
|
export { NextViewportAdapter } from './NextViewportAdapter';
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
import type { Types } from '@cornerstonejs/core';
|
||||||
|
import type ViewportInfo from '../Viewport';
|
||||||
|
import type {
|
||||||
|
Presentations,
|
||||||
|
PositionPresentation,
|
||||||
|
LutPresentation,
|
||||||
|
} from '../../../types/Presentation';
|
||||||
|
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||||
|
import type { DataIdPayload } from './dataIdRegistry';
|
||||||
|
|
||||||
|
/** A pending overlay (SEG/RTSTRUCT) add produced by the service's prelude. */
|
||||||
|
export type OverlayMountTask = {
|
||||||
|
imageIds?: string[];
|
||||||
|
addOverlayFn?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the service's lane-agnostic stack-mount prelude computes: the
|
||||||
|
* backend receives it ready-made and performs only the lane-specific mount.
|
||||||
|
*/
|
||||||
|
export interface StackMountContext {
|
||||||
|
/** All display set UIDs bound to the viewport (first = the stack source). */
|
||||||
|
displaySetInstanceUIDs: string[];
|
||||||
|
imageIds: string[];
|
||||||
|
/** Resolved initial slice (position presentation / view reference / HP options). */
|
||||||
|
initialImageIndex: number;
|
||||||
|
/** VOI/invert/colormap seeded from the LUT presentation or display set options. */
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
displayArea?: unknown;
|
||||||
|
rotation?: number;
|
||||||
|
flipHorizontal?: boolean;
|
||||||
|
presentations: Presentations;
|
||||||
|
viewportInfo: ViewportInfo;
|
||||||
|
overlayProcessingResults?: OverlayMountTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the service's lane-agnostic volume-mount prelude computes before
|
||||||
|
* the lane fork in setVolumesForViewport.
|
||||||
|
*/
|
||||||
|
export interface VolumeMountContext {
|
||||||
|
/** Volume inputs with their display set options, overlays already filtered out. */
|
||||||
|
filteredVolumeInputArray: Array<{
|
||||||
|
volumeInput: {
|
||||||
|
imageIds?: string[];
|
||||||
|
volumeId: string;
|
||||||
|
displaySetInstanceUID: string;
|
||||||
|
blendMode?: unknown;
|
||||||
|
slabThickness?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
displaySetOptions: unknown;
|
||||||
|
}>;
|
||||||
|
/** Per-volume VOI/invert/colormap/preset derived from the display set options. */
|
||||||
|
volumesProperties: Array<{ properties: Record<string, unknown>; volumeId: string }>;
|
||||||
|
viewportInfo: ViewportInfo;
|
||||||
|
overlayProcessingResults?: OverlayMountTask[];
|
||||||
|
presentations: Presentations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects how OHIF drives cornerstone viewports (migration plan §4.3). One
|
||||||
|
* implementation is chosen ONCE, lazily on first use (a `get backend()` getter on
|
||||||
|
* CornerstoneViewportService — NOT the constructor, because the service singleton is
|
||||||
|
* built during extension registration before init.tsx sets the flag), from
|
||||||
|
* `appConfig.useNextViewports`:
|
||||||
|
* - LegacyViewportBackend: today's behavior, selected when the flag is off (default).
|
||||||
|
* - NextViewportBackend: the native GenericViewport ("next") path.
|
||||||
|
*
|
||||||
|
* The service holds exactly one backend for its lifetime and routes the forked
|
||||||
|
* concerns through it: mount dispatch, the per-family MOUNT BODIES
|
||||||
|
* (mountStack/mountVolumes/mountEcg/mountOther/remount), presentation
|
||||||
|
* capture/restore, and the native dataId lifecycle. The service keeps only the
|
||||||
|
* lane-agnostic preludes (option/property derivation, bookkeeping, events) and
|
||||||
|
* the genuinely shared volume tail; it contains no per-lane branches itself.
|
||||||
|
*/
|
||||||
|
export interface IViewportBackend {
|
||||||
|
/**
|
||||||
|
* Routes a viewport's data to the correct per-family mount. Legacy routes by the
|
||||||
|
* runtime cornerstone viewport type; next routes by the bound data shape, because
|
||||||
|
* native stack and volume content both report a single PLANAR_NEXT type (§4.4).
|
||||||
|
*/
|
||||||
|
dispatchMount(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
viewportData: StackViewportData | VolumeViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
presentations?: Presentations
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts an image stack. Legacy: setStack/setProperties/setPresentations +
|
||||||
|
* displayArea/rotation/flip via the camera surface. Next: register the dataId,
|
||||||
|
* setDisplaySets, seed VOI from metadata, apply presentation + view state.
|
||||||
|
*/
|
||||||
|
mountStack(viewport: Types.IStackViewport, context: StackMountContext): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lane-specific volume mount. Next mounts the volumes natively (registered
|
||||||
|
* dataIds + one setDisplaySets call + per-binding presentations) and returns
|
||||||
|
* true — the service then only broadcasts. Legacy returns false, and the
|
||||||
|
* service runs the shared volume tail (setVolumes/addVolumes optimization,
|
||||||
|
* property application, presentations, jumpToSlice), which a native
|
||||||
|
* overlay-only mount also traverses safely.
|
||||||
|
*/
|
||||||
|
mountVolumes(viewport: Types.IViewport, context: VolumeMountContext): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared volume tail's overlay-only fallback: when every volume input is
|
||||||
|
* an overlay display set, legacy still mounts them via setVolumes; next
|
||||||
|
* no-ops (its overlays are added via the segmentation representations).
|
||||||
|
*/
|
||||||
|
mountOverlayOnlyVolumes(viewport: Types.IViewport, volumeInputArray: unknown[]): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts an ECG waveform. Legacy: viewport.setEcg(imageId). Next: register
|
||||||
|
* the display set's dataId and mount through the generic setDisplaySets API.
|
||||||
|
*/
|
||||||
|
mountEcg(
|
||||||
|
viewport: Types.IECGViewport,
|
||||||
|
displaySet: { displaySetInstanceUID: string; imageIds?: string[] },
|
||||||
|
imageId: string
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts video / whole-slide content (the caller applies the view reference
|
||||||
|
* afterwards). Legacy keys the displaySetId off imageIds[0]; next registers
|
||||||
|
* the family-specific dataId first.
|
||||||
|
*/
|
||||||
|
mountOther(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
displaySet: { displaySetInstanceUID: string; imageIds: string[] }
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mounts changed viewport data onto an existing viewport (updateViewport),
|
||||||
|
* optionally restoring the camera afterwards. Legacy snapshots getCamera and
|
||||||
|
* dispatches by runtime type; next snapshots the semantic view state and
|
||||||
|
* routes through dispatchMount. May return undefined when the viewport family
|
||||||
|
* has no re-mount path (matching the historical legacy behavior).
|
||||||
|
*/
|
||||||
|
remount(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
viewportData: StackViewportData | VolumeViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
keepCamera: boolean
|
||||||
|
): Promise<void> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the position presentation (camera/zoom/pan + view reference) to persist
|
||||||
|
* for restore. Legacy uses getViewPresentation (pan/zoom); native stores the
|
||||||
|
* semantic view-state displayArea (pan/zoom) since it has no getViewPresentation.
|
||||||
|
*/
|
||||||
|
getPositionPresentation(
|
||||||
|
csViewport: Types.IViewport,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
viewportId: string
|
||||||
|
): PositionPresentation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores a position presentation. Both apply the view reference (slice/
|
||||||
|
* orientation); legacy then applies getViewPresentation via setViewPresentation,
|
||||||
|
* native applies the stored displayArea via setViewState.
|
||||||
|
*/
|
||||||
|
setPositionPresentation(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
positionPresentation: PositionPresentation
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores a LUT presentation (VOI/colormap/invert). Legacy uses setProperties;
|
||||||
|
* native uses setDisplaySetPresentation (a PLANAR_NEXT viewport has no
|
||||||
|
* setProperties), so calling setPresentations on native no longer throws.
|
||||||
|
*/
|
||||||
|
setLutPresentation(viewport: Types.IViewport, lutPresentation: LutPresentation): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a native dataset id for a viewport against cornerstone's global
|
||||||
|
* GenericViewport metadata provider (§4.7). Next ref-counts and tracks per
|
||||||
|
* viewport so it can be released on unmount; legacy is a no-op (never registers).
|
||||||
|
*/
|
||||||
|
registerDataId(viewportId: string, dataId: string, payload: DataIdPayload): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the dataset registrations a viewport owns. Called from the service's
|
||||||
|
* disableElement BEFORE the viewport bookkeeping is deleted. Next releases (and
|
||||||
|
* removes from the provider when the last reference is gone); legacy is a no-op.
|
||||||
|
*/
|
||||||
|
onViewportDisabled(viewportId: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flushes all remaining registrations. Called from the service's destroy().
|
||||||
|
* Next clears its registry; legacy is a no-op.
|
||||||
|
*/
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
import type { Types as CoreTypes } from '@cornerstonejs/core';
|
||||||
|
|
||||||
|
export type FlipValue = 'toggle' | boolean;
|
||||||
|
export type RotationMode = 'apply' | 'set';
|
||||||
|
|
||||||
|
export interface VolumeLightingOptions {
|
||||||
|
shade?: boolean;
|
||||||
|
ambient?: number;
|
||||||
|
diffuse?: number;
|
||||||
|
specular?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindowLevelParams {
|
||||||
|
windowWidth: number;
|
||||||
|
windowCenter: number;
|
||||||
|
/** Legacy volume target; resolved by the caller. Ignored on native (active binding). */
|
||||||
|
volumeId?: string;
|
||||||
|
/** Native per-binding target (e.g. the PT overlay in a fusion); ignored on legacy. */
|
||||||
|
displaySetInstanceUID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColormapParams {
|
||||||
|
colormap: Record<string, unknown>;
|
||||||
|
/** Used by the legacy orthographic branch to resolve the volumeId; ignored on native. */
|
||||||
|
displaySetInstanceUID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-viewport interaction/appearance operations, extracted out of commandsModule so
|
||||||
|
* command bodies stay thin (migration plan §4.3). This mirrors the IViewportBackend
|
||||||
|
* family (LegacyViewportBackend / NextViewportBackend): there is one implementation per
|
||||||
|
* lane and each uses its lane's cornerstone APIs DIRECTLY (legacy getCamera/setProperties/
|
||||||
|
* getViewPresentation vs native getViewState/setDisplaySetPresentation/getViewReference).
|
||||||
|
*
|
||||||
|
* DISPATCH: unlike IViewportBackend (selected once by the appConfig flag because it owns
|
||||||
|
* per-session mount lifecycle), operations are routed PER VIEWPORT via the dispatcher in
|
||||||
|
* viewportOperations.ts (isNextViewport(viewport) ? next : legacy). The viewport is
|
||||||
|
* already created and self-describing, and a session can hold a mix of legacy and native
|
||||||
|
* viewports; per-viewport routing is the runtime truth.
|
||||||
|
*
|
||||||
|
* RENDER: no method calls viewport.render(). The caller renders, matching today's
|
||||||
|
* per-command render timing (e.g. setViewportColormap renders only when immediate).
|
||||||
|
*
|
||||||
|
* VIEWPORT RESOLUTION: never done here — the command owns "which viewport".
|
||||||
|
*/
|
||||||
|
export interface IViewportOperations {
|
||||||
|
// ---- camera / view-state ----
|
||||||
|
|
||||||
|
/** Toggle (default) or set horizontal flip. */
|
||||||
|
flipHorizontal(viewport: CoreTypes.IViewport, newValue?: FlipValue): void;
|
||||||
|
|
||||||
|
/** Toggle (default) or set vertical flip. */
|
||||||
|
flipVertical(viewport: CoreTypes.IViewport, newValue?: FlipValue): void;
|
||||||
|
|
||||||
|
/** Rotate: mode 'apply' = relative, mode 'set' = absolute (with flip-parity correction). */
|
||||||
|
rotate(viewport: CoreTypes.IViewport, rotation: number, mode?: RotationMode): void;
|
||||||
|
|
||||||
|
/** Reset properties + camera/view-state to defaults (slice/navigation preserved on native). */
|
||||||
|
reset(viewport: CoreTypes.IViewport): void;
|
||||||
|
|
||||||
|
/** Zoom by direction: >0 in, <0 out, 0 = fit-to-window (reset). */
|
||||||
|
scaleBy(viewport: CoreTypes.IViewport, direction: number): void;
|
||||||
|
|
||||||
|
/** Read the view-plane normal in a lane-appropriate way. */
|
||||||
|
getViewPlaneNormal(viewport: CoreTypes.IViewport): CoreTypes.Point3 | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-plane re-center (+ zoom-to-fit) of a measurement AFTER the caller's setViewReference
|
||||||
|
* slice jump. Returns true when it re-centered (caller should render), false when the
|
||||||
|
* measurement was already visible or in-plane centering is unsupported on the lane.
|
||||||
|
*/
|
||||||
|
centerOnMeasurement(viewport: CoreTypes.IViewport, measurement: Record<string, unknown>): boolean;
|
||||||
|
|
||||||
|
// ---- appearance / properties ----
|
||||||
|
|
||||||
|
/** Toggle invert. */
|
||||||
|
invert(viewport: CoreTypes.IViewport): void;
|
||||||
|
|
||||||
|
/** Apply a VOI window/level. */
|
||||||
|
setWindowLevel(viewport: CoreTypes.IViewport, params: WindowLevelParams): void;
|
||||||
|
|
||||||
|
/** Apply a colormap. */
|
||||||
|
setColormap(viewport: CoreTypes.IViewport, params: ColormapParams): void;
|
||||||
|
|
||||||
|
// ---- 3D volume rendering (CS-14: native unsupported yet) ----
|
||||||
|
|
||||||
|
/** VR preset. */
|
||||||
|
setPreset(viewport: CoreTypes.IViewport, preset: string): void;
|
||||||
|
|
||||||
|
/** VR sample distance / samples-per-ray quality. */
|
||||||
|
setVolumeRenderingQuality(viewport: CoreTypes.IViewport, volumeQuality: number): void;
|
||||||
|
|
||||||
|
/** Shift scalar-opacity transfer-function points. */
|
||||||
|
shiftVolumeOpacityPoints(viewport: CoreTypes.IViewport, shift: number): void;
|
||||||
|
|
||||||
|
/** VR lighting (shade/ambient/diffuse/specular). */
|
||||||
|
setVolumeLighting(viewport: CoreTypes.IViewport, options: VolumeLightingOptions): void;
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import type { Types } from '@cornerstonejs/core';
|
||||||
|
import type ViewportInfo from '../Viewport';
|
||||||
|
import type { Presentations } from '../../../types/Presentation';
|
||||||
|
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||||
|
import type { OverlayMountTask } from './IViewportBackend';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrow slice of CornerstoneViewportService that a viewport backend is
|
||||||
|
* allowed to reach (migration plan §4.3 access pattern). The service `implements`
|
||||||
|
* this and passes `this` to each backend, so a backend can dispatch the per-family
|
||||||
|
* mount work back to the shared service methods without reaching into unrelated
|
||||||
|
* internals. Keeping this interface narrow is what stops the off (legacy) path
|
||||||
|
* from drifting as the next backend grows.
|
||||||
|
*/
|
||||||
|
export interface IViewportServiceInternals {
|
||||||
|
_setStackViewport(
|
||||||
|
viewport: Types.IStackViewport,
|
||||||
|
viewportData: StackViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
presentations?: Presentations
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
_setVolumeViewport(
|
||||||
|
viewport: Types.IVolumeViewport,
|
||||||
|
viewportData: VolumeViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
presentations?: Presentations
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
_setEcgViewport(viewport: Types.IECGViewport, viewportData: StackViewportData): Promise<void>;
|
||||||
|
|
||||||
|
_setOtherViewport(
|
||||||
|
viewport: Types.IStackViewport,
|
||||||
|
viewportData: StackViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
presentations?: Presentations
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
/** Applies lut/position/segmentation presentations to a mounted viewport. */
|
||||||
|
setPresentations(viewportId: string, presentations: Presentations): void;
|
||||||
|
|
||||||
|
/** Runs the pending overlay (SEG/RTSTRUCT) adds produced by the mount prelude. */
|
||||||
|
_addOverlayRepresentations(overlayProcessingResults?: OverlayMountTask[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Records which display sets a viewport shows (service bookkeeping). */
|
||||||
|
_trackViewportDisplaySets(viewportId: string, displaySetInstanceUIDs: string[]): void;
|
||||||
|
}
|
||||||
@ -0,0 +1,264 @@
|
|||||||
|
import { Enums as csEnums, Types, metaData } from '@cornerstonejs/core';
|
||||||
|
import {
|
||||||
|
getLegacyViewportType,
|
||||||
|
isStackViewportType,
|
||||||
|
isVolume3DViewportType,
|
||||||
|
isVolumeViewportType,
|
||||||
|
} from '../../../utils/getLegacyViewportType';
|
||||||
|
import type ViewportInfo from '../Viewport';
|
||||||
|
import type {
|
||||||
|
Presentations,
|
||||||
|
PositionPresentation,
|
||||||
|
LutPresentation,
|
||||||
|
} from '../../../types/Presentation';
|
||||||
|
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||||
|
import type { IViewportBackend, StackMountContext, VolumeMountContext } from './IViewportBackend';
|
||||||
|
import type { IViewportServiceInternals } from './IViewportServiceInternals';
|
||||||
|
import { DataIdRegistry } from './dataIdRegistry';
|
||||||
|
|
||||||
|
// Mirrors WITH_ORIENTATION in CornerstoneViewportService (inlined to avoid a
|
||||||
|
// value import that would create a backend -> service circular dependency).
|
||||||
|
const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy (default) viewport backend. Selected when `appConfig.useNextViewports`
|
||||||
|
* is off. Routing mirrors today's CornerstoneViewportService._setDisplaySets legacy
|
||||||
|
* branch exactly (dispatch by runtime cornerstone viewport type) and delegates the
|
||||||
|
* per-family mount to the unchanged service methods, so the off path stays
|
||||||
|
* byte-identical. The one legacy family that touches the GenericViewport metadata
|
||||||
|
* provider is WSI (mountOther); those registrations go through the same
|
||||||
|
* ref-counted registry as the native backend so they are released on viewport
|
||||||
|
* disable/destroy instead of leaking across viewport reuse.
|
||||||
|
*/
|
||||||
|
export class LegacyViewportBackend implements IViewportBackend {
|
||||||
|
private readonly registry = new DataIdRegistry();
|
||||||
|
|
||||||
|
constructor(private readonly service: IViewportServiceInternals) {}
|
||||||
|
|
||||||
|
dispatchMount(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
viewportData: StackViewportData | VolumeViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
presentations: Presentations = {}
|
||||||
|
): Promise<void> {
|
||||||
|
if (isStackViewportType(viewport)) {
|
||||||
|
return this.service._setStackViewport(
|
||||||
|
viewport as Types.IStackViewport,
|
||||||
|
viewportData as StackViewportData,
|
||||||
|
viewportInfo,
|
||||||
|
presentations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVolumeViewportType(viewport)) {
|
||||||
|
return this.service._setVolumeViewport(
|
||||||
|
viewport as Types.IVolumeViewport,
|
||||||
|
viewportData as VolumeViewportData,
|
||||||
|
viewportInfo,
|
||||||
|
presentations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getLegacyViewportType(viewport) === csEnums.ViewportType.ECG) {
|
||||||
|
return this.service._setEcgViewport(
|
||||||
|
viewport as unknown as Types.IECGViewport,
|
||||||
|
viewportData as StackViewportData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.service._setOtherViewport(
|
||||||
|
viewport as Types.IStackViewport,
|
||||||
|
viewportData as StackViewportData,
|
||||||
|
viewportInfo,
|
||||||
|
presentations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountStack(viewport: Types.IStackViewport, context: StackMountContext): Promise<void> {
|
||||||
|
const {
|
||||||
|
imageIds,
|
||||||
|
initialImageIndex,
|
||||||
|
properties,
|
||||||
|
displayArea,
|
||||||
|
rotation,
|
||||||
|
flipHorizontal,
|
||||||
|
presentations,
|
||||||
|
overlayProcessingResults,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
await viewport.setStack(imageIds, initialImageIndex);
|
||||||
|
viewport.setProperties({ ...properties });
|
||||||
|
this.service.setPresentations(viewport.id, presentations);
|
||||||
|
|
||||||
|
await this.service._addOverlayRepresentations(overlayProcessingResults);
|
||||||
|
|
||||||
|
if (displayArea) {
|
||||||
|
viewport.setDisplayArea(displayArea as Types.DisplayArea);
|
||||||
|
}
|
||||||
|
if (rotation) {
|
||||||
|
viewport.setProperties({ rotation } as Parameters<typeof viewport.setProperties>[0]);
|
||||||
|
}
|
||||||
|
if (flipHorizontal) {
|
||||||
|
viewport.setCamera({ flipHorizontal: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountVolumes(): Promise<boolean> {
|
||||||
|
// Legacy volumes mount through the service's shared volume tail
|
||||||
|
// (setVolumes/addVolumes optimization, property application, presentations).
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountOverlayOnlyVolumes(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
volumeInputArray: unknown[]
|
||||||
|
): Promise<void> {
|
||||||
|
await (viewport as Types.IVolumeViewport).setVolumes(volumeInputArray as Types.IVolumeInput[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountEcg(
|
||||||
|
viewport: Types.IECGViewport,
|
||||||
|
_displaySet: { displaySetInstanceUID: string; imageIds?: string[] },
|
||||||
|
imageId: string
|
||||||
|
): Promise<void> {
|
||||||
|
return viewport.setEcg(imageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountOther(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
displaySet: { displaySetInstanceUID: string; imageIds: string[] }
|
||||||
|
): Promise<void> {
|
||||||
|
// CS3D's "redo viewports" replaced setDataIds with the generic
|
||||||
|
// setDisplaySets({ displaySetId }) API; the legacy adapters key off
|
||||||
|
// imageIds[0] as the displaySetId, so do the same here.
|
||||||
|
const displaySetId = displaySet.imageIds[0];
|
||||||
|
// Register the WSI dataset so the viewport can resolve its imageIds +
|
||||||
|
// webClient by display-set id, then mount via setDisplaySets. The webClient
|
||||||
|
// was registered under the WADO_WEB_CLIENT module (keyed by imageIds[0]) by
|
||||||
|
// the SM SOP class handler. CS3D's "redo viewports" reads this same registry
|
||||||
|
// (genericViewportDisplaySetMetadataProvider) from its WSI data provider;
|
||||||
|
// without this entry setDisplaySets throws "No registered WSI dataset" and
|
||||||
|
// the viewport renders gray.
|
||||||
|
const webClient = metaData.get(csEnums.MetadataModules.WADO_WEB_CLIENT, displaySetId);
|
||||||
|
// Ref-counted registration so the provider entry is removed when the last
|
||||||
|
// viewport showing this WSI display set is disabled (or on service destroy).
|
||||||
|
this.registry.register(viewport.id, displaySetId, {
|
||||||
|
kind: 'wsi',
|
||||||
|
imageIds: displaySet.imageIds,
|
||||||
|
options: { webClient },
|
||||||
|
});
|
||||||
|
await (
|
||||||
|
viewport as unknown as {
|
||||||
|
setDisplaySets: (args: { displaySetId: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
).setDisplaySets({ displaySetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
remount(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
viewportData: StackViewportData | VolumeViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
keepCamera: boolean
|
||||||
|
): Promise<void> | undefined {
|
||||||
|
let displaySetPromise: Promise<void> | undefined;
|
||||||
|
|
||||||
|
if (isVolumeViewportType(viewport)) {
|
||||||
|
// Snapshot the camera only for the family that uses it; taking it before
|
||||||
|
// the family checks would throw for families with no re-mount path.
|
||||||
|
const vp = viewport as Types.IVolumeViewport;
|
||||||
|
const viewportCamera = keepCamera ? vp.getCamera() : undefined;
|
||||||
|
displaySetPromise = this.service
|
||||||
|
._setVolumeViewport(
|
||||||
|
viewport as Types.IVolumeViewport,
|
||||||
|
viewportData as VolumeViewportData,
|
||||||
|
viewportInfo
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
if (viewportCamera) {
|
||||||
|
vp.setCamera(viewportCamera);
|
||||||
|
vp.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStackViewportType(viewport)) {
|
||||||
|
displaySetPromise = this.service._setStackViewport(
|
||||||
|
viewport as Types.IStackViewport,
|
||||||
|
viewportData as StackViewportData,
|
||||||
|
viewportInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return displaySetPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPositionPresentation(
|
||||||
|
csViewport: Types.IViewport,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
viewportId: string
|
||||||
|
): PositionPresentation {
|
||||||
|
const vp = csViewport as Types.IStackViewport;
|
||||||
|
return {
|
||||||
|
viewportType: viewportInfo.getViewportType(),
|
||||||
|
viewReference: isVolume3DViewportType(csViewport) ? null : vp.getViewReference(),
|
||||||
|
viewPresentation: vp.getViewPresentation({ pan: true, zoom: true }),
|
||||||
|
viewportId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setPositionPresentation(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
positionPresentation: PositionPresentation
|
||||||
|
): void {
|
||||||
|
const vp = viewport as Types.IStackViewport | Types.IVolumeViewport;
|
||||||
|
const viewRef = positionPresentation?.viewReference;
|
||||||
|
if (viewRef) {
|
||||||
|
// The orientation can be updated here to navigate to the specified
|
||||||
|
// measurement or previous item, but this will not switch to volume
|
||||||
|
// or to stack from the other type
|
||||||
|
if (vp.isReferenceViewable(viewRef, WITH_ORIENTATION)) {
|
||||||
|
vp.setViewReference(viewRef);
|
||||||
|
} else {
|
||||||
|
console.warn('Unable to apply reference viewable', viewRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewPresentation = positionPresentation?.viewPresentation;
|
||||||
|
if (viewPresentation) {
|
||||||
|
vp.setViewPresentation(viewPresentation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLutPresentation(viewport: Types.IViewport, lutPresentation: LutPresentation): void {
|
||||||
|
if (!lutPresentation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vp = viewport as Types.IStackViewport | Types.IVolumeViewport;
|
||||||
|
const { properties } = lutPresentation;
|
||||||
|
if (isVolumeViewportType(vp)) {
|
||||||
|
if (properties instanceof Map) {
|
||||||
|
properties.forEach((propertiesEntry, volumeId) => {
|
||||||
|
(vp as Types.IVolumeViewport).setProperties(propertiesEntry, volumeId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
vp.setProperties(properties);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vp.setProperties(properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDataId(): void {
|
||||||
|
// Legacy mounts do not register dataIds through the backend interface; the
|
||||||
|
// one provider-backed family (WSI) registers inline in mountOther.
|
||||||
|
}
|
||||||
|
|
||||||
|
onViewportDisabled(viewportId: string): void {
|
||||||
|
this.registry.releaseViewport(viewportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.registry.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
import { utilities as csUtils, Types as CoreTypes } from '@cornerstonejs/core';
|
||||||
|
import { mat4, vec3 } from 'gl-matrix';
|
||||||
|
import {
|
||||||
|
isStackViewportType,
|
||||||
|
isVolumeViewportType,
|
||||||
|
isOrthographicViewportType,
|
||||||
|
} from '../../../utils/getLegacyViewportType';
|
||||||
|
import { getCenterExtent } from '../../../utils/getCenterExtent';
|
||||||
|
import { isMeasurementWithinViewport } from '../../../utils/isMeasurementWithinViewport';
|
||||||
|
import type {
|
||||||
|
IViewportOperations,
|
||||||
|
FlipValue,
|
||||||
|
RotationMode,
|
||||||
|
VolumeLightingOptions,
|
||||||
|
WindowLevelParams,
|
||||||
|
ColormapParams,
|
||||||
|
} from './IViewportOperations';
|
||||||
|
|
||||||
|
// Loose view of the VTK actor/mapper/property chain used by the 3D VR ops. These
|
||||||
|
// live on vtk.js objects, not cornerstone types, so they are accessed structurally
|
||||||
|
// (mirrors the previously-untyped commandsModule bodies).
|
||||||
|
type VtkActorChain = {
|
||||||
|
actor: {
|
||||||
|
getMapper: () => Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
getProperty: () => Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy lane of IViewportOperations: every method is the corresponding
|
||||||
|
* commandsModule body lifted verbatim, using the legacy cornerstone APIs directly
|
||||||
|
* (getCamera/setCamera, getProperties/setProperties, getViewPresentation/
|
||||||
|
* setViewPresentation, resetCamera, getActors). This is the byte-identical flag-off
|
||||||
|
* path; the dispatcher only routes non-generic viewports here.
|
||||||
|
*
|
||||||
|
* No method calls viewport.render() — the command renders (matching per-command
|
||||||
|
* render timing).
|
||||||
|
*/
|
||||||
|
export const legacyViewportOperations: IViewportOperations = {
|
||||||
|
flipHorizontal(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void {
|
||||||
|
const vp = viewport as CoreTypes.IStackViewport;
|
||||||
|
let flipHorizontal: boolean;
|
||||||
|
if (newValue === 'toggle') {
|
||||||
|
flipHorizontal = !vp.getCamera().flipHorizontal;
|
||||||
|
} else {
|
||||||
|
flipHorizontal = newValue;
|
||||||
|
}
|
||||||
|
vp.setCamera({ flipHorizontal });
|
||||||
|
},
|
||||||
|
|
||||||
|
flipVertical(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void {
|
||||||
|
const vp = viewport as CoreTypes.IStackViewport;
|
||||||
|
let flipVertical: boolean;
|
||||||
|
if (newValue === 'toggle') {
|
||||||
|
flipVertical = !vp.getCamera().flipVertical;
|
||||||
|
} else {
|
||||||
|
flipVertical = newValue;
|
||||||
|
}
|
||||||
|
vp.setCamera({ flipVertical });
|
||||||
|
},
|
||||||
|
|
||||||
|
invert(viewport: CoreTypes.IViewport): void {
|
||||||
|
const vp = viewport as CoreTypes.IStackViewport;
|
||||||
|
const { invert } = vp.getProperties();
|
||||||
|
vp.setProperties({ invert: !invert });
|
||||||
|
},
|
||||||
|
|
||||||
|
rotate(viewport: CoreTypes.IViewport, rotation: number, mode: RotationMode = 'apply'): void {
|
||||||
|
if (isVolumeViewportType(viewport)) {
|
||||||
|
const vp = viewport as CoreTypes.IVolumeViewport;
|
||||||
|
const camera = vp.getCamera();
|
||||||
|
const rotAngle = (rotation * Math.PI) / 180;
|
||||||
|
const rotMat = mat4.identity(new Float32Array(16));
|
||||||
|
mat4.rotate(rotMat, rotMat, rotAngle, camera.viewPlaneNormal);
|
||||||
|
const rotatedViewUp = vec3.transformMat4(vec3.create(), camera.viewUp, rotMat);
|
||||||
|
vp.setCamera({ viewUp: rotatedViewUp as CoreTypes.Point3 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vp = viewport as CoreTypes.IStackViewport;
|
||||||
|
if (vp.getRotation !== undefined) {
|
||||||
|
const { rotation: currentRotation } = vp.getViewPresentation();
|
||||||
|
const newRotation =
|
||||||
|
mode === 'apply'
|
||||||
|
? (currentRotation + rotation + 360) % 360
|
||||||
|
: (() => {
|
||||||
|
// In 'set' mode, account for the effect horizontal/vertical flips
|
||||||
|
// have on the perceived rotation direction. A single flip mirrors
|
||||||
|
// the image and inverses rotation direction, while two flips
|
||||||
|
// restore the original parity. We therefore invert the rotation
|
||||||
|
// angle when an odd number of flips are applied so that the
|
||||||
|
// requested absolute rotation matches the user expectation.
|
||||||
|
const { flipHorizontal = false, flipVertical = false } = vp.getViewPresentation();
|
||||||
|
|
||||||
|
const flipsParity = (flipHorizontal ? 1 : 0) + (flipVertical ? 1 : 0);
|
||||||
|
const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation;
|
||||||
|
|
||||||
|
return (effectiveRotation + 360) % 360;
|
||||||
|
})();
|
||||||
|
vp.setViewPresentation({ rotation: newRotation });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset(viewport: CoreTypes.IViewport): void {
|
||||||
|
const vp = viewport as CoreTypes.IStackViewport;
|
||||||
|
vp.resetProperties?.();
|
||||||
|
vp.resetCamera();
|
||||||
|
},
|
||||||
|
|
||||||
|
scaleBy(viewport: CoreTypes.IViewport, direction: number): void {
|
||||||
|
const scaleFactor = direction > 0 ? 0.9 : 1.1;
|
||||||
|
if (isStackViewportType(viewport)) {
|
||||||
|
const vp = viewport as CoreTypes.IStackViewport;
|
||||||
|
if (direction) {
|
||||||
|
const { parallelScale } = vp.getCamera();
|
||||||
|
vp.setCamera({ parallelScale: parallelScale * scaleFactor });
|
||||||
|
} else {
|
||||||
|
vp.resetCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getViewPlaneNormal(viewport: CoreTypes.IViewport): CoreTypes.Point3 | undefined {
|
||||||
|
return (viewport as CoreTypes.IStackViewport).getCamera().viewPlaneNormal;
|
||||||
|
},
|
||||||
|
|
||||||
|
centerOnMeasurement(
|
||||||
|
viewport: CoreTypes.IViewport,
|
||||||
|
measurement: Record<string, unknown>
|
||||||
|
): boolean {
|
||||||
|
if (isMeasurementWithinViewport(viewport, measurement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vp = viewport as CoreTypes.IStackViewport;
|
||||||
|
const camera = vp.getCamera();
|
||||||
|
const { focalPoint: cameraFocalPoint, position: cameraPosition } = camera;
|
||||||
|
const { center, extent } = getCenterExtent(measurement);
|
||||||
|
const position = vec3.sub(vec3.create(), cameraPosition, cameraFocalPoint);
|
||||||
|
vec3.add(position, position, center);
|
||||||
|
vp.setCamera({ focalPoint: center, position: position as unknown as CoreTypes.Point3 });
|
||||||
|
// Zoom out if the measurement is too large
|
||||||
|
const measurementSize = vec3.dist(extent.min, extent.max);
|
||||||
|
if (measurementSize > camera.parallelScale) {
|
||||||
|
const scaleFactor = measurementSize / camera.parallelScale;
|
||||||
|
vp.setZoom(vp.getZoom() / scaleFactor);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
setWindowLevel(viewport: CoreTypes.IViewport, params: WindowLevelParams): void {
|
||||||
|
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
|
||||||
|
params.windowWidth,
|
||||||
|
params.windowCenter
|
||||||
|
);
|
||||||
|
if (isVolumeViewportType(viewport)) {
|
||||||
|
(viewport as CoreTypes.IVolumeViewport).setProperties(
|
||||||
|
{ voiRange: { upper, lower } },
|
||||||
|
params.volumeId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
(viewport as CoreTypes.IStackViewport).setProperties({ voiRange: { upper, lower } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setColormap(viewport: CoreTypes.IViewport, params: ColormapParams): void {
|
||||||
|
const { colormap, displaySetInstanceUID } = params;
|
||||||
|
if (isStackViewportType(viewport)) {
|
||||||
|
(viewport as CoreTypes.IStackViewport).setProperties({ colormap });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOrthographicViewportType(viewport)) {
|
||||||
|
const vp = viewport as CoreTypes.IVolumeViewport;
|
||||||
|
// ToDo: Find a better way of obtaining the volumeId that corresponds to the displaySetInstanceUID
|
||||||
|
const volumeId =
|
||||||
|
vp.getAllVolumeIds().find((_volumeId: string) => _volumeId.includes(displaySetInstanceUID)) ??
|
||||||
|
vp.getVolumeId();
|
||||||
|
vp.setProperties({ colormap }, volumeId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setPreset(viewport: CoreTypes.IViewport, preset: string): void {
|
||||||
|
(viewport as CoreTypes.IVolumeViewport).setProperties({ preset });
|
||||||
|
},
|
||||||
|
|
||||||
|
setVolumeRenderingQuality(viewport: CoreTypes.IViewport, volumeQuality: number): void {
|
||||||
|
const actorEntry = (viewport as unknown as CoreTypes.IVolumeViewport).getActors()[0];
|
||||||
|
if (!actorEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { actor } = actorEntry;
|
||||||
|
const mapper = (actor as unknown as VtkActorChain['actor']).getMapper();
|
||||||
|
const image = mapper.getInputData() as {
|
||||||
|
getDimensions: () => number[];
|
||||||
|
getSpacing: () => number[];
|
||||||
|
};
|
||||||
|
const dims = image.getDimensions();
|
||||||
|
const spacing = image.getSpacing();
|
||||||
|
const spatialDiagonal = vec3.length(
|
||||||
|
vec3.fromValues(dims[0] * spacing[0], dims[1] * spacing[1], dims[2] * spacing[2])
|
||||||
|
);
|
||||||
|
|
||||||
|
let sampleDistance = spacing.reduce((a, b) => a + b) / 3.0;
|
||||||
|
sampleDistance /= volumeQuality > 1 ? 0.5 * volumeQuality ** 2 : 1.0;
|
||||||
|
const samplesPerRay = spatialDiagonal / sampleDistance + 1;
|
||||||
|
mapper.setMaximumSamplesPerRay(samplesPerRay);
|
||||||
|
mapper.setSampleDistance(sampleDistance);
|
||||||
|
},
|
||||||
|
|
||||||
|
shiftVolumeOpacityPoints(viewport: CoreTypes.IViewport, shift: number): void {
|
||||||
|
const actorEntry = (viewport as unknown as CoreTypes.IVolumeViewport).getActors()[0];
|
||||||
|
if (!actorEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { actor } = actorEntry;
|
||||||
|
const ofun = (actor as unknown as VtkActorChain['actor']).getProperty().getScalarOpacity(0) as {
|
||||||
|
getSize: () => number;
|
||||||
|
getNodeValue: (i: number, v: number[]) => void;
|
||||||
|
removeAllPoints: () => void;
|
||||||
|
addPoint: (...args: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const opacityPointValues: number[][] = []; // Array to hold values
|
||||||
|
// Gather Existing Values
|
||||||
|
const size = ofun.getSize();
|
||||||
|
for (let pointIdx = 0; pointIdx < size; pointIdx++) {
|
||||||
|
const opacityPointValue = [0, 0, 0, 0];
|
||||||
|
ofun.getNodeValue(pointIdx, opacityPointValue);
|
||||||
|
// opacityPointValue now holds [xLocation, opacity, midpoint, sharpness]
|
||||||
|
opacityPointValues.push(opacityPointValue);
|
||||||
|
}
|
||||||
|
// Add offset
|
||||||
|
opacityPointValues.forEach(opacityPointValue => {
|
||||||
|
opacityPointValue[0] += shift; // Change the location value
|
||||||
|
});
|
||||||
|
// Set new values
|
||||||
|
ofun.removeAllPoints();
|
||||||
|
opacityPointValues.forEach(opacityPointValue => {
|
||||||
|
ofun.addPoint(...opacityPointValue);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setVolumeLighting(viewport: CoreTypes.IViewport, options: VolumeLightingOptions): void {
|
||||||
|
const actorEntry = (viewport as unknown as CoreTypes.IVolumeViewport).getActors()[0];
|
||||||
|
if (!actorEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { actor } = actorEntry;
|
||||||
|
const property = (actor as unknown as VtkActorChain['actor']).getProperty() as {
|
||||||
|
setShade: (v: boolean) => void;
|
||||||
|
setAmbient: (v: number) => void;
|
||||||
|
setDiffuse: (v: number) => void;
|
||||||
|
setSpecular: (v: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.shade !== undefined) {
|
||||||
|
property.setShade(options.shade);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.ambient !== undefined) {
|
||||||
|
property.setAmbient(options.ambient);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.diffuse !== undefined) {
|
||||||
|
property.setDiffuse(options.diffuse);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.specular !== undefined) {
|
||||||
|
property.setSpecular(options.specular);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,671 @@
|
|||||||
|
import {
|
||||||
|
Enums as csEnums,
|
||||||
|
Types,
|
||||||
|
metaData,
|
||||||
|
utilities as csUtils,
|
||||||
|
CONSTANTS as csConstants,
|
||||||
|
isRegisteredRenderBackend,
|
||||||
|
} from '@cornerstonejs/core';
|
||||||
|
import type { RenderBackendValue } from '@cornerstonejs/core';
|
||||||
|
import { utilities as csToolsUtils } from '@cornerstonejs/tools';
|
||||||
|
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||||
|
import { getViewportRenderingOverride } from '../../../utils/nextViewports';
|
||||||
|
import type ViewportInfo from '../Viewport';
|
||||||
|
import type {
|
||||||
|
Presentations,
|
||||||
|
PositionPresentation,
|
||||||
|
LutPresentation,
|
||||||
|
} from '../../../types/Presentation';
|
||||||
|
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||||
|
import type { IViewportBackend, StackMountContext, VolumeMountContext } from './IViewportBackend';
|
||||||
|
import type { IViewportServiceInternals } from './IViewportServiceInternals';
|
||||||
|
import { DataIdRegistry, type DataIdPayload } from './dataIdRegistry';
|
||||||
|
|
||||||
|
// Mirrors WITH_ORIENTATION in CornerstoneViewportService (inlined to avoid a
|
||||||
|
// value import that would create a backend -> service circular dependency).
|
||||||
|
const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-mount render backend override for a planar mount, resolved from the
|
||||||
|
* `<viewportType>.viewportRendering` URL param / appConfig captured at init
|
||||||
|
* (e.g. `?orthographic.viewportRendering=cpu`). Validated at mount time (not
|
||||||
|
* init) because extension backends may call registerRenderBackend after the
|
||||||
|
* cornerstone extension initializes; an unregistered value is dropped with a
|
||||||
|
* warning rather than failing the mount.
|
||||||
|
*/
|
||||||
|
function getMountRenderBackend(viewportTypeKey: string): RenderBackendValue | undefined {
|
||||||
|
const backend = getViewportRenderingOverride(viewportTypeKey);
|
||||||
|
if (!backend) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (backend !== 'auto' && !isRegisteredRenderBackend(backend)) {
|
||||||
|
console.warn(
|
||||||
|
`${viewportTypeKey}.viewportRendering: "${backend}" is not a registered render backend; ignoring.`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return backend as RenderBackendValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PlanarViewState fields that encode pan/zoom/rotation/flip. Slice and
|
||||||
|
// orientation are deliberately EXCLUDED — they are restored via the view reference,
|
||||||
|
// and a partial setViewState patch that omits them leaves them untouched (the merge
|
||||||
|
// at PlanarViewport.setViewState preserves unspecified fields).
|
||||||
|
const NATIVE_VIEW_PRESENTATION_KEYS = [
|
||||||
|
'displayArea',
|
||||||
|
'anchorWorld',
|
||||||
|
'anchorCanvas',
|
||||||
|
'scale',
|
||||||
|
'scaleMode',
|
||||||
|
'rotation',
|
||||||
|
'flipHorizontal',
|
||||||
|
'flipVertical',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Minimal structural view of a native PlanarViewport's semantic accessors. These
|
||||||
|
// live on IGenericViewport (not IStackViewport/IViewport), so we cast at the boundary
|
||||||
|
// rather than import core-internal PlanarViewport/PlanarViewState types.
|
||||||
|
type NativePlanarViewport = Types.IViewport & {
|
||||||
|
getViewState: () => Record<string, unknown>;
|
||||||
|
setViewState: (patch: Record<string, unknown>) => void;
|
||||||
|
getViewReference: () => Types.ViewReference;
|
||||||
|
setViewReference: (ref: Types.ViewReference) => void;
|
||||||
|
isReferenceViewable?: (ref: Types.ViewReference, opts?: unknown) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Picks the pan/zoom/rotation/flip subset out of a (deep-cloned) getViewState() result. */
|
||||||
|
function pickNativeViewPresentation(viewState: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of NATIVE_VIEW_PRESENTATION_KEYS) {
|
||||||
|
if (viewState[key] !== undefined) {
|
||||||
|
out[key] = viewState[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a default VOI window/level range from an image's DICOM voiLutModule
|
||||||
|
* metadata (first WindowCenter/WindowWidth pair). Used to seed native viewport
|
||||||
|
* windowing, which (unlike legacy StackViewport) is not auto-applied from
|
||||||
|
* metadata. Returns undefined when the metadata has no usable window.
|
||||||
|
*/
|
||||||
|
function getDefaultVoiRangeFromMetadata(
|
||||||
|
imageId: string
|
||||||
|
): { lower: number; upper: number } | undefined {
|
||||||
|
if (!imageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const voiLutModule = metaData.get('voiLutModule', imageId);
|
||||||
|
const wc = Array.isArray(voiLutModule?.windowCenter)
|
||||||
|
? voiLutModule.windowCenter[0]
|
||||||
|
: voiLutModule?.windowCenter;
|
||||||
|
const ww = Array.isArray(voiLutModule?.windowWidth)
|
||||||
|
? voiLutModule.windowWidth[0]
|
||||||
|
: voiLutModule?.windowWidth;
|
||||||
|
if (wc == null || ww == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return csUtils.windowLevel.toLowHighRange(ww, wc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native GenericViewport ("next") backend. Selected when `appConfig.useNextViewports`
|
||||||
|
* is on. Routes the mount by the bound data shape (native stack and volume content
|
||||||
|
* both report a single PLANAR_NEXT type, so the legacy runtime-type checks cannot
|
||||||
|
* classify them — §4.4), owns the per-family native MOUNT BODIES
|
||||||
|
* (mountStack/mountVolumes/mountEcg/mountOther/remount), and owns the ref-counted
|
||||||
|
* dataId lifecycle (§4.7) over cornerstone's global GenericViewport metadata
|
||||||
|
* provider. The service's shared methods hold no native branches.
|
||||||
|
*/
|
||||||
|
export class NextViewportBackend implements IViewportBackend {
|
||||||
|
private readonly registry = new DataIdRegistry();
|
||||||
|
|
||||||
|
constructor(private readonly service: IViewportServiceInternals) {}
|
||||||
|
|
||||||
|
dispatchMount(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
viewportData: StackViewportData | VolumeViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
presentations: Presentations = {}
|
||||||
|
): Promise<void> {
|
||||||
|
// Non-planar native families (video / WSI / ECG) route by viewport TYPE to their
|
||||||
|
// dedicated mounts: the bound data shape cannot distinguish them, and each needs
|
||||||
|
// family-specific dataId registration. Mirrors the legacy backend's type dispatch.
|
||||||
|
const type = (viewport as { type?: string }).type;
|
||||||
|
|
||||||
|
if (type === csEnums.ViewportType.ECG_NEXT) {
|
||||||
|
return this.service._setEcgViewport(
|
||||||
|
viewport as unknown as Types.IECGViewport,
|
||||||
|
viewportData as StackViewportData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === csEnums.ViewportType.VIDEO_NEXT ||
|
||||||
|
type === csEnums.ViewportType.WHOLE_SLIDE_NEXT
|
||||||
|
) {
|
||||||
|
return this.service._setOtherViewport(
|
||||||
|
viewport as unknown as Types.IStackViewport,
|
||||||
|
viewportData as StackViewportData,
|
||||||
|
viewportInfo,
|
||||||
|
presentations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planar stack vs volume content both report PLANAR_NEXT, so infer from the
|
||||||
|
// persisted dataShapeType contract (§4.4) — the canonical discriminator set by
|
||||||
|
// CornerstoneCacheService and used everywhere else. Don't probe `'volume' in
|
||||||
|
// firstData`: that field can be lazily initialized after the data object is built,
|
||||||
|
// so the presence check is unreliable. Fall back to the probe only when an older
|
||||||
|
// viewportData has no dataShapeType.
|
||||||
|
const dataShapeType = (viewportData as { dataShapeType?: csEnums.ViewportType }).dataShapeType;
|
||||||
|
const firstData = (viewportData?.data?.[0] ?? {}) as Record<string, unknown>;
|
||||||
|
const isVolumeContent =
|
||||||
|
dataShapeType === csEnums.ViewportType.ORTHOGRAPHIC ||
|
||||||
|
dataShapeType === csEnums.ViewportType.VOLUME_3D ||
|
||||||
|
(dataShapeType === undefined && 'volume' in firstData);
|
||||||
|
|
||||||
|
if (isVolumeContent) {
|
||||||
|
return this.service._setVolumeViewport(
|
||||||
|
viewport as unknown as Types.IVolumeViewport,
|
||||||
|
viewportData as VolumeViewportData,
|
||||||
|
viewportInfo,
|
||||||
|
presentations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.service._setStackViewport(
|
||||||
|
viewport as unknown as Types.IStackViewport,
|
||||||
|
viewportData as StackViewportData,
|
||||||
|
viewportInfo,
|
||||||
|
presentations
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native stack mount: register the display set and mount it with
|
||||||
|
* setDisplaySets (render path inferred from the data), then apply
|
||||||
|
* VOI/colormap via setDisplaySetPresentation and pan/zoom/rotate/flip/
|
||||||
|
* displayArea via setViewState — instead of the legacy setStack/setProperties/
|
||||||
|
* setCamera surface, which a direct PLANAR_NEXT viewport does not expose.
|
||||||
|
*/
|
||||||
|
async mountStack(viewport: Types.IStackViewport, context: StackMountContext): Promise<void> {
|
||||||
|
const {
|
||||||
|
displaySetInstanceUIDs,
|
||||||
|
imageIds,
|
||||||
|
initialImageIndex,
|
||||||
|
properties,
|
||||||
|
displayArea,
|
||||||
|
rotation,
|
||||||
|
flipHorizontal,
|
||||||
|
presentations,
|
||||||
|
overlayProcessingResults,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
// Native stacks arrive as PLANAR_NEXT and bypass the legacy STACK-typed
|
||||||
|
// invalid-stack guard upstream, so guard empty/malformed stack data here
|
||||||
|
// before registering and indexing imageIds below.
|
||||||
|
if (!imageIds?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vp = viewport as unknown as NativePlanarViewport & {
|
||||||
|
setDisplaySets: (args: {
|
||||||
|
displaySetId: string;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}) => Promise<void>;
|
||||||
|
setDisplaySetPresentation: (props: Record<string, unknown>) => void;
|
||||||
|
element: HTMLDivElement;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
const dataId = displaySetInstanceUIDs[0];
|
||||||
|
|
||||||
|
// Register through the ref-counted registry (§4.7) instead of the raw
|
||||||
|
// provider.add, so the registration is released on unmount and shared
|
||||||
|
// (MPR) registrations are not double-added or prematurely removed.
|
||||||
|
this.registerDataId(vp.id, dataId, {
|
||||||
|
kind: 'planar',
|
||||||
|
imageIds,
|
||||||
|
initialImageIdIndex: initialImageIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stackRenderBackend = getMountRenderBackend('stack');
|
||||||
|
await vp.setDisplaySets({
|
||||||
|
displaySetId: dataId,
|
||||||
|
options: {
|
||||||
|
orientation: csEnums.OrientationAxis.ACQUISITION,
|
||||||
|
role: 'source',
|
||||||
|
...(stackRenderBackend ? { renderBackend: stackRenderBackend } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Native viewports are presentation-driven and do NOT auto-derive a default
|
||||||
|
// window/level from the image's DICOM VOI metadata the way legacy StackViewport
|
||||||
|
// does, so without this the image renders with a raw/full-range VOI (too dark).
|
||||||
|
// When no explicit VOI is provided, seed it from the voiLutModule metadata.
|
||||||
|
if (!properties.voiRange) {
|
||||||
|
const defaultVoi = getDefaultVoiRangeFromMetadata(imageIds[initialImageIndex] ?? imageIds[0]);
|
||||||
|
if (defaultVoi) {
|
||||||
|
properties.voiRange = defaultVoi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentationProps: Record<string, unknown> = {};
|
||||||
|
if (properties.voiRange) {
|
||||||
|
presentationProps.voiRange = properties.voiRange;
|
||||||
|
}
|
||||||
|
if (properties.invert !== undefined) {
|
||||||
|
presentationProps.invert = properties.invert;
|
||||||
|
}
|
||||||
|
if (properties.colormap) {
|
||||||
|
presentationProps.colormap = properties.colormap;
|
||||||
|
}
|
||||||
|
if (Object.keys(presentationProps).length > 0) {
|
||||||
|
vp.setDisplaySetPresentation(presentationProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewStatePatch: Record<string, unknown> = {};
|
||||||
|
if (displayArea) {
|
||||||
|
viewStatePatch.displayArea = displayArea;
|
||||||
|
}
|
||||||
|
if (rotation) {
|
||||||
|
viewStatePatch.rotation = rotation;
|
||||||
|
}
|
||||||
|
if (flipHorizontal) {
|
||||||
|
viewStatePatch.flipHorizontal = true;
|
||||||
|
}
|
||||||
|
if (Object.keys(viewStatePatch).length > 0) {
|
||||||
|
vp.setViewState(viewStatePatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable stack-context prefetch for the native path. setDisplaySets above has
|
||||||
|
// already populated imageIds via genericViewportDisplaySetMetadataProvider, so
|
||||||
|
// getStackData returns a valid stack at enable() time. Scroll re-prefetch is
|
||||||
|
// driven by the native STACK_NEW_IMAGE event.
|
||||||
|
csToolsUtils.stackContextPrefetch.enable(vp.element);
|
||||||
|
|
||||||
|
// Restore persisted pan/zoom/rotation/flip (+ view reference) on top of the
|
||||||
|
// HP-derived defaults applied above, so a returning display set recovers its
|
||||||
|
// camera presentation. The LUT was already applied inline above, so restore
|
||||||
|
// position + segmentation only. Replaying segmentationPresentation re-adds
|
||||||
|
// hydrated representations (RTSS contour / SEG labelmap) on this native re-mount;
|
||||||
|
// without it a hydrated overlay silently disappears (the contour-vanishes-on-
|
||||||
|
// hydrate bug), because the overlay display set is no longer in the viewport's
|
||||||
|
// display-set list after hydration. Native-safe: position via setViewReference/
|
||||||
|
// setViewState, and the replayed addSegmentationRepresentation routes through the
|
||||||
|
// native segmentation backend (no convertStackToVolumeViewport / getViewPresentation).
|
||||||
|
if (presentations?.positionPresentation || presentations?.segmentationPresentation) {
|
||||||
|
this.service.setPresentations(vp.id, {
|
||||||
|
positionPresentation: presentations.positionPresentation,
|
||||||
|
segmentationPresentation: presentations.segmentationPresentation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.service._addOverlayRepresentations(overlayProcessingResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native volume/MPR mount: a direct PLANAR_NEXT viewport renders a volume by
|
||||||
|
* registering the dataset (with the already-cached volumeId) and calling
|
||||||
|
* setDisplaySets with the requested orientation; cornerstone selects the image
|
||||||
|
* vs reformatted-volume render path from that orientation. Returns true so the
|
||||||
|
* service skips the legacy setVolumes/setProperties tail; an overlay-only mount
|
||||||
|
* (no base volumes) returns false and traverses the shared tail, whose
|
||||||
|
* legacy-surface steps are lane-guarded via mountOverlayOnlyVolumes.
|
||||||
|
*/
|
||||||
|
async mountVolumes(viewport: Types.IViewport, context: VolumeMountContext): Promise<boolean> {
|
||||||
|
const {
|
||||||
|
filteredVolumeInputArray,
|
||||||
|
volumesProperties,
|
||||||
|
viewportInfo,
|
||||||
|
overlayProcessingResults,
|
||||||
|
presentations,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
if (!filteredVolumeInputArray.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._setNativeVolumeDisplaySets(
|
||||||
|
viewport,
|
||||||
|
filteredVolumeInputArray,
|
||||||
|
volumesProperties,
|
||||||
|
viewportInfo,
|
||||||
|
overlayProcessingResults
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore persisted pan/zoom/rotation/flip (+ view reference) so a returning
|
||||||
|
// MPR/volume pane recovers its camera. Also replay the stored lutPresentation so
|
||||||
|
// user window/level, colormap, invert, opacity or threshold edits survive the
|
||||||
|
// re-mount instead of resetting to the hanging-protocol defaults applied
|
||||||
|
// per-binding above (applied via setDisplaySetPresentation). And replay
|
||||||
|
// segmentationPresentation so hydrated overlays (SEG labelmap / RTSS contour)
|
||||||
|
// reappear on this native re-mount instead of vanishing. Native-safe: position
|
||||||
|
// via setViewReference/setViewState, segmentation via the native backend.
|
||||||
|
if (
|
||||||
|
presentations?.positionPresentation ||
|
||||||
|
presentations?.lutPresentation ||
|
||||||
|
presentations?.segmentationPresentation
|
||||||
|
) {
|
||||||
|
this.service.setPresentations(viewport.id, {
|
||||||
|
positionPresentation: presentations.positionPresentation,
|
||||||
|
lutPresentation: presentations.lutPresentation,
|
||||||
|
segmentationPresentation: presentations.segmentationPresentation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mountOverlayOnlyVolumes(): Promise<void> {
|
||||||
|
// Generic ("next") viewports don't expose the legacy setVolumes surface. A
|
||||||
|
// native overlay-only mount adds its overlays via _addOverlayRepresentations
|
||||||
|
// in the shared tail; there is nothing to mount here.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native ECG_NEXT has no setEcg; register the waveform under the display set's
|
||||||
|
* dataId and mount it through the generic setDisplaySets API (the native ECG data
|
||||||
|
* provider reads sourceDataId). Ref-counted via the registry (§4.7).
|
||||||
|
*/
|
||||||
|
async mountEcg(
|
||||||
|
viewport: Types.IECGViewport,
|
||||||
|
displaySet: { displaySetInstanceUID: string; imageIds?: string[] },
|
||||||
|
imageId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const dataId = displaySet.displaySetInstanceUID;
|
||||||
|
this.registerDataId(viewport.id, dataId, { kind: 'ecg', sourceDataId: imageId });
|
||||||
|
this.service._trackViewportDisplaySets(viewport.id, [dataId]);
|
||||||
|
await (
|
||||||
|
viewport as unknown as {
|
||||||
|
setDisplaySets: (args: { displaySetId: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
).setDisplaySets({ displaySetId: dataId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native VIDEO_NEXT / WHOLE_SLIDE_NEXT: register the family-specific dataId, then
|
||||||
|
* mount through the generic setDisplaySets API. The native video data provider
|
||||||
|
* reads sourceDataId; the WSI provider reads imageIds + a DICOMweb client, which
|
||||||
|
* we resolve from the WADO_WEB_CLIENT metadata exactly as the legacy WSI adapter
|
||||||
|
* does. Ref-counted via the registry (§4.7).
|
||||||
|
*/
|
||||||
|
async mountOther(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
displaySet: { displaySetInstanceUID: string; imageIds: string[] }
|
||||||
|
): Promise<void> {
|
||||||
|
const dataId = displaySet.displaySetInstanceUID;
|
||||||
|
const imageId = displaySet.imageIds[0];
|
||||||
|
const isWsi = (viewport as { type?: string }).type === csEnums.ViewportType.WHOLE_SLIDE_NEXT;
|
||||||
|
const payload = isWsi
|
||||||
|
? {
|
||||||
|
kind: 'wsi' as const,
|
||||||
|
imageIds: displaySet.imageIds,
|
||||||
|
options: {
|
||||||
|
webClient: metaData.get(csEnums.MetadataModules.WADO_WEB_CLIENT, imageId),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { kind: 'video' as const, sourceDataId: imageId };
|
||||||
|
this.registerDataId(viewport.id, dataId, payload);
|
||||||
|
this.service._trackViewportDisplaySets(viewport.id, [dataId]);
|
||||||
|
await (
|
||||||
|
viewport as unknown as {
|
||||||
|
setDisplaySets: (args: { displaySetId: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
).setDisplaySets({ displaySetId: dataId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native re-mount: no getCamera/setCamera. Snapshot/restore the camera via the
|
||||||
|
* semantic view state, and route the mount through dispatchMount (it routes by
|
||||||
|
* data shape, since native stack and volume both report PLANAR_NEXT).
|
||||||
|
*/
|
||||||
|
remount(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
viewportData: StackViewportData | VolumeViewportData,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
keepCamera: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const vp = viewport as NativePlanarViewport;
|
||||||
|
const viewState = keepCamera ? (vp.getViewState?.() ?? {}) : undefined;
|
||||||
|
return this.dispatchMount(viewport, viewportData, viewportInfo).then(() => {
|
||||||
|
if (keepCamera && viewState) {
|
||||||
|
vp.setViewState?.(viewState);
|
||||||
|
viewport.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts one or more volumes on a native viewport for volume/MPR rendering.
|
||||||
|
* Each base volume is registered with its already-cached volumeId and bound via
|
||||||
|
* setDisplaySets at the viewport's requested orientation; the first base volume
|
||||||
|
* is the source binding, any others are overlays (fusion). VOI/colormap/invert
|
||||||
|
* are applied per-binding via setDisplaySetPresentation.
|
||||||
|
*/
|
||||||
|
private async _setNativeVolumeDisplaySets(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
filteredVolumeInputArray: VolumeMountContext['filteredVolumeInputArray'],
|
||||||
|
volumesProperties: VolumeMountContext['volumesProperties'],
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
overlayProcessingResults: VolumeMountContext['overlayProcessingResults']
|
||||||
|
): Promise<void> {
|
||||||
|
const orientation = viewportInfo.getOrientation();
|
||||||
|
// A native VOLUME_3D_NEXT viewport renders the volume as a 3D VTK volume
|
||||||
|
// (renderMode 'vtkVolume3d'), not a reformatted planar slice; its appearance is
|
||||||
|
// driven by a volume-rendering preset, not orientation/role.
|
||||||
|
const is3D = (viewport as { type?: string }).type === csEnums.ViewportType.VOLUME_3D_NEXT;
|
||||||
|
const nativeViewport = viewport as unknown as {
|
||||||
|
setDisplaySets: (
|
||||||
|
...entries: Array<{ displaySetId: string; options: Record<string, unknown> }>
|
||||||
|
) => Promise<void>;
|
||||||
|
setDisplaySetPresentation: (dataId: string, props: Record<string, unknown>) => void;
|
||||||
|
getDefaultActor?: () => { actor?: unknown } | undefined;
|
||||||
|
render: () => void;
|
||||||
|
};
|
||||||
|
const setDisplaySetsEntries: Array<{
|
||||||
|
displaySetId: string;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
const volumeRenderBackend = is3D ? undefined : getMountRenderBackend('orthographic');
|
||||||
|
|
||||||
|
// First pass: register each dataId and build the COMPLETE entry set. The native
|
||||||
|
// PlanarViewport.setDisplaySets has replace semantics (removeReplaceableData), so
|
||||||
|
// it must receive ALL entries in ONE call - the first entry (role 'source')
|
||||||
|
// resolves the source binding and the rest are overlays. Calling it once per
|
||||||
|
// volume instead drops the previously-set source (e.g. the fusion CT): the next
|
||||||
|
// single-entry overlay call (PT) finds no source entry, falls back to entries[0]
|
||||||
|
// (= PT), and removeReplaceableData tears down CT - leaving only the PT colormap.
|
||||||
|
for (const [index, { volumeInput }] of filteredVolumeInputArray.entries()) {
|
||||||
|
const { imageIds, volumeId, displaySetInstanceUID } = volumeInput;
|
||||||
|
const dataId = displaySetInstanceUID;
|
||||||
|
|
||||||
|
// Ref-counted registration (§4.7): the MPR triptych shares one dataId across
|
||||||
|
// panes, so register() adds to the provider once and release() removes only
|
||||||
|
// when the last pane unmounts. (kind is stored but ignored by the volume3d
|
||||||
|
// data provider, which reads imageIds/volumeId.)
|
||||||
|
this.registerDataId(viewport.id, dataId, {
|
||||||
|
kind: 'planar',
|
||||||
|
imageIds,
|
||||||
|
volumeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setDisplaySetsEntries.push({
|
||||||
|
displaySetId: dataId,
|
||||||
|
options: is3D
|
||||||
|
? { renderMode: 'vtkVolume3d' }
|
||||||
|
: {
|
||||||
|
orientation,
|
||||||
|
role: index === 0 ? 'source' : 'overlay',
|
||||||
|
// Volume/MPR panes are 'orthographic' viewports at the OHIF level;
|
||||||
|
// renderBackend is a planar mount option, so the 3D branch is exempt.
|
||||||
|
...(volumeRenderBackend ? { renderBackend: volumeRenderBackend } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single replace call with the full entry set so the source (CT) is resolved
|
||||||
|
// and preserved instead of being torn down by per-volume calls.
|
||||||
|
await nativeViewport.setDisplaySets(...setDisplaySetsEntries);
|
||||||
|
|
||||||
|
// Second pass: per-dataId presentations and the 3D preset.
|
||||||
|
for (const [index, { volumeInput }] of filteredVolumeInputArray.entries()) {
|
||||||
|
const dataId = volumeInput.displaySetInstanceUID;
|
||||||
|
const props = volumesProperties[index]?.properties;
|
||||||
|
if (props) {
|
||||||
|
const presentationProps: Record<string, unknown> = {};
|
||||||
|
if (props.voiRange) {
|
||||||
|
presentationProps.voiRange = props.voiRange;
|
||||||
|
}
|
||||||
|
if (props.invert !== undefined) {
|
||||||
|
presentationProps.invert = props.invert;
|
||||||
|
}
|
||||||
|
// colormap is a planar (LUT) concept; 3D appearance comes from the preset.
|
||||||
|
if (props.colormap && !is3D) {
|
||||||
|
presentationProps.colormap = props.colormap;
|
||||||
|
}
|
||||||
|
// Slab/blend for projection viewports (e.g. the TMTV MIP pane: blendMode
|
||||||
|
// 'MIP' + slabThickness 'fullVolume'). The native volume-slice render path
|
||||||
|
// maps blendMode -> reslice SlabType (MAX/MIN/MEAN) and applies the slab
|
||||||
|
// thickness on the reslice mapper; without them the mapper renders a single
|
||||||
|
// slice instead of a projection. 3D volume rendering derives its look from
|
||||||
|
// the preset, not a slab, so this is planar-only. blendMode was already
|
||||||
|
// normalized from the HP string ('MIP') to a BlendModes enum by
|
||||||
|
// ViewportInfo.mapDisplaySetOptions, and slabThickness was resolved to a
|
||||||
|
// number by _getSlabThickness ('fullVolume' -> volume diagonal).
|
||||||
|
if (!is3D && volumeInput.blendMode !== undefined) {
|
||||||
|
presentationProps.blendMode = volumeInput.blendMode;
|
||||||
|
}
|
||||||
|
if (!is3D && volumeInput.slabThickness !== undefined) {
|
||||||
|
presentationProps.slabThickness = volumeInput.slabThickness;
|
||||||
|
}
|
||||||
|
if (Object.keys(presentationProps).length > 0) {
|
||||||
|
nativeViewport.setDisplaySetPresentation(dataId, presentationProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D volume rendering needs an RGBA transfer function (preset) to be visible;
|
||||||
|
// the bare native VolumeViewport3D has no setProperties, so apply the preset to
|
||||||
|
// the volume actor directly (mirrors the legacy adapter's applyPresetToBinding).
|
||||||
|
if (is3D && index === 0 && props?.preset) {
|
||||||
|
const preset = csConstants.VIEWPORT_PRESETS?.find(p => p.name === props.preset);
|
||||||
|
const actor = nativeViewport.getDefaultActor?.()?.actor;
|
||||||
|
if (preset && actor) {
|
||||||
|
csUtils.applyPreset(actor as Parameters<typeof csUtils.applyPreset>[0], preset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do NOT overwrite the service's viewport display-set bookkeeping here. The
|
||||||
|
// caller (_setVolumeViewport) already populated it with the COMPLETE set (base
|
||||||
|
// volumes + SEG/RT/fusion overlays) before this native mount; writing back the
|
||||||
|
// base-volume-only ids would drop the overlay UIDs from getViewportDisplaySets()
|
||||||
|
// and the later presentation/hydration flows.
|
||||||
|
|
||||||
|
await this.service._addOverlayRepresentations(overlayProcessingResults);
|
||||||
|
nativeViewport.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPositionPresentation(
|
||||||
|
csViewport: Types.IViewport,
|
||||||
|
viewportInfo: ViewportInfo,
|
||||||
|
viewportId: string
|
||||||
|
): PositionPresentation {
|
||||||
|
const is3D = isVolume3DViewportType(csViewport);
|
||||||
|
const vp = csViewport as NativePlanarViewport;
|
||||||
|
|
||||||
|
// A direct PLANAR_NEXT viewport has no getViewPresentation; pan/zoom/rotation/flip
|
||||||
|
// live in the semantic view state. getViewState() is already deep-cloned, normalized
|
||||||
|
// and JSON-serializable, so snapshot the pan/zoom subset (slice/orientation come back
|
||||||
|
// via the view reference).
|
||||||
|
const viewState =
|
||||||
|
!is3D && typeof vp.getViewState === 'function' ? vp.getViewState() : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
viewportType: viewportInfo.getViewportType(),
|
||||||
|
viewReference: is3D ? null : vp.getViewReference(),
|
||||||
|
// Opaque native pan/zoom blob; cast at the boundary (legacy stores a Types.ViewPresentation).
|
||||||
|
viewPresentation: (viewState
|
||||||
|
? pickNativeViewPresentation(viewState)
|
||||||
|
: undefined) as unknown as Types.ViewPresentation,
|
||||||
|
viewportId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setPositionPresentation(
|
||||||
|
viewport: Types.IViewport,
|
||||||
|
positionPresentation: PositionPresentation
|
||||||
|
): void {
|
||||||
|
const vp = viewport as NativePlanarViewport;
|
||||||
|
|
||||||
|
// 1) Slice + orientation first, via the view reference.
|
||||||
|
const viewRef = positionPresentation?.viewReference;
|
||||||
|
if (viewRef && vp.isReferenceViewable?.(viewRef, WITH_ORIENTATION)) {
|
||||||
|
vp.setViewReference(viewRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Pan/zoom/rotation/flip second, as a partial setViewState patch that omits
|
||||||
|
// slice/orientation so step 1 is preserved (the merge keeps unspecified fields).
|
||||||
|
const vpres = positionPresentation?.viewPresentation as unknown as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (vpres && typeof vp.setViewState === 'function') {
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const key of NATIVE_VIEW_PRESENTATION_KEYS) {
|
||||||
|
if (vpres[key] !== undefined) {
|
||||||
|
patch[key] = vpres[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
// When the snapshot held live anchor/scale pan/zoom, displayArea was omitted;
|
||||||
|
// clear any stale displayArea explicitly so anchor/scale take effect (setViewState
|
||||||
|
// only rewrites displayArea when it is an own key of the patch).
|
||||||
|
if (!('displayArea' in patch)) {
|
||||||
|
patch.displayArea = undefined;
|
||||||
|
}
|
||||||
|
vp.setViewState(patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLutPresentation(viewport: Types.IViewport, lutPresentation: LutPresentation): void {
|
||||||
|
if (!lutPresentation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { properties } = lutPresentation;
|
||||||
|
// Native LUT presentation is the getDisplaySetPresentation shape (voiRange/
|
||||||
|
// colormap/invert), not a per-volumeId Map; a PLANAR_NEXT viewport applies it via
|
||||||
|
// setDisplaySetPresentation (it has no legacy setProperties).
|
||||||
|
if (!properties || properties instanceof Map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nativeViewport = viewport as unknown as {
|
||||||
|
setDisplaySetPresentation: (props: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
const presentationProps: Record<string, unknown> = {};
|
||||||
|
if (properties.voiRange) {
|
||||||
|
presentationProps.voiRange = properties.voiRange;
|
||||||
|
}
|
||||||
|
if (properties.invert !== undefined) {
|
||||||
|
presentationProps.invert = properties.invert;
|
||||||
|
}
|
||||||
|
if (properties.colormap) {
|
||||||
|
presentationProps.colormap = properties.colormap;
|
||||||
|
}
|
||||||
|
if (Object.keys(presentationProps).length > 0) {
|
||||||
|
nativeViewport.setDisplaySetPresentation(presentationProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDataId(viewportId: string, dataId: string, payload: DataIdPayload): void {
|
||||||
|
this.registry.register(viewportId, dataId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
onViewportDisabled(viewportId: string): void {
|
||||||
|
this.registry.releaseViewport(viewportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.registry.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
utilities as csUtils,
|
||||||
|
CONSTANTS as csConstants,
|
||||||
|
Types as CoreTypes,
|
||||||
|
} from '@cornerstonejs/core';
|
||||||
|
import { getViewportAdapter } from '../adapter';
|
||||||
|
import { legacyViewportOperations } from './LegacyViewportOperations';
|
||||||
|
import type {
|
||||||
|
IViewportOperations,
|
||||||
|
FlipValue,
|
||||||
|
RotationMode,
|
||||||
|
VolumeLightingOptions,
|
||||||
|
WindowLevelParams,
|
||||||
|
ColormapParams,
|
||||||
|
} from './IViewportOperations';
|
||||||
|
|
||||||
|
// Native PLANAR_NEXT semantic accessors not covered by the viewport adapter.
|
||||||
|
// They live on IGenericViewport, so cast structurally at the boundary.
|
||||||
|
type NativePlanarViewport = CoreTypes.IViewport & {
|
||||||
|
resetViewState?: () => void;
|
||||||
|
resetDisplaySetPresentation?: (dataId?: string) => void;
|
||||||
|
getZoom?: () => number;
|
||||||
|
setZoom?: (zoom: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native ("next") lane of IViewportOperations for direct PLANAR_NEXT viewports.
|
||||||
|
* Appearance and camera/view-state ops go through the viewport adapter (which
|
||||||
|
* encapsulates the native getViewState/setViewState and
|
||||||
|
* getDisplaySetPresentation/setDisplaySetPresentation primitives, including the
|
||||||
|
* active-binding dataId default); the remaining ops use the native semantic API
|
||||||
|
* directly. The dispatcher only routes generic viewports here.
|
||||||
|
*
|
||||||
|
* No method calls viewport.render() — the command renders.
|
||||||
|
*/
|
||||||
|
export const nextViewportOperations: IViewportOperations = {
|
||||||
|
flipHorizontal(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void {
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
const flipHorizontal =
|
||||||
|
newValue === 'toggle' ? !adapter.getViewState().flipHorizontal : newValue;
|
||||||
|
adapter.setViewState({ flipHorizontal });
|
||||||
|
},
|
||||||
|
|
||||||
|
flipVertical(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void {
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
const flipVertical = newValue === 'toggle' ? !adapter.getViewState().flipVertical : newValue;
|
||||||
|
adapter.setViewState({ flipVertical });
|
||||||
|
},
|
||||||
|
|
||||||
|
invert(viewport: CoreTypes.IViewport): void {
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
const { invert } = adapter.getPresentation();
|
||||||
|
adapter.setPresentation({ invert: !invert });
|
||||||
|
},
|
||||||
|
|
||||||
|
rotate(viewport: CoreTypes.IViewport, rotation: number, mode: RotationMode = 'apply'): void {
|
||||||
|
// rotation/flip live in the semantic view state; getViewPresentation is absent.
|
||||||
|
const adapter = getViewportAdapter(viewport);
|
||||||
|
const state = adapter.getViewState();
|
||||||
|
const currentRotation = (state.rotation as number) ?? 0;
|
||||||
|
const newRotation =
|
||||||
|
mode === 'apply'
|
||||||
|
? (currentRotation + rotation + 360) % 360
|
||||||
|
: (() => {
|
||||||
|
const flipsParity = (state.flipHorizontal ? 1 : 0) + (state.flipVertical ? 1 : 0);
|
||||||
|
const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation;
|
||||||
|
return (effectiveRotation + 360) % 360;
|
||||||
|
})();
|
||||||
|
adapter.setViewState({ rotation: newRotation });
|
||||||
|
},
|
||||||
|
|
||||||
|
reset(viewport: CoreTypes.IViewport): void {
|
||||||
|
const vp = viewport as NativePlanarViewport;
|
||||||
|
// Reset the per-display-set presentation (VOI/colormap/invert) to defaults.
|
||||||
|
vp.resetDisplaySetPresentation?.();
|
||||||
|
// No resetCamera on PLANAR_NEXT; resetViewState resets pan/zoom/rotation/orientation/flip.
|
||||||
|
vp.resetViewState?.();
|
||||||
|
},
|
||||||
|
|
||||||
|
scaleBy(viewport: CoreTypes.IViewport, direction: number): void {
|
||||||
|
// parallelScale and zoom are inversely related (smaller parallelScale = more
|
||||||
|
// zoomed in = larger zoom), so divide by scaleFactor to match the legacy direction.
|
||||||
|
const scaleFactor = direction > 0 ? 0.9 : 1.1;
|
||||||
|
const vp = viewport as unknown as {
|
||||||
|
getZoom?: () => number;
|
||||||
|
setZoom?: (zoom: number) => void;
|
||||||
|
resetViewState?: (options?: { resetOrientation?: boolean }) => void;
|
||||||
|
};
|
||||||
|
if (direction) {
|
||||||
|
// Zoom is only meaningful on planar (stack / volume-slice / MPR) native viewports.
|
||||||
|
// VolumeViewport3D is also a generic viewport but exposes no getZoom/setZoom, so
|
||||||
|
// guard before calling — a no-op there matches the legacy lane, which only zoomed
|
||||||
|
// stack viewports (otherwise the zoom hotkey would throw on a native 3D viewport).
|
||||||
|
if (vp.getZoom && vp.setZoom) {
|
||||||
|
vp.setZoom(vp.getZoom() / scaleFactor);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// direction === 0 is the fitViewportToWindow command. Legacy resetCamera()
|
||||||
|
// resets pan/zoom/rotation/flip but never the viewing orientation, while a
|
||||||
|
// full native resetViewState() would also snap an MPR back to its requested
|
||||||
|
// axis — so keep the orientation.
|
||||||
|
vp.resetViewState?.({ resetOrientation: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getViewPlaneNormal(viewport: CoreTypes.IViewport): CoreTypes.Point3 | undefined {
|
||||||
|
return getViewportAdapter(viewport).getViewPlaneNormal();
|
||||||
|
},
|
||||||
|
|
||||||
|
centerOnMeasurement(): boolean {
|
||||||
|
// CS-14: native PLANAR_NEXT has no getCamera/setCamera for in-plane pan; the
|
||||||
|
// caller's setViewReference already navigated to the measurement's slice.
|
||||||
|
// TODO(next): port in-plane centering via the camera bridge + setViewState pan.
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
setWindowLevel(viewport: CoreTypes.IViewport, params: WindowLevelParams): void {
|
||||||
|
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
|
||||||
|
params.windowWidth,
|
||||||
|
params.windowCenter
|
||||||
|
);
|
||||||
|
// Target the binding for params.displaySetInstanceUID so a PT/CT *fusion* W/L lands
|
||||||
|
// on the intended layer (e.g. the PT overlay) instead of always the source (CT) —
|
||||||
|
// mirroring setColormap. When no id is given (single stack/volume) the adapter falls
|
||||||
|
// back to the source binding.
|
||||||
|
getViewportAdapter(viewport).setPresentation(
|
||||||
|
{ voiRange: { upper, lower } },
|
||||||
|
params.displaySetInstanceUID
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setColormap(viewport: CoreTypes.IViewport, params: ColormapParams): void {
|
||||||
|
// Target the binding for params.displaySetInstanceUID. OHIF's dataId scheme maps a
|
||||||
|
// display set 1:1 onto its native dataId (bare UID), so a PT/CT *fusion* colormap lands
|
||||||
|
// on the overlay (PT) binding instead of defaulting to the source (CT). When no id is
|
||||||
|
// given (single-volume / plain stack colormap) the adapter falls back to the source.
|
||||||
|
getViewportAdapter(viewport).setPresentation(
|
||||||
|
{ colormap: params.colormap },
|
||||||
|
params.displaySetInstanceUID
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
setPreset(viewport: CoreTypes.IViewport, preset: string): void {
|
||||||
|
// The native VolumeViewport3D has no setProperties; apply the volume-rendering
|
||||||
|
// preset (RGBA transfer function) to the volume actor directly.
|
||||||
|
const presetObj = csConstants.VIEWPORT_PRESETS?.find(p => p.name === preset);
|
||||||
|
const actor = (
|
||||||
|
viewport as unknown as { getDefaultActor?: () => { actor?: unknown } | undefined }
|
||||||
|
).getDefaultActor?.()?.actor;
|
||||||
|
if (presetObj && actor) {
|
||||||
|
csUtils.applyPreset(actor as Parameters<typeof csUtils.applyPreset>[0], presetObj);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// VR sample-distance / opacity-points / lighting operate on the vtk volume actor via
|
||||||
|
// getActors, which the native VolumeViewport3D exposes; the work is lane-agnostic, so
|
||||||
|
// reuse the legacy actor-based implementations.
|
||||||
|
setVolumeRenderingQuality(viewport: CoreTypes.IViewport, volumeQuality: number): void {
|
||||||
|
legacyViewportOperations.setVolumeRenderingQuality(viewport, volumeQuality);
|
||||||
|
},
|
||||||
|
|
||||||
|
shiftVolumeOpacityPoints(viewport: CoreTypes.IViewport, shift: number): void {
|
||||||
|
legacyViewportOperations.shiftVolumeOpacityPoints(viewport, shift);
|
||||||
|
},
|
||||||
|
|
||||||
|
setVolumeLighting(viewport: CoreTypes.IViewport, options: VolumeLightingOptions): void {
|
||||||
|
legacyViewportOperations.setVolumeLighting(viewport, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
# Next-viewport dispatch: how legacy-vs-native is decided
|
||||||
|
|
||||||
|
The `useNextViewports` opt-in routes OHIF onto cornerstone3D's native
|
||||||
|
("next" / GenericViewport) API. This document is the single description of
|
||||||
|
where and how the two lanes are selected. If you are adding a new legacy/native
|
||||||
|
divergence, one of the homes below is the right place — never an inline
|
||||||
|
`isGenericViewport` / flag check at the call site (enforced by
|
||||||
|
`scripts/check-next-viewport-boundaries.sh`).
|
||||||
|
|
||||||
|
## The three homes for divergence
|
||||||
|
|
||||||
|
| Home | What belongs there |
|
||||||
|
|---|---|
|
||||||
|
| `../adapter/` (`IViewportAdapter`) | API-surface bridging on a live viewport: reads/writes that exist on both lanes with different spellings (camera vs view state, properties vs display-set presentation, volumeId vs dataId, classification). The contract is next-shaped; the legacy adapter does the adapting. |
|
||||||
|
| `./` (`IViewportBackend`, `IViewportOperations`, and the segmentation twins in `../../SegmentationService/backends/`) | Lifecycle and interaction bodies: mount/re-mount, presentation capture/restore, dataId registration, per-command operations (flip/rotate/W-L/...), labelmap add/assembly. |
|
||||||
|
| `../../../utils/nextViewportPolicies.ts` | Behavioral policy: appearance defaults and workflow rules that differ by choice, not by API (fusion opacity flattening, overlay opacity, RTSTRUCT hydrate stack-pin). |
|
||||||
|
|
||||||
|
Pre-mount classification of `viewportData` (no live viewport yet) lives in
|
||||||
|
`../../../utils/viewportDataShape.ts`; the persisted `dataShapeType` field set
|
||||||
|
by `CornerstoneCacheService` is the sanctioned way to survive the native
|
||||||
|
type collapse (stack/volume/MPR all report `PLANAR_NEXT` at runtime).
|
||||||
|
|
||||||
|
## Two dispatch strategies (deliberately different)
|
||||||
|
|
||||||
|
- **Session flag, selected once** — `isNextViewportsEnabled()`. Used where no
|
||||||
|
viewport exists yet, or for per-session lifecycle:
|
||||||
|
`IViewportBackend` (the `get backend()` getter on CornerstoneViewportService),
|
||||||
|
`getCornerstoneViewportType` (viewport-type resolution), the SEG assembly
|
||||||
|
path, and the policies module.
|
||||||
|
- **Per-viewport predicate** — `isNextViewport(viewport)` (the adapter module's
|
||||||
|
wrapper around `csUtils.isGenericViewport`). Used where a self-describing
|
||||||
|
viewport is in hand, because a flag-on session can hold BOTH native and
|
||||||
|
legacy viewports: `getViewportAdapter`, `viewportOperations`, and the
|
||||||
|
segmentation backend twins.
|
||||||
|
|
||||||
|
Do not "unify" these: lifecycle genuinely is per-session; everything else
|
||||||
|
genuinely is per-viewport.
|
||||||
|
|
||||||
|
## Sanctioned flag reads (`isNextViewportsEnabled`) — the exhaustive list
|
||||||
|
|
||||||
|
1. `utils/getCornerstoneViewportType.ts` — maps requested OHIF viewport types
|
||||||
|
to native types.
|
||||||
|
2. `services/ViewportService/CornerstoneViewportService.ts` — the lazy
|
||||||
|
`get backend()` selection.
|
||||||
|
3. `services/SegmentationService/SegmentationService.ts` —
|
||||||
|
`assembleSegmentationDataForSEG` (dispatched at SEG-load time, before any
|
||||||
|
target viewport exists).
|
||||||
|
4. `utils/nextViewportPolicies.ts` — policy rules that apply before viewports
|
||||||
|
exist (e.g. the RTSTRUCT hydrate stack-pin).
|
||||||
|
5. `extensions/tmtv/src/getHangingProtocolModule.ts` — applies the
|
||||||
|
`NEXT_FUSION_PT_OPACITY` policy when the HP module is gathered (the flag is
|
||||||
|
settled by then; the legacy ramp in `hpViewports` stays untouched).
|
||||||
|
|
||||||
|
Adding a sixth read requires updating this list AND the whitelist in
|
||||||
|
`scripts/check-next-viewport-boundaries.sh` — if you can express the change as
|
||||||
|
an adapter capability, a backend method, or a policy entry instead, do that.
|
||||||
|
|
||||||
|
## Sanctioned `csUtils.isGenericViewport` calls
|
||||||
|
|
||||||
|
Only `../adapter/getViewportAdapter.ts` (which also exports the
|
||||||
|
`isNextViewport` predicate for the dispatchers above). Everyone else consumes
|
||||||
|
`IViewportAdapter` methods.
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
import { utilities as csUtils } from '@cornerstonejs/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload registered with cornerstone's global GenericViewport dataset metadata
|
||||||
|
* provider. The shape is family-specific and mirrors what each native viewport's
|
||||||
|
* data provider reads (see the cornerstone genericViewport examples):
|
||||||
|
* - planar : imageIds (+ optional volumeId) — stack / volume-slice / MPR / 3D
|
||||||
|
* - video : sourceDataId (the video imageId)
|
||||||
|
* - ecg : sourceDataId (the waveform imageId)
|
||||||
|
* - wsi : imageIds + a DICOMweb webClient used to fetch tiles
|
||||||
|
*/
|
||||||
|
export type DataIdPayload =
|
||||||
|
| { kind: 'planar'; imageIds: string[]; volumeId?: string; initialImageIdIndex?: number }
|
||||||
|
| { kind: 'video' | 'ecg'; sourceDataId: string }
|
||||||
|
| { kind: 'wsi'; imageIds: string[]; options: { webClient: unknown } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the lifecycle of OHIF's `dataId` registrations against cornerstone's
|
||||||
|
* process-global `genericViewportDisplaySetMetadataProvider` (see migration plan §4.7).
|
||||||
|
*
|
||||||
|
* Why this exists: cornerstone's `removeData`/`setDisplaySets` do NOT garbage-collect
|
||||||
|
* the global registration store (upstream blocker CS-18), so OHIF must own add/remove.
|
||||||
|
* The MPR triptych mounts the SAME `dataId` (the displaySetInstanceUID-derived volume)
|
||||||
|
* from N panes, so a naive add-on-every-mount / never-remove both over-registers and
|
||||||
|
* leaks. This registry ref-counts per `dataId` and keeps a per-viewport ledger so:
|
||||||
|
* - `provider.add` fires only on the 0 -> 1 transition,
|
||||||
|
* - `provider.remove` fires only on the 1 -> 0 transition,
|
||||||
|
* - unmounting one pane of a shared-volume triptych does not unregister data the
|
||||||
|
* other panes still need.
|
||||||
|
*
|
||||||
|
* Used by the native ("next") backend for all families, and by the legacy
|
||||||
|
* backend for its one provider-backed family (WSI mounts via mountOther).
|
||||||
|
*/
|
||||||
|
export class DataIdRegistry {
|
||||||
|
// Global ref-count keyed by dataId (the provider store is a single global namespace).
|
||||||
|
private readonly refCounts = new Map<string, number>();
|
||||||
|
// Last payload registered per dataId, so a re-registration that promotes a
|
||||||
|
// stack-only dataId to volume-backed can be detected and forwarded to the provider.
|
||||||
|
private readonly payloads = new Map<string, DataIdPayload>();
|
||||||
|
// Per-viewport ledger of the dataIds it registered, to drive release on unmount.
|
||||||
|
private readonly byViewport = new Map<string, string[]>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the registration dataId for a display set. PT/CT *fusion* overlays are
|
||||||
|
* distinct display sets with their own UIDs, so the bare displaySetInstanceUID is
|
||||||
|
* already collision-free and is used for both source and overlay bindings (the LUT
|
||||||
|
* presentation store keys by the same UID, giving a clean 1:1 dataId mapping). The
|
||||||
|
* `'overlay'` suffix is reserved for the case where a source and an overlay share the
|
||||||
|
* SAME displaySetInstanceUID but need distinct registrations — i.e. derived labelmap
|
||||||
|
* overlays (segmentation / M4), which are not yet on the native path.
|
||||||
|
*/
|
||||||
|
static dataIdFor(displaySetInstanceUID: string, role?: 'overlay'): string {
|
||||||
|
return role === 'overlay' ? `${displaySetInstanceUID}::overlay` : displaySetInstanceUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers (or ref-bumps) a dataId for a viewport. Adds to the cornerstone
|
||||||
|
* provider only on the first reference. Idempotent payloads across panes that
|
||||||
|
* share a dataId are expected (same imageIds/volumeId), so first-writer wins —
|
||||||
|
* EXCEPT when a later payload promotes a previously stack-only registration to
|
||||||
|
* a volume-backed one (gains a `volumeId`). That happens when a data overlay
|
||||||
|
* (fusion) is added to a viewport whose source was first mounted as a vtkImage
|
||||||
|
* stack: the source is re-registered with its volumeId so it can render as a
|
||||||
|
* volume slice alongside the overlay. Without updating the provider here, the
|
||||||
|
* source would keep its volumeId-less payload and stay vtkImage while the
|
||||||
|
* overlay is a vtkVolumeSlice (broken fusion).
|
||||||
|
*/
|
||||||
|
register(viewportId: string, dataId: string, payload: DataIdPayload): void {
|
||||||
|
const prev = this.refCounts.get(dataId) ?? 0;
|
||||||
|
const existing = this.payloads.get(dataId);
|
||||||
|
const promotesToVolume =
|
||||||
|
!!(payload as { volumeId?: string }).volumeId &&
|
||||||
|
!(existing as { volumeId?: string } | undefined)?.volumeId;
|
||||||
|
|
||||||
|
if (prev === 0 || promotesToVolume) {
|
||||||
|
csUtils.genericViewportDisplaySetMetadataProvider.add(dataId, payload);
|
||||||
|
this.payloads.set(dataId, payload);
|
||||||
|
}
|
||||||
|
this.refCounts.set(dataId, prev + 1);
|
||||||
|
|
||||||
|
const list = this.byViewport.get(viewportId) ?? [];
|
||||||
|
list.push(dataId);
|
||||||
|
this.byViewport.set(viewportId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases every dataId a viewport registered (called on element disable).
|
||||||
|
* Removes from the provider only when the last reference is gone.
|
||||||
|
*/
|
||||||
|
releaseViewport(viewportId: string): void {
|
||||||
|
const dataIds = this.byViewport.get(viewportId);
|
||||||
|
if (!dataIds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const dataId of dataIds) {
|
||||||
|
const next = (this.refCounts.get(dataId) ?? 1) - 1;
|
||||||
|
if (next <= 0) {
|
||||||
|
this.refCounts.delete(dataId);
|
||||||
|
this.payloads.delete(dataId);
|
||||||
|
csUtils.genericViewportDisplaySetMetadataProvider.remove(dataId);
|
||||||
|
} else {
|
||||||
|
this.refCounts.set(dataId, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.byViewport.delete(viewportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flushes all remaining registrations (called on service destroy). Removes each
|
||||||
|
* dataId individually rather than `provider.clear()`, which would wipe
|
||||||
|
* registrations owned by other rendering contexts / service instances.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
for (const dataId of this.refCounts.keys()) {
|
||||||
|
csUtils.genericViewportDisplaySetMetadataProvider.remove(dataId);
|
||||||
|
}
|
||||||
|
this.refCounts.clear();
|
||||||
|
this.payloads.clear();
|
||||||
|
this.byViewport.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { Types as CoreTypes } from '@cornerstonejs/core';
|
||||||
|
import { isNextViewport } from '../adapter';
|
||||||
|
import type {
|
||||||
|
IViewportOperations,
|
||||||
|
FlipValue,
|
||||||
|
RotationMode,
|
||||||
|
VolumeLightingOptions,
|
||||||
|
WindowLevelParams,
|
||||||
|
ColormapParams,
|
||||||
|
} from './IViewportOperations';
|
||||||
|
import { legacyViewportOperations } from './LegacyViewportOperations';
|
||||||
|
import { nextViewportOperations } from './NextViewportOperations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the operations lane for a SPECIFIC viewport. Unlike the IViewportBackend
|
||||||
|
* lifecycle backend (selected once by the appConfig flag because it owns the
|
||||||
|
* per-session mount), operations route per viewport: the viewport is already created
|
||||||
|
* and self-describing, and a session can hold both legacy and native viewports.
|
||||||
|
*/
|
||||||
|
function backendFor(viewport: CoreTypes.IViewport): IViewportOperations {
|
||||||
|
return isNextViewport(viewport) ? nextViewportOperations : legacyViewportOperations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton facade over the legacy/next operations backends. commandsModule (and any
|
||||||
|
* other caller) imports this and calls e.g. `viewportOperations.flipHorizontal(viewport)`,
|
||||||
|
* keeping native-vs-legacy interaction logic out of the command bodies (migration §4.3).
|
||||||
|
* Stateless and dependency-free (each op takes an already-resolved viewport), so it is a
|
||||||
|
* plain singleton rather than a registered service.
|
||||||
|
*/
|
||||||
|
export const viewportOperations: IViewportOperations = {
|
||||||
|
flipHorizontal: (viewport: CoreTypes.IViewport, newValue?: FlipValue) =>
|
||||||
|
backendFor(viewport).flipHorizontal(viewport, newValue),
|
||||||
|
|
||||||
|
flipVertical: (viewport: CoreTypes.IViewport, newValue?: FlipValue) =>
|
||||||
|
backendFor(viewport).flipVertical(viewport, newValue),
|
||||||
|
|
||||||
|
invert: (viewport: CoreTypes.IViewport) => backendFor(viewport).invert(viewport),
|
||||||
|
|
||||||
|
rotate: (viewport: CoreTypes.IViewport, rotation: number, mode?: RotationMode) =>
|
||||||
|
backendFor(viewport).rotate(viewport, rotation, mode),
|
||||||
|
|
||||||
|
reset: (viewport: CoreTypes.IViewport) => backendFor(viewport).reset(viewport),
|
||||||
|
|
||||||
|
scaleBy: (viewport: CoreTypes.IViewport, direction: number) =>
|
||||||
|
backendFor(viewport).scaleBy(viewport, direction),
|
||||||
|
|
||||||
|
getViewPlaneNormal: (viewport: CoreTypes.IViewport) =>
|
||||||
|
backendFor(viewport).getViewPlaneNormal(viewport),
|
||||||
|
|
||||||
|
centerOnMeasurement: (viewport: CoreTypes.IViewport, measurement: Record<string, unknown>) =>
|
||||||
|
backendFor(viewport).centerOnMeasurement(viewport, measurement),
|
||||||
|
|
||||||
|
setWindowLevel: (viewport: CoreTypes.IViewport, params: WindowLevelParams) =>
|
||||||
|
backendFor(viewport).setWindowLevel(viewport, params),
|
||||||
|
|
||||||
|
setColormap: (viewport: CoreTypes.IViewport, params: ColormapParams) =>
|
||||||
|
backendFor(viewport).setColormap(viewport, params),
|
||||||
|
|
||||||
|
setPreset: (viewport: CoreTypes.IViewport, preset: string) =>
|
||||||
|
backendFor(viewport).setPreset(viewport, preset),
|
||||||
|
|
||||||
|
setVolumeRenderingQuality: (viewport: CoreTypes.IViewport, volumeQuality: number) =>
|
||||||
|
backendFor(viewport).setVolumeRenderingQuality(viewport, volumeQuality),
|
||||||
|
|
||||||
|
shiftVolumeOpacityPoints: (viewport: CoreTypes.IViewport, shift: number) =>
|
||||||
|
backendFor(viewport).shiftVolumeOpacityPoints(viewport, shift),
|
||||||
|
|
||||||
|
setVolumeLighting: (viewport: CoreTypes.IViewport, options: VolumeLightingOptions) =>
|
||||||
|
backendFor(viewport).setVolumeLighting(viewport, options),
|
||||||
|
};
|
||||||
@ -54,8 +54,17 @@ class ImageOverlayViewerTool extends AnnotationDisplayTool {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A direct Generic ("next") viewport returns a falsy view-reference id until
|
||||||
|
// it has data bound (e.g. while it is being enabled, before setDisplaySets).
|
||||||
|
// getTargetId() would throw in that case and break the whole render pass, so
|
||||||
|
// skip overlay rendering until a reference is resolvable. Legacy viewports
|
||||||
|
// always return a string here, so this leaves their behavior unchanged.
|
||||||
|
if (!viewport.getViewReferenceId?.()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const targetId = this.getTargetId(viewport);
|
const targetId = this.getTargetId(viewport);
|
||||||
return targetId.split('imageId:')[1];
|
return targetId?.split('imageId:')[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAnnotation = (enabledElement, svgDrawingHelper) => {
|
renderAnnotation = (enabledElement, svgDrawingHelper) => {
|
||||||
|
|||||||
@ -21,11 +21,19 @@ type VolumeData = {
|
|||||||
|
|
||||||
type StackViewportData = {
|
type StackViewportData = {
|
||||||
viewportType: Enums.ViewportType;
|
viewportType: Enums.ViewportType;
|
||||||
|
// The legacy stack/volume data-shape decision (STACK vs ORTHOGRAPHIC/VOLUME_3D),
|
||||||
|
// preserved even when `viewportType` is a native Generic type (PLANAR_NEXT) that
|
||||||
|
// collapses both. Consumers that must distinguish stack from volume content
|
||||||
|
// (data re-build on invalidation, orientation markers) read this instead of
|
||||||
|
// `viewportType`, which is ambiguous on the native path. See migration plan §4.7.
|
||||||
|
dataShapeType?: Enums.ViewportType;
|
||||||
data: StackData[];
|
data: StackData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type VolumeViewportData = {
|
type VolumeViewportData = {
|
||||||
viewportType: Enums.ViewportType;
|
viewportType: Enums.ViewportType;
|
||||||
|
/** See StackViewportData.dataShapeType. */
|
||||||
|
dataShapeType?: Enums.ViewportType;
|
||||||
data: VolumeData[];
|
data: VolumeData[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import html2canvas from 'html2canvas';
|
|||||||
import { getEnabledElement } from '@cornerstonejs/core';
|
import { getEnabledElement } from '@cornerstonejs/core';
|
||||||
import { ToolGroupManager, segmentation, Enums } from '@cornerstonejs/tools';
|
import { ToolGroupManager, segmentation, Enums } from '@cornerstonejs/tools';
|
||||||
import { getEnabledElement as OHIFgetEnabledElement } from '../state';
|
import { getEnabledElement as OHIFgetEnabledElement } from '../state';
|
||||||
import { isStackViewportType, isVolumeViewportType } from './getLegacyViewportType';
|
import { getViewportAdapter } from '../services/ViewportService/adapter';
|
||||||
import { useSystem } from '@ohif/core/src';
|
import { useSystem } from '@ohif/core/src';
|
||||||
|
|
||||||
const { downloadUrl } = utils;
|
const { downloadUrl } = utils;
|
||||||
@ -119,35 +119,12 @@ const CornerstoneViewportDownloadForm = ({
|
|||||||
const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
|
const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Capture current viewport state
|
// Capture current viewport state. The download (capture) viewport is created
|
||||||
// - properties: VOI, colormap, interpolation, etc.
|
// with the SAME type as the source (see handleEnableViewport), so source and
|
||||||
// - viewPresentation: flip/rotate/zoom presentation state added for
|
// capture are both legacy or both native, and the source's adapter can mount
|
||||||
// saving flip and rotation for capture
|
// its displayed content (data + appearance + view state) onto the capture
|
||||||
// - viewReference: image/volume reference
|
// viewport directly.
|
||||||
const properties = viewport.getProperties();
|
await getViewportAdapter(viewport).copyDisplayedContentTo(downloadViewport);
|
||||||
const viewPresentation = viewport.getViewPresentation?.();
|
|
||||||
const viewRef = viewport.getViewReference?.();
|
|
||||||
|
|
||||||
if (isStackViewportType(downloadViewport)) {
|
|
||||||
const imageId = viewport.getCurrentImageId();
|
|
||||||
await downloadViewport.setStack([imageId]);
|
|
||||||
} else if (isVolumeViewportType(downloadViewport)) {
|
|
||||||
const volumeIds = viewport.getAllVolumeIds();
|
|
||||||
await downloadViewport.setVolumes([{ volumeId: volumeIds[0] }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply presentation state so captured image preserves flip/rotate
|
|
||||||
if (viewPresentation && downloadViewport.setViewPresentation) {
|
|
||||||
downloadViewport.setViewPresentation(viewPresentation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply viewport display properties
|
|
||||||
downloadViewport.setProperties(properties);
|
|
||||||
|
|
||||||
// Ensure correct image/volume reference
|
|
||||||
if (viewRef && downloadViewport.setViewReference) {
|
|
||||||
downloadViewport.setViewReference(viewRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadViewport.render();
|
downloadViewport.render();
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,11 @@ jest.mock('@cornerstonejs/core', () => ({
|
|||||||
ORTHOGRAPHIC: 'orthographic',
|
ORTHOGRAPHIC: 'orthographic',
|
||||||
VOLUME_3D: 'volume3d',
|
VOLUME_3D: 'volume3d',
|
||||||
ECG: 'ecg',
|
ECG: 'ecg',
|
||||||
|
PLANAR_NEXT: 'planarNext',
|
||||||
|
VOLUME_3D_NEXT: 'volume3dNext',
|
||||||
|
VIDEO_NEXT: 'videoNext',
|
||||||
|
WHOLE_SLIDE_NEXT: 'wholeSlideNext',
|
||||||
|
ECG_NEXT: 'ecgNext',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -58,7 +63,7 @@ describe('getCornerstoneViewportType', () => {
|
|||||||
|
|
||||||
it('should throw error for invalid viewport type', () => {
|
it('should throw error for invalid viewport type', () => {
|
||||||
expect(() => getCornerstoneViewportType('invalid')).toThrow(
|
expect(() => getCornerstoneViewportType('invalid')).toThrow(
|
||||||
'Invalid viewport type: invalid. Valid types are: stack, volume, video, wholeslide, ecg'
|
'Invalid viewport type: invalid. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,4 +99,71 @@ describe('getCornerstoneViewportType', () => {
|
|||||||
const result = getCornerstoneViewportType('wholeslide', undefined);
|
const result = getCornerstoneViewportType('wholeslide', undefined);
|
||||||
expect(result).toBe(Enums.ViewportType.WHOLE_SLIDE);
|
expect(result).toBe(Enums.ViewportType.WHOLE_SLIDE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useNextViewports (native Generic Viewport types)', () => {
|
||||||
|
it('maps stack to PLANAR_NEXT', () => {
|
||||||
|
expect(getCornerstoneViewportType('stack', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.PLANAR_NEXT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps volume and orthographic to PLANAR_NEXT', () => {
|
||||||
|
expect(getCornerstoneViewportType('volume', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.PLANAR_NEXT
|
||||||
|
);
|
||||||
|
expect(getCornerstoneViewportType('orthographic', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.PLANAR_NEXT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps volume3d / video / wholeslide / ecg to their *_NEXT types', () => {
|
||||||
|
expect(getCornerstoneViewportType('volume3d', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.VOLUME_3D_NEXT
|
||||||
|
);
|
||||||
|
expect(getCornerstoneViewportType('video', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.VIDEO_NEXT
|
||||||
|
);
|
||||||
|
expect(getCornerstoneViewportType('wholeslide', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.WHOLE_SLIDE_NEXT
|
||||||
|
);
|
||||||
|
expect(getCornerstoneViewportType('ecg', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.ECG_NEXT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the displaySet viewportType override under the flag', () => {
|
||||||
|
const displaySets = [{ viewportType: 'volume' }] as Types.DisplaySet[];
|
||||||
|
expect(getCornerstoneViewportType('stack', displaySets, true)).toBe(
|
||||||
|
Enums.ViewportType.PLANAR_NEXT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for an invalid viewport type under the flag', () => {
|
||||||
|
expect(() =>
|
||||||
|
getCornerstoneViewportType('invalid', undefined, true)
|
||||||
|
).toThrow('Invalid viewport type: invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the legacy mapping unchanged when the flag is off', () => {
|
||||||
|
expect(getCornerstoneViewportType('stack', undefined, false)).toBe(
|
||||||
|
Enums.ViewportType.STACK
|
||||||
|
);
|
||||||
|
expect(getCornerstoneViewportType('volume', undefined, false)).toBe(
|
||||||
|
Enums.ViewportType.ORTHOGRAPHIC
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent for already-native types regardless of the flag', () => {
|
||||||
|
// A viewport's stored cs type can be re-fed into the mapper.
|
||||||
|
expect(getCornerstoneViewportType('planarNext', undefined, false)).toBe(
|
||||||
|
Enums.ViewportType.PLANAR_NEXT
|
||||||
|
);
|
||||||
|
expect(getCornerstoneViewportType('planarNext', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.PLANAR_NEXT
|
||||||
|
);
|
||||||
|
expect(getCornerstoneViewportType('volume3dNext', undefined, true)).toBe(
|
||||||
|
Enums.ViewportType.VOLUME_3D_NEXT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Types } from '@ohif/core';
|
import type { Types } from '@ohif/core';
|
||||||
import { Enums } from '@cornerstonejs/core';
|
import { Enums } from '@cornerstonejs/core';
|
||||||
|
import { isNextViewportsEnabled } from './nextViewports';
|
||||||
|
|
||||||
const STACK = 'stack';
|
const STACK = 'stack';
|
||||||
const VOLUME = 'volume';
|
const VOLUME = 'volume';
|
||||||
@ -11,10 +12,53 @@ const ECG = 'ecg';
|
|||||||
|
|
||||||
export default function getCornerstoneViewportType(
|
export default function getCornerstoneViewportType(
|
||||||
viewportType: string,
|
viewportType: string,
|
||||||
displaySets?: Types.DisplaySet[]
|
displaySets?: Types.DisplaySet[],
|
||||||
|
useNextViewports = isNextViewportsEnabled()
|
||||||
): Enums.ViewportType {
|
): Enums.ViewportType {
|
||||||
const lowerViewportType =
|
const lowerViewportType =
|
||||||
displaySets?.[0]?.viewportType?.toLowerCase() || viewportType.toLowerCase();
|
displaySets?.[0]?.viewportType?.toLowerCase() || viewportType.toLowerCase();
|
||||||
|
|
||||||
|
// Already a native Generic ("next") type — e.g. re-derived from a viewport's
|
||||||
|
// stored cornerstone type (ViewportInfo.viewportType). Pass through
|
||||||
|
// idempotently, exactly as the legacy types below map to themselves; this must
|
||||||
|
// hold regardless of the flag so re-entrant callers don't throw.
|
||||||
|
switch (lowerViewportType) {
|
||||||
|
case 'planarnext':
|
||||||
|
return Enums.ViewportType.PLANAR_NEXT;
|
||||||
|
case 'volume3dnext':
|
||||||
|
return Enums.ViewportType.VOLUME_3D_NEXT;
|
||||||
|
case 'videonext':
|
||||||
|
return Enums.ViewportType.VIDEO_NEXT;
|
||||||
|
case 'wholeslidenext':
|
||||||
|
return Enums.ViewportType.WHOLE_SLIDE_NEXT;
|
||||||
|
case 'ecgnext':
|
||||||
|
return Enums.ViewportType.ECG_NEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native Generic Viewport ("next") path (appConfig.useNextViewports). Stack and
|
||||||
|
// volume/orthographic both collapse to PLANAR_NEXT — the render path (image vs
|
||||||
|
// volume slice) is inferred from the data shape, not from the viewport type.
|
||||||
|
if (useNextViewports) {
|
||||||
|
switch (lowerViewportType) {
|
||||||
|
case STACK:
|
||||||
|
case VOLUME:
|
||||||
|
case ORTHOGRAPHIC:
|
||||||
|
return Enums.ViewportType.PLANAR_NEXT;
|
||||||
|
case VOLUME_3D:
|
||||||
|
return Enums.ViewportType.VOLUME_3D_NEXT;
|
||||||
|
case VIDEO:
|
||||||
|
return Enums.ViewportType.VIDEO_NEXT;
|
||||||
|
case WHOLESLIDE:
|
||||||
|
return Enums.ViewportType.WHOLE_SLIDE_NEXT;
|
||||||
|
case ECG:
|
||||||
|
return Enums.ViewportType.ECG_NEXT;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (lowerViewportType === STACK) {
|
if (lowerViewportType === STACK) {
|
||||||
return Enums.ViewportType.STACK;
|
return Enums.ViewportType.STACK;
|
||||||
}
|
}
|
||||||
@ -39,6 +83,6 @@ export default function getCornerstoneViewportType(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, video, wholeslide, ecg`
|
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Resolves the data ID (e.g. volumeId) for a viewport and display set.
|
|
||||||
* For viewports with multiple volumes/actors, returns the id that matches the display set; otherwise undefined.
|
|
||||||
* Use this to call viewport.getProperties(dataId) in a viewport-type-agnostic way.
|
|
||||||
*
|
|
||||||
* @param viewport - Viewport instance (stack, volume, or future types with optional getAllVolumeIds)
|
|
||||||
* @param displaySetInstanceUID - Display set instance UID to match
|
|
||||||
* @returns volumeId (or equivalent) for multi-actor viewports, undefined for single-actor
|
|
||||||
*/
|
|
||||||
export function getDataIdForViewport(
|
|
||||||
viewport: unknown,
|
|
||||||
displaySetInstanceUID: string
|
|
||||||
): string | undefined {
|
|
||||||
const vp = viewport as { getAllVolumeIds?: () => string[] };
|
|
||||||
if (typeof vp.getAllVolumeIds !== 'function') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const volumeIds = vp.getAllVolumeIds() || [];
|
|
||||||
return volumeIds.length > 0
|
|
||||||
? volumeIds.find(id => id.includes(displaySetInstanceUID)) ?? undefined
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
@ -23,44 +23,42 @@ type ViewportLike = {
|
|||||||
* type. For "does this viewport support operation X" questions, prefer the
|
* type. For "does this viewport support operation X" questions, prefer the
|
||||||
* cornerstone capability guards (`utilities.viewportSupports*`) instead.
|
* cornerstone capability guards (`utilities.viewportSupports*`) instead.
|
||||||
*/
|
*/
|
||||||
export function getLegacyViewportType(
|
export function getLegacyViewportType(viewport: unknown): csEnums.ViewportType | undefined {
|
||||||
viewport: unknown
|
|
||||||
): csEnums.ViewportType | undefined {
|
|
||||||
const vp = viewport as ViewportLike | null | undefined;
|
const vp = viewport as ViewportLike | null | undefined;
|
||||||
return vp?.requestedType ?? vp?.type;
|
return vp?.requestedType ?? vp?.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy STACK viewport (image stack). Replaces `instanceof StackViewport`. */
|
/** Legacy STACK viewport (image stack). Replaces `instanceof StackViewport`. */
|
||||||
export function isStackViewportType(
|
export function isStackViewportType(viewport: unknown): viewport is csTypes.IStackViewport {
|
||||||
viewport: unknown
|
|
||||||
): viewport is csTypes.IStackViewport {
|
|
||||||
return getLegacyViewportType(viewport) === ViewportType.STACK;
|
return getLegacyViewportType(viewport) === ViewportType.STACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy ORTHOGRAPHIC (MPR) viewport. Replaces `instanceof VolumeViewport`. */
|
/** Legacy ORTHOGRAPHIC (MPR) viewport. Replaces `instanceof VolumeViewport`. */
|
||||||
export function isOrthographicViewportType(
|
export function isOrthographicViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport {
|
||||||
viewport: unknown
|
|
||||||
): viewport is csTypes.IVolumeViewport {
|
|
||||||
return getLegacyViewportType(viewport) === ViewportType.ORTHOGRAPHIC;
|
return getLegacyViewportType(viewport) === ViewportType.ORTHOGRAPHIC;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy VOLUME_3D viewport. Replaces `instanceof VolumeViewport3D`. */
|
/**
|
||||||
export function isVolume3DViewportType(
|
* 3D volume viewport. Replaces `instanceof VolumeViewport3D`.
|
||||||
viewport: unknown
|
*
|
||||||
): viewport is csTypes.IVolumeViewport {
|
* Matches both the legacy `VOLUME_3D` type and the native ("next") `VOLUME_3D_NEXT`.
|
||||||
return getLegacyViewportType(viewport) === ViewportType.VOLUME_3D;
|
* Under `useNextViewports`, OHIF requests `VOLUME_3D_NEXT` directly, so cornerstone
|
||||||
|
* leaves `requestedType` unset and `getLegacyViewportType` returns the native type
|
||||||
|
* (cornerstone only rewrites `requestedType` back to the legacy type for its own
|
||||||
|
* compat remap of a legacy `VOLUME_3D` request). Checking only `VOLUME_3D` would miss
|
||||||
|
* native 3D viewports and misroute them through the planar (getViewReference/getViewState)
|
||||||
|
* branch, and would leave 3D gates such as `is3DVolume` false.
|
||||||
|
*/
|
||||||
|
export function isVolume3DViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport {
|
||||||
|
const legacyType = getLegacyViewportType(viewport);
|
||||||
|
return legacyType === ViewportType.VOLUME_3D || legacyType === ViewportType.VOLUME_3D_NEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy ORTHOGRAPHIC or VOLUME_3D viewport (i.e. a `BaseVolumeViewport`).
|
* Legacy ORTHOGRAPHIC or VOLUME_3D viewport (i.e. a `BaseVolumeViewport`).
|
||||||
* Replaces `instanceof BaseVolumeViewport`.
|
* Replaces `instanceof BaseVolumeViewport`.
|
||||||
*/
|
*/
|
||||||
export function isVolumeViewportType(
|
export function isVolumeViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport {
|
||||||
viewport: unknown
|
|
||||||
): viewport is csTypes.IVolumeViewport {
|
|
||||||
const legacyType = getLegacyViewportType(viewport);
|
const legacyType = getLegacyViewportType(viewport);
|
||||||
return (
|
return legacyType === ViewportType.ORTHOGRAPHIC || legacyType === ViewportType.VOLUME_3D;
|
||||||
legacyType === ViewportType.ORTHOGRAPHIC ||
|
|
||||||
legacyType === ViewportType.VOLUME_3D
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
extensions/cornerstone/src/utils/nextViewportPolicies.ts
Normal file
41
extensions/cornerstone/src/utils/nextViewportPolicies.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { isNextViewportsEnabled } from './nextViewports';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The named home for every BEHAVIORAL POLICY difference between the legacy and
|
||||||
|
* native ("next") viewport paths — appearance defaults and workflow rules that
|
||||||
|
* are not API bridging (that's `services/ViewportService/adapter/`) and not
|
||||||
|
* mount lifecycle (that's `services/ViewportService/backends/`). Keeping them
|
||||||
|
* in one greppable file is the point: a policy divergence that lives inline in
|
||||||
|
* a mode or hanging protocol is invisible to the next reader.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial PT opacity for TMTV fusion viewports on the native path. Legacy (in
|
||||||
|
* tmtv's hpViewports) uses a per-value opacity ramp; native applies a ramp
|
||||||
|
* literally through its flat 2D blend (which would keep the background
|
||||||
|
* transparent), so the native path replaces the ramp with this single flat,
|
||||||
|
* more CT-weighted starting blend.
|
||||||
|
*/
|
||||||
|
export const NEXT_FUSION_PT_OPACITY = 0.4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial opacity for data overlays (e.g. colormapped foreground layers) on
|
||||||
|
* the native path. Native viewports composite an overlay as a 2D image slice
|
||||||
|
* with a flat alpha blend and no volume ray-cast opacity attenuation: the
|
||||||
|
* legacy nominal 0.9 renders at ~40% effective through the ray-cast path but
|
||||||
|
* reads ~80-90% on native, so native starts at the legacy-equivalent
|
||||||
|
* effective value.
|
||||||
|
*/
|
||||||
|
export const NEXT_OVERLAY_OPACITY = 0.4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport type to pin when hydrating a segmentation's referenced display set.
|
||||||
|
* RTSTRUCT contours render correctly on a native stack/vtkImage viewport and
|
||||||
|
* scroll fast, so the referenced image stays in stack mode on hydrate rather
|
||||||
|
* than being promoted to a volume slice (which the perf acceptance criteria
|
||||||
|
* forbid). Scoped to RTSTRUCT + the next path; SEG and legacy keep the default
|
||||||
|
* (undefined = no pin).
|
||||||
|
*/
|
||||||
|
export function getHydrationViewportTypeForModality(modality: string): 'stack' | undefined {
|
||||||
|
return modality === 'RTSTRUCT' && isNextViewportsEnabled() ? 'stack' : undefined;
|
||||||
|
}
|
||||||
157
extensions/cornerstone/src/utils/nextViewports.ts
Normal file
157
extensions/cornerstone/src/utils/nextViewports.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Module-level accessor for the `appConfig.genericViewports.enabled` opt-in flag.
|
||||||
|
*
|
||||||
|
* The flag is captured once at extension init (from the `useNextViewports` URL
|
||||||
|
* query param, else appConfig) and read by the
|
||||||
|
* two viewport-type chokepoints (`getCornerstoneViewportType` and the
|
||||||
|
* `CornerstoneViewportService` backend split) without threading appConfig
|
||||||
|
* through every service/viewport constructor. Defaults to `false` so the legacy
|
||||||
|
* path is unchanged until an app opts in.
|
||||||
|
*/
|
||||||
|
let nextViewportsEnabled = false;
|
||||||
|
|
||||||
|
export function setNextViewportsEnabled(value: boolean): void {
|
||||||
|
nextViewportsEnabled = Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNextViewportsEnabled(): boolean {
|
||||||
|
return nextViewportsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective flag at init. A `useNextViewports` URL query parameter
|
||||||
|
* takes precedence over the appConfig value, so the native backend can be opted
|
||||||
|
* into per-session via the URL (e.g. `?useNextViewports=true`) without editing
|
||||||
|
* the deployed config. `?useNextViewports` (no value), `=true`, or `=1` enable
|
||||||
|
* it; any other value disables it. When the param is absent, appConfig wins.
|
||||||
|
*/
|
||||||
|
export function resolveNextViewportsEnabled(appConfigValue: unknown): boolean {
|
||||||
|
return resolveBooleanUrlOptIn('useNextViewports', appConfigValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aliases accepted by the `viewportRendering` param/config, mapped to
|
||||||
|
* cornerstone render backend wire ids ('gpu' is the VTK/WebGL backend).
|
||||||
|
* Values not listed here pass through untouched so backends registered via
|
||||||
|
* cornerstone's `registerRenderBackend()` (e.g. a webgpu backend) can be
|
||||||
|
* selected by their wire id.
|
||||||
|
*/
|
||||||
|
const RENDER_BACKEND_ALIASES: Record<string, string> = {
|
||||||
|
webgl: 'gpu',
|
||||||
|
gpu: 'gpu',
|
||||||
|
cpu: 'cpu',
|
||||||
|
auto: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRenderBackend(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return RENDER_BACKEND_ALIASES[trimmed.toLowerCase()] ?? trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewportRenderingSelection {
|
||||||
|
/** Global render backend for all viewports (cornerstone `setRenderBackend`). */
|
||||||
|
renderBackend?: string;
|
||||||
|
/**
|
||||||
|
* Per-viewport-type overrides (per-mount `renderBackend` option), keyed by
|
||||||
|
* the lowercased OHIF viewport type (e.g. 'stack', 'orthographic').
|
||||||
|
*/
|
||||||
|
renderBackendByViewportType: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the render backend selection at init.
|
||||||
|
*
|
||||||
|
* `?viewportRendering=cpu|webgl|webgpu|auto` selects the render backend for
|
||||||
|
* all viewports per-session, and `?<viewportType>.viewportRendering=<backend>`
|
||||||
|
* (e.g. `?orthographic.viewportRendering=cpu`) overrides it for a single
|
||||||
|
* viewport type via the per-mount `renderBackend` option. URL params take
|
||||||
|
* precedence over `appConfig.genericViewports.viewportRendering`, which accepts
|
||||||
|
* either a backend string or `{ default?, stack?, orthographic? }`.
|
||||||
|
*
|
||||||
|
* 'webgl' is an alias for cornerstone's 'gpu' (VTK/WebGL) backend; 'cpu' and
|
||||||
|
* 'auto' map to the same-named backends; any other value is passed through as
|
||||||
|
* the wire id of a backend registered with `registerRenderBackend()` (e.g. a
|
||||||
|
* webgpu backend). Unlike a boolean CPU flag, this lets a session force GPU
|
||||||
|
* rendering when the deployed config defaults to CPU, and vice versa.
|
||||||
|
*/
|
||||||
|
export function resolveViewportRendering(appConfigValue: unknown): ViewportRenderingSelection {
|
||||||
|
const selection: ViewportRenderingSelection = { renderBackendByViewportType: {} };
|
||||||
|
|
||||||
|
if (typeof appConfigValue === 'string') {
|
||||||
|
selection.renderBackend = normalizeRenderBackend(appConfigValue);
|
||||||
|
} else if (appConfigValue && typeof appConfigValue === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(appConfigValue)) {
|
||||||
|
const backend = normalizeRenderBackend(value);
|
||||||
|
if (!backend) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'default') {
|
||||||
|
selection.renderBackend = backend;
|
||||||
|
} else {
|
||||||
|
selection.renderBackendByViewportType[key.toLowerCase()] = backend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
for (const [key, value] of params.entries()) {
|
||||||
|
const backend = normalizeRenderBackend(value);
|
||||||
|
if (!backend) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'viewportRendering') {
|
||||||
|
selection.renderBackend = backend;
|
||||||
|
} else if (key.endsWith('.viewportRendering')) {
|
||||||
|
const viewportType = key.slice(0, -'.viewportRendering'.length).toLowerCase();
|
||||||
|
if (viewportType) {
|
||||||
|
selection.renderBackendByViewportType[viewportType] = backend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// window/URL unavailable (SSR/non-browser) — keep the config-derived selection.
|
||||||
|
}
|
||||||
|
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-viewport-type render backend overrides, captured once at extension init
|
||||||
|
* (like the `useNextViewports` flag above) and read by the native mount paths
|
||||||
|
* in NextViewportBackend, which pass the override as the per-mount
|
||||||
|
* `renderBackend` option on `setDisplaySets`.
|
||||||
|
*/
|
||||||
|
let renderBackendByViewportType: Record<string, string> = {};
|
||||||
|
|
||||||
|
export function setViewportRenderingOverrides(overrides: Record<string, string>): void {
|
||||||
|
renderBackendByViewportType = overrides ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getViewportRenderingOverride(viewportType: string): string | undefined {
|
||||||
|
return renderBackendByViewportType[viewportType?.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a boolean opt-in URL query param, falling back to a config value when
|
||||||
|
* the param is absent. `?param` (no value), `=true`, or `=1` enable it; any
|
||||||
|
* other value disables it.
|
||||||
|
*/
|
||||||
|
function resolveBooleanUrlOptIn(paramName: string, fallbackValue: unknown): boolean {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.has(paramName)) {
|
||||||
|
const value = params.get(paramName);
|
||||||
|
return value === '' || value === 'true' || value === '1';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// window/URL unavailable (SSR/non-browser) — fall back to the config value.
|
||||||
|
}
|
||||||
|
return Boolean(fallbackValue);
|
||||||
|
}
|
||||||
@ -53,10 +53,14 @@ export function setupSegmentationDataModifiedHandler({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isUnsubscribed && updatedSegmentation) {
|
if (!isUnsubscribed && updatedSegmentation) {
|
||||||
segmentationService.addOrUpdateSegmentation({
|
const existingSegmentation = segmentationService.getSegmentation(segmentationId);
|
||||||
segmentationId,
|
|
||||||
segments: updatedSegmentation.segments,
|
if (existingSegmentation) {
|
||||||
});
|
segmentationService.addOrUpdateSegmentation({
|
||||||
|
segmentationId,
|
||||||
|
segments: updatedSegmentation.segments,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
1000
|
1000
|
||||||
|
|||||||
85
extensions/cornerstone/src/utils/viewportDataShape.ts
Normal file
85
extensions/cornerstone/src/utils/viewportDataShape.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { Enums } from '@cornerstonejs/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-mount classification of a viewport's bound data (viewportData from
|
||||||
|
* CornerstoneCacheService), for code that runs before — or independently of —
|
||||||
|
* a live viewport instance (overlays, scrollbars). Native ("next") viewports
|
||||||
|
* collapse stack/volume onto a single PLANAR_NEXT viewportType, so these
|
||||||
|
* helpers classify by the persisted dataShapeType and the data shape itself
|
||||||
|
* (imageIds = stack, volume/volumeId = volume) instead of the runtime type.
|
||||||
|
*
|
||||||
|
* For classification of a LIVE viewport instance, use
|
||||||
|
* `getViewportAdapter(viewport).getShape()` instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type ViewportDatum = {
|
||||||
|
imageIds?: string[];
|
||||||
|
volume?: unknown;
|
||||||
|
volumeId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewportDataLike = {
|
||||||
|
viewportType?: Enums.ViewportType;
|
||||||
|
dataShapeType?: Enums.ViewportType;
|
||||||
|
data?: ViewportDatum | ViewportDatum[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The primary (non-overlay) datum of a viewportData. */
|
||||||
|
export function getPrimaryViewportDatum(viewportData: ViewportDataLike): ViewportDatum | undefined {
|
||||||
|
return Array.isArray(viewportData?.data) ? viewportData.data[0] : viewportData?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the primary datum is volume-shaped (volume/volumeId present). */
|
||||||
|
export function isVolumeViewportData(viewportData: ViewportDataLike): boolean {
|
||||||
|
const firstData = getPrimaryViewportDatum(viewportData);
|
||||||
|
return !!(firstData && (firstData.volume || firstData.volumeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stack/volume shape a viewportData was built for, transparent across the
|
||||||
|
* native type collapse: the persisted dataShapeType when present (set by
|
||||||
|
* CornerstoneCacheService on the native path), else the legacy viewportType.
|
||||||
|
*/
|
||||||
|
export function getViewportDataShapeType(
|
||||||
|
viewportData: ViewportDataLike
|
||||||
|
): Enums.ViewportType | undefined {
|
||||||
|
return viewportData?.dataShapeType ?? viewportData?.viewportType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice-navigation event for this viewport's content. Resolved from the
|
||||||
|
* legacy viewportType when it is meaningful, else from the bound data shape —
|
||||||
|
* which is known immediately, unlike a runtime content-mode check that may not
|
||||||
|
* be ready while a native viewport is still binding. Native viewports emit the
|
||||||
|
* same STACK_NEW_IMAGE / VOLUME_NEW_IMAGE events as legacy.
|
||||||
|
*/
|
||||||
|
export function getSliceEventName(viewportData: ViewportDataLike): string {
|
||||||
|
const { viewportType } = viewportData;
|
||||||
|
const firstData = getPrimaryViewportDatum(viewportData);
|
||||||
|
return (
|
||||||
|
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
|
||||||
|
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||||
|
(isVolumeViewportData(viewportData) && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||||
|
(firstData?.imageIds && Enums.Events.STACK_NEW_IMAGE) ||
|
||||||
|
Enums.Events.IMAGE_RENDERED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The slice count for a viewport. `viewport.getNumberOfSlices()` can be
|
||||||
|
* premature while a native viewport is still binding its data (it returns 1
|
||||||
|
* until then). For an image stack the count is known from the bound data, so
|
||||||
|
* prefer that and only fall back to the viewport for volume/MPR (where the
|
||||||
|
* count depends on orientation).
|
||||||
|
*/
|
||||||
|
export function getViewportSliceCount(
|
||||||
|
viewportData: ViewportDataLike,
|
||||||
|
viewport: { getNumberOfSlices: () => number }
|
||||||
|
): number {
|
||||||
|
const firstData = getPrimaryViewportDatum(viewportData);
|
||||||
|
return (
|
||||||
|
(!isVolumeViewportData(viewportData) && firstData?.imageIds?.length) ||
|
||||||
|
viewport.getNumberOfSlices()
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ohif/extension-default",
|
"name": "@ohif/extension-default",
|
||||||
"version": "3.13.0-beta.95",
|
"version": "3.13.0-beta.125",
|
||||||
"description": "Common/default features and functionality for basic image viewing",
|
"description": "Common/default features and functionality for basic image viewing",
|
||||||
"author": "OHIF Core Team",
|
"author": "OHIF Core Team",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -31,7 +31,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ohif/core": "workspace:*",
|
"@ohif/core": "workspace:*",
|
||||||
"@ohif/i18n": "workspace:*",
|
"@ohif/i18n": "workspace:*",
|
||||||
"dcmjs": "0.49.4",
|
"dcmjs": "0.52.0",
|
||||||
"dicomweb-client": "0.10.4",
|
"dicomweb-client": "0.10.4",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
@ -48,6 +48,7 @@
|
|||||||
"react-color": "2.19.3"
|
"react-color": "2.19.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"cross-env": "7.0.3",
|
||||||
"webpack-merge": "5.10.0"
|
"webpack-merge": "5.10.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@ -152,7 +152,7 @@ function DataSourceConfigurationModalComponent({
|
|||||||
>
|
>
|
||||||
<div className="text- flex items-center gap-2">
|
<div className="text- flex items-center gap-2">
|
||||||
{itemLabelIndex < selectedItems.length ? (
|
{itemLabelIndex < selectedItems.length ? (
|
||||||
<Icons.ByName name="status-tracked" />
|
<Icons.ByName name="status-tracked" className="text-highlight" />
|
||||||
) : (
|
) : (
|
||||||
<Icons.ByName name="status-untracked" />
|
<Icons.ByName name="status-untracked" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { DicomMetadataStore, IWebApiDataSource, utils } from '@ohif/core';
|
import { DicomMetadataStore, IWebApiDataSource, utils } from '@ohif/core';
|
||||||
import OHIF from '@ohif/core';
|
import OHIF from '@ohif/core';
|
||||||
import dcmjs from 'dcmjs';
|
import {
|
||||||
|
datasetToDicomBlob,
|
||||||
|
makeExistingPropertiesNonEnumerable,
|
||||||
|
setNonEnumerableInstanceProperty,
|
||||||
|
} from '../utils/dicomWriter';
|
||||||
|
import { appendFrameQueryToImageId } from '../utils/appendFrameQueryToImageId';
|
||||||
|
|
||||||
const metadataProvider = OHIF.classes.MetadataProvider;
|
const metadataProvider = OHIF.classes.MetadataProvider;
|
||||||
const { EVENTS } = DicomMetadataStore;
|
const { EVENTS } = DicomMetadataStore;
|
||||||
@ -152,14 +157,15 @@ function createDicomLocalApi(dicomLocalConfig) {
|
|||||||
SOPInstanceUID,
|
SOPInstanceUID,
|
||||||
} = instance;
|
} = instance;
|
||||||
|
|
||||||
instance.imageId = imageId;
|
setNonEnumerableInstanceProperty(instance, 'imageId', imageId);
|
||||||
|
makeExistingPropertiesNonEnumerable(instance);
|
||||||
|
|
||||||
// Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI.
|
// Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI.
|
||||||
metadataProvider.addImageIdToUIDs(imageId, {
|
metadataProvider.addImageIdToUIDs(imageId, {
|
||||||
StudyInstanceUID,
|
StudyInstanceUID,
|
||||||
SeriesInstanceUID,
|
SeriesInstanceUID,
|
||||||
SOPInstanceUID,
|
SOPInstanceUID,
|
||||||
frameIndex: isMultiframe ? index : 1,
|
frameNumber: isMultiframe ? index + 1 : 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -174,7 +180,7 @@ function createDicomLocalApi(dicomLocalConfig) {
|
|||||||
},
|
},
|
||||||
store: {
|
store: {
|
||||||
dicom: naturalizedReport => {
|
dicom: naturalizedReport => {
|
||||||
const reportBlob = dcmjs.data.datasetToBlob(naturalizedReport);
|
const reportBlob = datasetToDicomBlob(naturalizedReport);
|
||||||
|
|
||||||
//Create a URL for the binary.
|
//Create a URL for the binary.
|
||||||
var objectUrl = URL.createObjectURL(reportBlob);
|
var objectUrl = URL.createObjectURL(reportBlob);
|
||||||
@ -211,9 +217,6 @@ function createDicomLocalApi(dicomLocalConfig) {
|
|||||||
getImageIdsForInstance({ instance, frame }) {
|
getImageIdsForInstance({ instance, frame }) {
|
||||||
// Important: Never use instance.imageId because it might be multiframe,
|
// Important: Never use instance.imageId because it might be multiframe,
|
||||||
// which would make it an invalid imageId.
|
// which would make it an invalid imageId.
|
||||||
// if (instance.imageId) {
|
|
||||||
// return instance.imageId;
|
|
||||||
// }
|
|
||||||
|
|
||||||
const { StudyInstanceUID, SeriesInstanceUID } = instance;
|
const { StudyInstanceUID, SeriesInstanceUID } = instance;
|
||||||
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
|
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
|
||||||
@ -223,13 +226,20 @@ function createDicomLocalApi(dicomLocalConfig) {
|
|||||||
SOPInstanceUID
|
SOPInstanceUID
|
||||||
);
|
);
|
||||||
|
|
||||||
let imageId = storedInstance.url;
|
const baseImageId = storedInstance?.url || instance.url;
|
||||||
|
|
||||||
if (frame !== undefined) {
|
if (!baseImageId) {
|
||||||
imageId += `&frame=${frame}`;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageId;
|
const numberOfFrames = Number(storedInstance?.NumberOfFrames || instance.NumberOfFrames) || 1;
|
||||||
|
|
||||||
|
if (numberOfFrames > 1) {
|
||||||
|
const frameNumber = frame !== undefined ? frame : 1;
|
||||||
|
return appendFrameQueryToImageId(baseImageId, frameNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseImageId;
|
||||||
},
|
},
|
||||||
deleteStudyMetadataPromise() {
|
deleteStudyMetadataPromise() {
|
||||||
console.log('deleteStudyMetadataPromise not implemented');
|
console.log('deleteStudyMetadataPromise not implemented');
|
||||||
|
|||||||
@ -12,8 +12,7 @@
|
|||||||
.dicom-tag-browser-table tr {
|
.dicom-tag-browser-table tr {
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
color: #ffffff;
|
color: hsl(var(--foreground));
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +42,6 @@
|
|||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: '#20A5D6';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dicom-tag-browser-table th.dicom-tag-browser-table-left {
|
.dicom-tag-browser-table th.dicom-tag-browser-table-left {
|
||||||
|
|||||||
@ -165,8 +165,11 @@ const DicomTagBrowser = ({
|
|||||||
</div>
|
</div>
|
||||||
{shouldShowInstanceList && (
|
{shouldShowInstanceList && (
|
||||||
<div className="mx-auto mt-0.5 flex w-1/4 flex-col">
|
<div className="mx-auto mt-0.5 flex w-1/4 flex-col">
|
||||||
<span className="text-muted-foreground flex h-6 items-center pb-2 text-base">
|
<span className="text-muted-foreground flex h-6 min-w-0 items-center whitespace-nowrap pb-2 text-base">
|
||||||
Instance Number ({instanceNumber} of {activeDisplaySet?.images?.length})
|
<span className="truncate">Instance Number</span>
|
||||||
|
<span className="shrink-0">
|
||||||
|
({instanceNumber} of {activeDisplaySet?.images?.length})
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Slider
|
<Slider
|
||||||
value={[instanceNumber]}
|
value={[instanceNumber]}
|
||||||
|
|||||||
@ -12,12 +12,20 @@ import dcm4cheeReject from './dcm4cheeReject.js';
|
|||||||
|
|
||||||
import getImageId from './utils/getImageId.js';
|
import getImageId from './utils/getImageId.js';
|
||||||
import dcmjs from 'dcmjs';
|
import dcmjs from 'dcmjs';
|
||||||
|
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||||
import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStudyMetadata.js';
|
import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStudyMetadata.js';
|
||||||
import StaticWadoClient from './utils/StaticWadoClient';
|
import StaticWadoClient from './utils/StaticWadoClient';
|
||||||
import getDirectURL from '../utils/getDirectURL';
|
import getDirectURL from '../utils/getDirectURL';
|
||||||
import { fixBulkDataURI } from './utils/fixBulkDataURI';
|
import { fixBulkDataURI } from './utils/fixBulkDataURI';
|
||||||
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
|
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
|
||||||
|
import {
|
||||||
|
getDatasetTransferSyntaxUID,
|
||||||
|
setNonEnumerableInstanceProperty,
|
||||||
|
writeDicomDictToPart10Buffer,
|
||||||
|
} from '../utils/dicomWriter';
|
||||||
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
|
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
|
||||||
|
import { getRenderedURL } from './retrieveRendered';
|
||||||
|
import retrieveBulkData from './retrieveBulkData';
|
||||||
|
|
||||||
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
|
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
|
||||||
|
|
||||||
@ -25,7 +33,6 @@ const { naturalizeDataset, denaturalizeDataset } = DicomMetaDictionary;
|
|||||||
|
|
||||||
const ImplementationClassUID = '2.25.270695996825855179949881587723571202391.2.0.0';
|
const ImplementationClassUID = '2.25.270695996825855179949881587723571202391.2.0.0';
|
||||||
const ImplementationVersionName = 'OHIF-3.11.0';
|
const ImplementationVersionName = 'OHIF-3.11.0';
|
||||||
const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
|
|
||||||
|
|
||||||
const metadataProvider = classes.MetadataProvider;
|
const metadataProvider = classes.MetadataProvider;
|
||||||
|
|
||||||
@ -108,7 +115,6 @@ export type BulkDataURIConfig = {
|
|||||||
relativeResolution?: 'studies' | 'series';
|
relativeResolution?: 'studies' | 'series';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The header options are the options passed into the generateWadoHeader
|
* The header options are the options passed into the generateWadoHeader
|
||||||
* command. This takes an extensible set of attributes to allow future enhancements.
|
* command. This takes an extensible set of attributes to allow future enhancements.
|
||||||
@ -142,6 +148,54 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
// this is part of hte base standard.
|
// this is part of hte base standard.
|
||||||
dicomWebConfig.bulkDataURI ||= { enabled: true };
|
dicomWebConfig.bulkDataURI ||= { enabled: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the retrieve bulkdata function to naturalized DICOM data.
|
||||||
|
* This is done recursively, for sub-sequences. Shared by both the lazy
|
||||||
|
* (async) and non-lazy (sync) series-metadata retrieval paths.
|
||||||
|
*/
|
||||||
|
const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => {
|
||||||
|
if (!naturalized) {
|
||||||
|
return naturalized;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(naturalized)) {
|
||||||
|
const value = naturalized[key];
|
||||||
|
|
||||||
|
if (Array.isArray(value) && typeof value[0] === 'object') {
|
||||||
|
// Fix recursive values
|
||||||
|
const validValues = value.filter(Boolean);
|
||||||
|
validValues.forEach(child => addRetrieveBulkDataNaturalized(child, instance));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The value.Value will be set with the bulkdata read value
|
||||||
|
// in which case it isn't necessary to re-read this.
|
||||||
|
if (value && value.BulkDataURI && !value.Value) {
|
||||||
|
// handle the scenarios where bulkDataURI is relative path
|
||||||
|
fixBulkDataURI(value, instance, dicomWebConfig);
|
||||||
|
// Provide a method to fetch bulkdata
|
||||||
|
value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return naturalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* naturalizes the dataset, and adds a retrieve bulkdata method
|
||||||
|
* to any values containing BulkDataURI.
|
||||||
|
* @param {*} instance
|
||||||
|
* @returns naturalized dataset, with retrieveBulkData methods
|
||||||
|
*/
|
||||||
|
const addRetrieveBulkData = instance => {
|
||||||
|
const naturalized = naturalizeDataset(instance);
|
||||||
|
|
||||||
|
// if we know the server doesn't use bulkDataURI, then don't
|
||||||
|
if (!dicomWebConfig.bulkDataURI?.enabled) {
|
||||||
|
return naturalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return addRetrieveBulkDataNaturalized(naturalized);
|
||||||
|
};
|
||||||
|
|
||||||
const implementation = {
|
const implementation = {
|
||||||
initialize: ({ params, query }) => {
|
initialize: ({ params, query }) => {
|
||||||
if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') {
|
if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') {
|
||||||
@ -291,6 +345,14 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
params
|
params
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
renderedURL: (params, options) => {
|
||||||
|
return getRenderedURL({
|
||||||
|
config: dicomWebConfig,
|
||||||
|
getAuthorizationHeader,
|
||||||
|
retrieve: implementation.retrieve,
|
||||||
|
userAuthenticationService,
|
||||||
|
})(params, options);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Provide direct access to the dicom web client for certain use cases
|
* Provide direct access to the dicom web client for certain use cases
|
||||||
* where the dicom web client is used by an external library such as the
|
* where the dicom web client is used by an external library such as the
|
||||||
@ -300,6 +362,86 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
*/
|
*/
|
||||||
getWadoDicomWebClient: () => wadoDicomWebClient,
|
getWadoDicomWebClient: () => wadoDicomWebClient,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort prefetch of a whole multiframe instance as a single Part 10
|
||||||
|
* object, registered into the Cornerstone3D NATURALIZED frame registry so
|
||||||
|
* subsequent per-frame image loads are served locally instead of issuing
|
||||||
|
* one network request per frame (see the "Behaviours" doc
|
||||||
|
* segmentation-multiframe-part10-prefetch).
|
||||||
|
*
|
||||||
|
* Whether to use it at all is the caller's policy (the SEG handler
|
||||||
|
* resolves the `loadMultiframeAsPart10` config/customization, defaulting
|
||||||
|
* it on — per-frame loading is the explicit opt-out there).
|
||||||
|
* Never throws into the caller — on any failure it resolves `done` to
|
||||||
|
* `false` and the normal per-frame load path is used.
|
||||||
|
*
|
||||||
|
* @returns `{ done: Promise<boolean>, cancel: () => void }`.
|
||||||
|
*/
|
||||||
|
prefetchInstanceFrames: ({ instance, imageId }) => {
|
||||||
|
const noop = { done: Promise.resolve(false), cancel: () => {} };
|
||||||
|
|
||||||
|
if (!instance || !imageId) {
|
||||||
|
return noop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudyInstanceUID = instance.StudyInstanceUID;
|
||||||
|
const SeriesInstanceUID = instance.SeriesInstanceUID;
|
||||||
|
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
|
||||||
|
|
||||||
|
if (!StudyInstanceUID || !SeriesInstanceUID || !SOPInstanceUID) {
|
||||||
|
return noop;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
// Lazy resolver: dicomweb-client.retrieveInstance returns the Part 10
|
||||||
|
// instance as an ArrayBuffer, unwrapping multipart/related transparently
|
||||||
|
// (and returning the raw object for single-part responses).
|
||||||
|
const resolvePart10 = async () => {
|
||||||
|
wadoDicomWebClient.headers = getAuthorizationHeader();
|
||||||
|
const result = await wadoDicomWebClient.retrieveInstance({
|
||||||
|
studyInstanceUID: StudyInstanceUID,
|
||||||
|
seriesInstanceUID: SeriesInstanceUID,
|
||||||
|
sopInstanceUID: SOPInstanceUID,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
throw new Error('prefetchInstanceFrames cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result instanceof ArrayBuffer) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (Array.isArray(result) && result[0] instanceof ArrayBuffer) {
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
if (result && (result as { buffer?: ArrayBuffer }).buffer instanceof ArrayBuffer) {
|
||||||
|
return (result as ArrayBufferView).buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
throw new Error('Unexpected retrieveInstance result for instance prefetch');
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = (async () => {
|
||||||
|
try {
|
||||||
|
await dicomImageLoader.prefetchPart10Instance(imageId, resolvePart10);
|
||||||
|
return !cancelled;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[prefetchInstanceFrames] full-instance prefetch failed; falling back to per-frame loads',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
done,
|
||||||
|
cancel: () => {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => {
|
bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => {
|
||||||
qidoDicomWebClient.headers = getAuthorizationHeader();
|
qidoDicomWebClient.headers = getAuthorizationHeader();
|
||||||
const options = {
|
const options = {
|
||||||
@ -364,7 +506,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
FileMetaInformationVersion: dataset._meta?.FileMetaInformationVersion?.Value,
|
FileMetaInformationVersion: dataset._meta?.FileMetaInformationVersion?.Value,
|
||||||
MediaStorageSOPClassUID: dataset.SOPClassUID,
|
MediaStorageSOPClassUID: dataset.SOPClassUID,
|
||||||
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
|
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
|
||||||
TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN,
|
TransferSyntaxUID: getDatasetTransferSyntaxUID(dataset),
|
||||||
ImplementationClassUID,
|
ImplementationClassUID,
|
||||||
ImplementationVersionName,
|
ImplementationVersionName,
|
||||||
};
|
};
|
||||||
@ -376,7 +518,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
effectiveDicomDict = defaultDicomDict;
|
effectiveDicomDict = defaultDicomDict;
|
||||||
}
|
}
|
||||||
|
|
||||||
const part10Buffer = effectiveDicomDict.write();
|
const part10Buffer = writeDicomDictToPart10Buffer(effectiveDicomDict);
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
datasets: [part10Buffer],
|
datasets: [part10Buffer],
|
||||||
@ -408,8 +550,16 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
dicomWebConfig
|
dicomWebConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
// first naturalize the data
|
// first naturalize the data, attaching bulkdata retrieve methods so that
|
||||||
const naturalizedInstancesMetadata = data.map(naturalizeDataset);
|
// bulkdata-valued tags can be resolved (matching the lazy-load path).
|
||||||
|
const naturalizedInstancesMetadata = data.map(addRetrieveBulkData);
|
||||||
|
|
||||||
|
// Resolve the registered bulkdata tags (e.g. the Philips SUV Scale
|
||||||
|
// Factor) delivered as bulkdata into plain numbers BEFORE
|
||||||
|
// INSTANCES_ADDED fires. retrieveBulkData is bound to qidoDicomWebClient,
|
||||||
|
// so refresh its auth headers first (matching every other qido op here).
|
||||||
|
qidoDicomWebClient.headers = getAuthorizationHeader();
|
||||||
|
await utils.resolveBulkDataTags(naturalizedInstancesMetadata);
|
||||||
|
|
||||||
const seriesSummaryMetadata = {};
|
const seriesSummaryMetadata = {};
|
||||||
const instancesPerSeries = {};
|
const instancesPerSeries = {};
|
||||||
@ -437,9 +587,9 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
instance,
|
instance,
|
||||||
});
|
});
|
||||||
|
|
||||||
instance.imageId = imageId;
|
setNonEnumerableInstanceProperty(instance, 'imageId', imageId);
|
||||||
instance.wadoRoot = dicomWebConfig.wadoRoot;
|
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
|
||||||
instance.wadoUri = dicomWebConfig.wadoUri;
|
setNonEnumerableInstanceProperty(instance, 'wadoUri', dicomWebConfig.wadoUri);
|
||||||
|
|
||||||
metadataProvider.addImageIdToUIDs(imageId, {
|
metadataProvider.addImageIdToUIDs(imageId, {
|
||||||
StudyInstanceUID,
|
StudyInstanceUID,
|
||||||
@ -483,61 +633,23 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
dicomWebConfig
|
dicomWebConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds the retrieve bulkdata function to naturalized DICOM data.
|
|
||||||
* This is done recursively, for sub-sequences.
|
|
||||||
*/
|
|
||||||
const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => {
|
|
||||||
if (!naturalized) {
|
|
||||||
return naturalized;
|
|
||||||
}
|
|
||||||
for (const key of Object.keys(naturalized)) {
|
|
||||||
const value = naturalized[key];
|
|
||||||
|
|
||||||
if (Array.isArray(value) && typeof value[0] === 'object') {
|
|
||||||
// Fix recursive values
|
|
||||||
const validValues = value.filter(Boolean);
|
|
||||||
validValues.forEach(child => addRetrieveBulkDataNaturalized(child, instance));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The value.Value will be set with the bulkdata read value
|
|
||||||
// in which case it isn't necessary to re-read this.
|
|
||||||
if (value && value.BulkDataURI && !value.Value) {
|
|
||||||
// handle the scenarios where bulkDataURI is relative path
|
|
||||||
fixBulkDataURI(value, instance, dicomWebConfig);
|
|
||||||
// Provide a method to fetch bulkdata
|
|
||||||
value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return naturalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* naturalizes the dataset, and adds a retrieve bulkdata method
|
|
||||||
* to any values containing BulkDataURI.
|
|
||||||
* @param {*} instance
|
|
||||||
* @returns naturalized dataset, with retrieveBulkData methods
|
|
||||||
*/
|
|
||||||
const addRetrieveBulkData = instance => {
|
|
||||||
const naturalized = naturalizeDataset(instance);
|
|
||||||
|
|
||||||
// if we know the server doesn't use bulkDataURI, then don't
|
|
||||||
if (!dicomWebConfig.bulkDataURI?.enabled) {
|
|
||||||
return naturalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
return addRetrieveBulkDataNaturalized(naturalized);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Async load series, store as retrieved
|
// Async load series, store as retrieved
|
||||||
function storeInstances(instances) {
|
async function storeInstances(instances) {
|
||||||
const naturalizedInstances = instances.map(addRetrieveBulkData);
|
const naturalizedInstances = instances.map(addRetrieveBulkData);
|
||||||
|
|
||||||
|
// Resolve the registered bulkdata tags (e.g. the Philips SUV Scale
|
||||||
|
// Factor) that the server delivered as bulkdata into plain numbers
|
||||||
|
// BEFORE INSTANCES_ADDED fires, so SUV scaling and every other
|
||||||
|
// subscriber read a fully-resolved value rather than an unresolved
|
||||||
|
// { BulkDataURI }. retrieveBulkData is bound to qidoDicomWebClient, so
|
||||||
|
// refresh its auth headers first (matching every other qido op here).
|
||||||
|
qidoDicomWebClient.headers = getAuthorizationHeader();
|
||||||
|
await utils.resolveBulkDataTags(naturalizedInstances);
|
||||||
|
|
||||||
// Adding instanceMetadata to OHIF MetadataProvider
|
// Adding instanceMetadata to OHIF MetadataProvider
|
||||||
naturalizedInstances.forEach(instance => {
|
naturalizedInstances.forEach(instance => {
|
||||||
instance.wadoRoot = dicomWebConfig.wadoRoot;
|
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
|
||||||
instance.wadoUri = dicomWebConfig.wadoUri;
|
setNonEnumerableInstanceProperty(instance, 'wadoUri', dicomWebConfig.wadoUri);
|
||||||
|
|
||||||
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
|
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
|
||||||
const numberOfFrames = instance.NumberOfFrames || 1;
|
const numberOfFrames = instance.NumberOfFrames || 1;
|
||||||
@ -563,7 +675,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
const imageId = implementation.getImageIdsForInstance({
|
const imageId = implementation.getImageIdsForInstance({
|
||||||
instance,
|
instance,
|
||||||
});
|
});
|
||||||
instance.imageId = imageId;
|
setNonEnumerableInstanceProperty(instance, 'imageId', imageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
DicomMetadataStore.addInstances(naturalizedInstances, madeInClient);
|
DicomMetadataStore.addInstances(naturalizedInstances, madeInClient);
|
||||||
@ -585,20 +697,44 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
|
|
||||||
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
|
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
|
||||||
|
|
||||||
|
let completedSeriesCount = 0;
|
||||||
const seriesDeliveredPromises = seriesPromises.map(promise => {
|
const seriesDeliveredPromises = seriesPromises.map(promise => {
|
||||||
if (!returnPromises) {
|
let deliveredPromise;
|
||||||
promise?.start();
|
|
||||||
}
|
return {
|
||||||
return promise.then(instances => {
|
metadata: promise.metadata,
|
||||||
storeInstances(instances);
|
start: () => {
|
||||||
});
|
if (!deliveredPromise) {
|
||||||
|
deliveredPromise = promise.start().then(async instances => {
|
||||||
|
await storeInstances(instances);
|
||||||
|
|
||||||
|
completedSeriesCount++;
|
||||||
|
if (returnPromises && completedSeriesCount === seriesPromises.length) {
|
||||||
|
setSuccessFlag();
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deliveredPromise;
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (returnPromises) {
|
if (returnPromises) {
|
||||||
Promise.all(seriesDeliveredPromises).then(() => setSuccessFlag());
|
if (!seriesDeliveredPromises.length) {
|
||||||
return seriesPromises;
|
setSuccessFlag();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The route starts only the series required by the hanging protocol,
|
||||||
|
// then starts the remainder in the background. Return wrappers whose
|
||||||
|
// start() resolves after async metadata post-processing has stored the
|
||||||
|
// instances and fired INSTANCES_ADDED; resolving the raw retrieval here
|
||||||
|
// races hanging-protocol application against display-set creation.
|
||||||
|
return seriesDeliveredPromises;
|
||||||
} else {
|
} else {
|
||||||
await Promise.all(seriesDeliveredPromises);
|
await Promise.all(seriesDeliveredPromises.map(promise => promise.start()));
|
||||||
setSuccessFlag();
|
setSuccessFlag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -668,34 +804,4 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
|||||||
return IWebApiDataSource.create(implementation);
|
return IWebApiDataSource.create(implementation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A bindable function that retrieves the bulk data against this as the
|
|
||||||
* dicomweb client, and on the given value element.
|
|
||||||
*
|
|
||||||
* @param value - a bind value that stores the retrieve value to short circuit the
|
|
||||||
* next retrieve instance.
|
|
||||||
* @param options - to allow specifying the content type.
|
|
||||||
*/
|
|
||||||
function retrieveBulkData(value, options = {}) {
|
|
||||||
const { mediaType } = options;
|
|
||||||
const useOptions = {
|
|
||||||
// The bulkdata fetches work with either multipart or
|
|
||||||
// singlepart, so set multipart to false to let the server
|
|
||||||
// decide which type to respond with.
|
|
||||||
multipart: false,
|
|
||||||
BulkDataURI: value.BulkDataURI,
|
|
||||||
mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined,
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
return this.retrieveBulkData(useOptions).then(val => {
|
|
||||||
// There are DICOM PDF cases where the first ArrayBuffer in the array is
|
|
||||||
// the bulk data and DICOM video cases where the second ArrayBuffer is
|
|
||||||
// the bulk data. Here we play it safe and do a find.
|
|
||||||
const ret =
|
|
||||||
(val instanceof Array && val.find(arrayBuffer => arrayBuffer?.byteLength)) || undefined;
|
|
||||||
value.Value = ret;
|
|
||||||
return ret;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createDicomWebApi };
|
export { createDicomWebApi };
|
||||||
|
|||||||
@ -52,6 +52,7 @@ function processResults(qidoStudies) {
|
|||||||
accession: getString(qidoStudy['00080050']) || '', // short string, probably a number?
|
accession: getString(qidoStudy['00080050']) || '', // short string, probably a number?
|
||||||
mrn: getString(qidoStudy['00100020']) || '', // medicalRecordNumber
|
mrn: getString(qidoStudy['00100020']) || '', // medicalRecordNumber
|
||||||
patientName: utils.formatPN(getName(qidoStudy['00100010'])) || '',
|
patientName: utils.formatPN(getName(qidoStudy['00100010'])) || '',
|
||||||
|
patientBirthDate: getString(qidoStudy['00100030']) || '', // YYYYMMDD
|
||||||
instances: Number(getString(qidoStudy['00201208'])) || 0, // number
|
instances: Number(getString(qidoStudy['00201208'])) || 0, // number
|
||||||
description: getString(qidoStudy['00081030']) || '',
|
description: getString(qidoStudy['00081030']) || '',
|
||||||
modalities: getString(getModalities(qidoStudy['00080060'], qidoStudy['00080061'])) || '',
|
modalities: getString(getModalities(qidoStudy['00080060'], qidoStudy['00080061'])) || '',
|
||||||
@ -153,6 +154,7 @@ function mapParams(params, options = {}) {
|
|||||||
'00081030', // Study Description
|
'00081030', // Study Description
|
||||||
'00080060', // Modality
|
'00080060', // Modality
|
||||||
'00080090', // Referring Physician's Name
|
'00080090', // Referring Physician's Name
|
||||||
|
'00100030', // Patient's Birth Date
|
||||||
// Add more fields here if you want them in the result
|
// Add more fields here if you want them in the result
|
||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user