Compare commits

..

No commits in common. "master" and "v3.13.0-beta.75" have entirely different histories.

849 changed files with 61643 additions and 74738 deletions

View File

@ -1,346 +0,0 @@
---
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** (01 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 (01) 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).

View File

@ -1,54 +0,0 @@
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 (01) 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);
});
});

View File

@ -1,72 +0,0 @@
# 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.

View File

@ -1,120 +0,0 @@
# 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.

View File

@ -1,148 +0,0 @@
# 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).

View File

@ -1,100 +0,0 @@
# 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.

View File

@ -1,30 +1,26 @@
version: 2.1 version: 2.1
# ci: re-trigger pipeline
orbs: orbs:
codecov: codecov/codecov@1.0.5 codecov: codecov/codecov@1.0.5
cypress: cypress-io/cypress@3.4.2 cypress: cypress-io/cypress@3.4.2
defaults: &defaults defaults: &defaults
docker: docker:
- image: cimg/node:24.15.0 - image: cimg/node:20.19.0
environment: environment:
TERM: xterm TERM: xterm
QUICK_BUILD: true QUICK_BUILD: true
working_directory: ~/repo working_directory: ~/repo
commands: commands:
install_pnpm: install_bun:
steps: steps:
- run: - run:
name: Install pnpm name: Install Bun
command: | command: |
# The cimg/node global modules dir (/usr/local/lib/node_modules) is curl -fsSL https://bun.sh/install | bash -s "bun-v1.2.23"
# root-owned, so a plain `npm install -g` fails with EACCES. Install echo 'export BUN_INSTALL="$HOME/.bun"' >> $BASH_ENV
# with sudo so the global pnpm binary lands in the shared prefix. echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> $BASH_ENV
sudo npm install -g pnpm@11.5.2
echo 'export PATH="$(pnpm store path)/../.bin:$PATH"' >> $BASH_ENV
source $BASH_ENV source $BASH_ENV
jobs: jobs:
@ -32,16 +28,16 @@ jobs:
<<: *defaults <<: *defaults
resource_class: large resource_class: large
steps: steps:
- install_pnpm - install_bun
- run: node --version - run: node --version
- checkout - checkout
- run: - run:
name: Install Dependencies name: Install Dependencies
command: pnpm install --frozen-lockfile command: bun install --no-save
# RUN TESTS # RUN TESTS
- run: - run:
name: 'JavaScript Test Suite' name: 'JavaScript Test Suite'
command: pnpm run test:unit:ci command: bun run test:unit:ci
# platform/app # platform/app
- run: - run:
name: 'VIEWER: Combine report output' name: 'VIEWER: Combine report output'
@ -75,17 +71,17 @@ jobs:
steps: steps:
# Checkout code and ALL Git Tags # Checkout code and ALL Git Tags
- checkout - checkout
- install_pnpm - install_bun
- run: - run:
name: Install Dependencies name: Install Dependencies
command: pnpm install --frozen-lockfile command: bun install --no-save
# Build & Test # Build & Test
- run: - run:
name: 'Perform the versioning before build' name: 'Perform the versioning before build'
command: node ./version.mjs command: bun ./version.mjs
- run: - run:
name: 'Build the OHIF Viewer' name: 'Build the OHIF Viewer'
command: pnpm run build command: bun run build
no_output_timeout: 45m no_output_timeout: 45m
- run: - run:
name: 'Upload SourceMaps, Send Deploy Notification' name: 'Upload SourceMaps, Send Deploy Notification'
@ -111,55 +107,14 @@ jobs:
<<: *defaults <<: *defaults
resource_class: large resource_class: large
steps: steps:
- install_pnpm - install_bun
# Checkout code and ALL Git Tags # Checkout code and ALL Git Tags
- checkout - checkout
- attach_workspace: - attach_workspace:
at: ~/repo at: ~/repo
# SECURITY AUDIT - only when pnpm-lock.yaml has changed
- run:
name: 'Security Audit - High Risk Vulnerabilities'
command: |
git fetch origin master 2>/dev/null || true
BASE_REF=$(git merge-base HEAD origin/master 2>/dev/null)
if [[ -z "$BASE_REF" ]]; then
echo "Could not determine base ref (e.g. shallow clone or no origin/master), skipping security audit."
exit 0
fi
CHANGED_FILES=$(git diff --name-only origin/master...HEAD 2>/dev/null || echo "")
if ! echo "$CHANGED_FILES" | grep -qx 'pnpm-lock.yaml'; then
echo "pnpm-lock.yaml unchanged - skipping security audit."
exit 0
fi
echo "pnpm-lock.yaml changed - running pnpm audit for security vulnerabilities..."
echo "Checking for HIGH-RISK vulnerabilities..."
if pnpm audit --audit-level high; then
echo "No high-risk vulnerabilities found"
echo "Security audit passed!"
else
echo ""
echo "HIGH-RISK VULNERABILITIES DETECTED!"
echo "======================================"
echo ""
echo "To fix these issues:"
echo " 1. Run: pnpm audit"
echo " 2. Review the vulnerability details"
echo " 3. Update affected packages to secure versions"
echo " 4. Test your changes"
echo " 5. Re-run: pnpm audit --audit-level high"
echo ""
echo "Full audit report:"
pnpm audit || true
echo ""
echo "This build cannot proceed until high-risk vulnerabilities are resolved."
exit 1
fi
- run: - run:
name: Install Dependencies name: Install Dependencies
command: pnpm install command: bun install --frozen-lockfile
- run: - run:
name: Avoid hosts unknown for github name: Avoid hosts unknown for github
command: | command: |
@ -170,34 +125,28 @@ jobs:
git config --global user.name "ohif-bot" git config --global user.name "ohif-bot"
- run: - run:
name: Authenticate with NPM registry name: Authenticate with NPM registry
# Write the auth token to npm's per-user config (~/.npmrc), not the command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
# repo root. publish-package.mjs chdir's into each package dir to run
# `npm publish`, and npm only reads the project .npmrc from that dir
# plus the user .npmrc from $HOME -- it never sees ~/repo/.npmrc, so a
# root-level token yields ENEEDAUTH. Using ~/.npmrc also avoids
# clobbering the committed workspace-config .npmrc (node-linker=hoisted).
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
- run: - run:
name: build half of the packages (to avoid out of memory in circleci) name: build half of the packages (to avoid out of memory in circleci)
command: | command: |
pnpm run build:package-all bun run build:package-all
- run: - run:
name: build the other half of the packages name: build the other half of the packages
command: | command: |
pnpm run build:package-all-1 bun run build:package-all-1
NPM_PUBLISH: NPM_PUBLISH:
<<: *defaults <<: *defaults
resource_class: large resource_class: large
steps: steps:
- install_pnpm - install_bun
# Checkout code and ALL Git Tags # Checkout code and ALL Git Tags
- checkout - checkout
- attach_workspace: - attach_workspace:
at: ~/repo at: ~/repo
- run: - run:
name: Install Dependencies name: Install Dependencies
command: pnpm install --frozen-lockfile command: bun install --no-save
- run: - run:
name: Avoid hosts unknown for github name: Avoid hosts unknown for github
command: | command: |
@ -208,21 +157,15 @@ jobs:
git config --global user.name "ohif-bot" git config --global user.name "ohif-bot"
- run: - run:
name: Authenticate with NPM registry name: Authenticate with NPM registry
# Write the auth token to npm's per-user config (~/.npmrc), not the command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
# repo root. publish-package.mjs chdir's into each package dir to run
# `npm publish`, and npm only reads the project .npmrc from that dir
# plus the user .npmrc from $HOME -- it never sees ~/repo/.npmrc, so a
# root-level token yields ENEEDAUTH. Using ~/.npmrc also avoids
# clobbering the committed workspace-config .npmrc (node-linker=hoisted).
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
- run: - run:
name: build half of the packages (to avoid out of memory in circleci) name: build half of the packages (to avoid out of memory in circleci)
command: | command: |
pnpm run build:package-all bun run build:package-all
- run: - run:
name: build the other half of the packages name: build the other half of the packages
command: | command: |
pnpm run build:package-all-1 bun run build:package-all-1
- run: - run:
name: increase min time out name: increase min time out
command: | command: |
@ -234,20 +177,14 @@ jobs:
- run: - run:
name: publish package versions name: publish package versions
command: | command: |
node ./publish-version.mjs bun ./publish-version.mjs
- run: - run:
name: Re-assert the NPM auth token before publishing name: Again set the NPM registry (was deleted in the version script)
# Write the auth token to npm's per-user config (~/.npmrc), not the command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
# repo root. publish-package.mjs chdir's into each package dir to run
# `npm publish`, and npm only reads the project .npmrc from that dir
# plus the user .npmrc from $HOME -- it never sees ~/repo/.npmrc, so a
# root-level token yields ENEEDAUTH. Using ~/.npmrc also avoids
# clobbering the committed workspace-config .npmrc (node-linker=hoisted).
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
- run: - run:
name: publish package dist name: publish package dist
command: | command: |
node ./publish-package.mjs bun ./publish-package.mjs
- persist_to_workspace: - persist_to_workspace:
root: ~/repo root: ~/repo
paths: paths:
@ -269,11 +206,8 @@ jobs:
if [[ ! -e version.txt ]]; then if [[ ! -e version.txt ]]; then
exit 0 exit 0
else else
# Restore the committed .npmrc (pnpm workspace config such as # Remove npm config
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish rm -f ./.npmrc
# auth token, but the Dockerfile COPYs .npmrc for the in-image
# pnpm install, so it needs the config file present and token-free.
git checkout -- .npmrc
# Set our version number using vars # Set our version number using vars
export IMAGE_VERSION=$(cat version.txt) export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION export IMAGE_VERSION_FULL=v$IMAGE_VERSION
@ -306,11 +240,8 @@ jobs:
if [[ ! -e version.txt ]]; then if [[ ! -e version.txt ]]; then
exit 0 exit 0
else else
# Restore the committed .npmrc (pnpm workspace config such as # Remove npm config
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish rm -f ./.npmrc
# auth token, but the Dockerfile COPYs .npmrc for the in-image
# pnpm install, so it needs the config file present and token-free.
git checkout -- .npmrc
# Set our version number using vars # Set our version number using vars
export IMAGE_VERSION=$(cat version.txt) export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION export IMAGE_VERSION_FULL=v$IMAGE_VERSION
@ -347,11 +278,7 @@ jobs:
exit 0 exit 0
else else
echo "Building and pushing Docker image from the master branch (beta releases)" echo "Building and pushing Docker image from the master branch (beta releases)"
# Restore the committed .npmrc (pnpm workspace config such as rm -f ./.npmrc
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish
# auth token, but the Dockerfile COPYs .npmrc for the in-image
# pnpm install, so it needs the config file present and token-free.
git checkout -- .npmrc
# Set our version number using vars # Set our version number using vars
export IMAGE_VERSION=$(cat version.txt) export IMAGE_VERSION=$(cat version.txt)
@ -385,11 +312,7 @@ jobs:
exit 0 exit 0
else else
echo "Building and pushing ARM64 Docker image from the master branch (beta releases)" echo "Building and pushing ARM64 Docker image from the master branch (beta releases)"
# Restore the committed .npmrc (pnpm workspace config such as rm -f ./.npmrc
# node-linker=hoisted). NPM_PUBLISH overwrote it with a publish
# auth token, but the Dockerfile COPYs .npmrc for the in-image
# pnpm install, so it needs the config file present and token-free.
git checkout -- .npmrc
# Set our version number using vars # Set our version number using vars
export IMAGE_VERSION=$(cat version.txt) export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION export IMAGE_VERSION_FULL=v$IMAGE_VERSION
@ -407,21 +330,12 @@ jobs:
resource_class: large resource_class: large
parallelism: 8 parallelism: 8
steps: steps:
- install_pnpm - install_bun
- run: - run:
name: Install System Dependencies name: Install System Dependencies
command: | command: |
# CircleCI's base image registers third-party apt sources (git-lfs via sudo apt-get update
# packagecloud, git-core PPA via launchpad) that periodically hang sudo apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6
# `apt-get update`. The Cypress libs below all come from the Ubuntu
# archive, so drop those sources and bound the update with timeouts +
# retries so a slow mirror can't stall the job indefinitely.
sudo rm -f /etc/apt/sources.list.d/*git* || true
sudo apt-get update \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=20 \
-o Acquire::https::Timeout=20
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 &
@ -430,15 +344,74 @@ jobs:
name: Export Display Variable name: Export Display Variable
command: export DISPLAY=:99 command: export DISPLAY=:99
- cypress/install: - cypress/install:
install-command: pnpm install install-command: yarn install --frozen-lockfile --no-save
- cypress/run-tests: - cypress/run-tests:
# CI runs headless under Xvfb with no GPU, so Electron uses software
# WebGL. Newer Chromium deprecated that implicit fallback (canvas
# rendering degrades and races with element clicks). ELECTRON_EXTRA_LAUNCH_ARGS
# is the reliable way to pass the opt-in flag to Cypress's Electron browser.
cypress-command: | cypress-command: |
npx wait-on@latest http://localhost:3000 && cd platform/app && ELECTRON_EXTRA_LAUNCH_ARGS="--enable-unsafe-swiftshader" npx cypress run --record --parallel npx wait-on@latest http://localhost:3000 && cd platform/app && npx cypress run --record --parallel
start-command: pnpm run test:data && pnpm run test:e2e:serve start-command: yarn run test:data && yarn run test:e2e:serve
SECURITY_AUDIT:
<<: *defaults
resource_class: large
steps:
- install_bun
- checkout
- run:
name: 'Security Audit - High Risk Vulnerabilities'
command: |
git fetch origin master 2>/dev/null || true
BASE_REF=$(git merge-base HEAD origin/master 2>/dev/null)
if [[ -z "$BASE_REF" ]]; then
echo "Could not determine base ref (e.g. shallow clone or no origin/master), skipping security audit."
exit 0
fi
CHANGED_FILES=$(git diff --name-only origin/master...HEAD 2>/dev/null || echo "")
if ! echo "$CHANGED_FILES" | grep -qx 'bun.lock'; then
echo "⏭️ bun.lock unchanged - skipping security audit."
exit 0
fi
echo "🔍 bun.lock changed - running bun audit for security vulnerabilities..."
echo "Checking for HIGH-RISK vulnerabilities..."
# Define ignored vulnerabilities with comments
IGNORED_VULNS=(
"GHSA-3ppc-4f35-3m26" # CVE-2026-26996 - minimatch via itk-wasm and glob is safe because it does NOT use the CLI
# CVE-2026-26996 - minimatch via other packages are strictly for building and CI/CD purposes; no user supplied expressions are passed to minimatch
"GHSA-7r86-cg39-jmmj" # CVE-2026-27903 - minimatch same as above
"GHSA-23c5-xmqv-rm74" # CVE-2026-27904 - minimatch same as above
"GHSA-c2c7-rcm5-vvqj" # CVE-2026-33671 - picomatch is generally used for development and CI/CD purposes
)
# Build ignore flags
IGNORE_FLAGS=""
for vuln in "${IGNORED_VULNS[@]}"; do
IGNORE_FLAGS="$IGNORE_FLAGS --ignore=$vuln"
done
if bun audit $IGNORE_FLAGS --audit-level high; then
echo "✅ No high-risk vulnerabilities found"
echo "🎉 Security audit passed!"
exit 0
else
echo ""
echo "❌ HIGH-RISK VULNERABILITIES DETECTED!"
echo "======================================"
echo ""
echo "🔧 To fix these issues:"
echo " 1. Run: bun audit"
echo " 2. Review the vulnerability details"
echo " 3. Update affected packages to secure versions"
echo " 4. Test your changes"
echo " 5. Re-run: bun audit --audit-level high"
echo ""
echo "📋 Full audit report:"
bun audit $IGNORE_FLAGS --audit-level low || true
echo ""
echo "❌ This build cannot proceed until high-risk vulnerabilities are resolved."
exit 1
fi
DOCKER_MULTIARCH_MANIFEST: DOCKER_MULTIARCH_MANIFEST:
<<: *defaults <<: *defaults
@ -529,6 +502,10 @@ workflows:
- CYPRESS: - CYPRESS:
name: 'Cypress Tests' name: 'Cypress Tests'
context: cypress context: cypress
- SECURITY_AUDIT:
filters:
branches:
ignore: master
# viewer-dev.ohif.org # viewer-dev.ohif.org
DEPLOY_MASTER: DEPLOY_MASTER:

View File

@ -1,8 +1,18 @@
version: 2 version: 2
enable-beta-ecosystems: true enable-beta-ecosystems: true
updates: updates:
- package-ecosystem: 'npm' - package-ecosystem: 'bun'
# Disable all pull requests for npm/pnpm version updates. # Disable all pull requests for bun version updates.
open-pull-requests-limit: 0
directory: '/'
schedule:
interval: 'daily'
labels: ['dependencies']
commit-message:
prefix: 'chore'
include: 'scope'
- package-ecosystem: 'npm'
# Disable all pull requests for npm version updates.
open-pull-requests-limit: 0 open-pull-requests-limit: 0
directory: '/' directory: '/'
schedule: schedule:

View File

@ -21,29 +21,16 @@ jobs:
contents: read contents: read
pull-requests: read pull-requests: read
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with: with:
persist-credentials: false bun-version: 1.2.23
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24.15.0 node-version: 20 # Or your desired Node version
cache: pnpm
- name: Configure git for private repos - name: Install root dependencies
run: git config --global url."https://${GITHUB_TOKEN}:x-oauth-basic@github.com/".insteadOf ssh://git@github.com/ run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
# Internal @ohif/* deps are workspace:* in the committed manifests and the
# lockfile records them as links, so pnpm-lock.yaml stays in sync across
# version bumps and a frozen install passes. Frozen is the right choice
# for a deploy job: it fails fast if a PR ever changed a real dependency
# without committing the lockfile update, instead of silently reconciling.
# (Exact versions appear only in published tarballs, where pnpm publish
# rewrites workspace:* at pack time -- not publish-version.mjs.)
run: pnpm install --frozen-lockfile
# Removed Playwright tests and coverage generation steps # Removed Playwright tests and coverage generation steps
@ -57,44 +44,47 @@ jobs:
# Note: This relies on the merge commit being directly pushed to main # Note: This relies on the merge commit being directly pushed to main
PR_DATA=$(gh pr list --state merged --search "$MERGE_COMMIT_SHA" --json number,headRefOid --jq '.[0]') PR_DATA=$(gh pr list --state merged --search "$MERGE_COMMIT_SHA" --json number,headRefOid --jq '.[0]')
if [ -z "$PR_DATA" ]; then if [ -z "$PR_DATA" ]; then
echo "::warning::Could not find merged PR for commit $MERGE_COMMIT_SHA. Skipping coverage embedding." echo "Could not find merged PR for commit $MERGE_COMMIT_SHA."
echo "coverage_found=false" >> $GITHUB_OUTPUT # Decide how to handle - fail, or maybe generate coverage now?
exit 0 # For now, let's fail.
exit 1
fi fi
PR_HEAD_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid') PR_HEAD_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid')
PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number') PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number')
echo "Found PR Number: $PR_NUMBER" echo "Found PR Number: $PR_NUMBER"
echo "Found PR Head SHA: $PR_HEAD_SHA" echo "Found PR Head SHA: $PR_HEAD_SHA"
# Find the latest *successful* playwright.yml run for the PR head commit so we # Find the latest workflow run ID for the playwright workflow on the PR head commit
# never embed coverage from a failed/incomplete run. # Uses the workflow file name 'playwright.yml'
RUN_ID=$(gh run list --workflow playwright.yml --commit "$PR_HEAD_SHA" --event pull_request --status success --json databaseId --jq '.[0].databaseId') # Remove the --status success flag to find any run
RUN_ID=$(gh run list --workflow playwright.yml --commit "$PR_HEAD_SHA" --event pull_request --json databaseId --jq '.[0].databaseId')
if [ -z "$RUN_ID" ]; then if [ -z "$RUN_ID" ]; then
echo "::warning::Could not find a successful 'playwright.yml' run for PR $PR_NUMBER (Head SHA: $PR_HEAD_SHA). Skipping coverage embedding." echo "Could not find any 'playwright.yml' run for PR $PR_NUMBER (Head SHA: $PR_HEAD_SHA)."
echo "coverage_found=false" >> $GITHUB_OUTPUT # Decide how to handle - maybe try finding the artifact from the merge commit run if that exists?
exit 0 # For now, let's fail.
exit 1
fi fi
echo "Found Run ID: $RUN_ID" echo "Found Run ID: $RUN_ID"
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
echo "coverage_found=true" >> $GITHUB_OUTPUT
- name: Download coverage artifact from PR run - name: Download coverage artifact from PR run
if: steps.find_pr_run.outputs.coverage_found == 'true'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
mkdir -p ./coverage-artifact mkdir -p ./coverage-artifact
gh run download ${{ steps.find_pr_run.outputs.run_id }} -n coverage-report-pr --dir ./coverage-artifact gh run download ${{ steps.find_pr_run.outputs.run_id }} -n coverage-report-pr --dir ./coverage-artifact
# Verify the artifact contains an HTML coverage report rather than checking a single asset # Check if download was successful (e.g., check if files exist)
if [ -z "$(ls -A ./coverage-artifact)" ] || ! ls ./coverage-artifact/*.html >/dev/null 2>&1; then if [ ! -f ./coverage-artifact/base.css ]; then
echo "Failed to download or find an HTML coverage report in artifact 'coverage-report-pr' from run ${{ steps.find_pr_run.outputs.run_id }}." echo "Failed to download or find expected files in artifact 'coverage-report-pr' from run ${{ steps.find_pr_run.outputs.run_id }}."
exit 1 exit 1
fi fi
echo "Artifact downloaded successfully." echo "Artifact downloaded successfully."
- name: Install docs dependencies
run: cd platform/docs && bun install
- name: Copy coverage to docs static directory - name: Copy coverage to docs static directory
if: steps.find_pr_run.outputs.coverage_found == 'true'
run: | run: |
# Copy files from the downloaded artifact directory # Copy files from the downloaded artifact directory
mkdir -p platform/docs/static/coverage mkdir -p platform/docs/static/coverage
@ -109,17 +99,12 @@ jobs:
cp ./coverage-artifact/sorter.js platform/docs/static/ cp ./coverage-artifact/sorter.js platform/docs/static/
- name: Build docs - name: Build docs
run: pnpm --filter ohif-docs run build run: cd platform/docs && bun run build
- name: Deploy to Netlify - name: Deploy to Netlify
# The docs are already built by the "Build docs" step above, so pass run: |
# --no-build: `netlify deploy` runs the build command by default, which cd platform/docs
# would otherwise pick up the repo-root netlify.toml (configured for the npx netlify-cli deploy --dir=./build --prod
# 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 }}

View File

@ -55,9 +55,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@v4
with:
persist-credentials: false
# Add any setup steps before running the `github/codeql-action/init` action. # Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node` # This includes steps like installing compilers or runtimes (`actions/setup-node`
@ -67,7 +65,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3.36.2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@ -95,6 +93,6 @@ jobs:
exit 1 exit 1
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3.36.2 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@ -23,36 +23,21 @@ concurrency:
jobs: jobs:
playwright-tests: playwright-tests:
timeout-minutes: 120 timeout-minutes: 120
environment: fork-pr-approval runs-on: self-hosted
runs-on: [self-hosted, nashua]
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
node-version: [24.15.0] node-version: [20]
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with: with:
persist-credentials: false bun-version: 1.2.23
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
# No `cache: pnpm`: this is a self-hosted runner with a persistent - name: Install Yarn
# pnpm store on local disk. The Actions cache doesn't preserve the run: npm install -g yarn@1.22.22
# pnpm self-install's links faithfully, dropping pnpm/dist/worker.js
# and breaking `pnpm install` with MODULE_NOT_FOUND.
# Install pnpm via Corepack instead of pnpm/action-setup. The action's
# self-installer is a JS action run under the runner's bundled
# externals/node{version} and derives the `npm` path from that runtime — which
# on this self-hosted runner is corrupted (Cannot find module
# '../lib/cli.js'), so it dies regardless of `standalone:true`. Corepack
# ships inside the Node that setup-node just installed, reads the pinned
# `packageManager` (pnpm@11.5.2) from package.json, and fetches pnpm via
# Node's own https — it never invokes the npm CLI.
- name: Enable Corepack (pnpm)
shell: bash
run: |
corepack enable
corepack prepare --activate
# ── CS3D integration: detect label and ref type ────────────────────── # ── CS3D integration: detect label and ref type ──────────────────────
- name: Check for CS3D integration label - name: Check for CS3D integration label
@ -102,11 +87,11 @@ jobs:
- name: Install & Build CS3D - name: Install & Build CS3D
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch' if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch'
working-directory: libs/@cornerstonejs working-directory: libs/@cornerstonejs
run: pnpm install --frozen-lockfile && pnpm run build:esm run: bun install --frozen-lockfile && bun run build:esm
# ── Common: install OHIF dependencies ─────────────────────────────── # ── Common: install OHIF dependencies ───────────────────────────────
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: bun install --frozen-lockfile
# ── CS3D branch path: link packages after OHIF install ────────────── # ── CS3D branch path: link packages after OHIF install ──────────────
- name: Link CS3D packages - name: Link CS3D packages
@ -119,22 +104,17 @@ jobs:
if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'version' if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'version'
run: | run: |
node .scripts/cs3d-set-version.mjs "${CS3D_VERSION}" node .scripts/cs3d-set-version.mjs "${CS3D_VERSION}"
pnpm install --no-frozen-lockfile bun install --config=./bunfig.update-lockfile.toml
env: env:
CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }} CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }}
# ── Common: run tests ─────────────────────────────────────────────── # ── Common: run tests ───────────────────────────────────────────────
- name: Install Playwright browsers - name: Install Playwright browsers
# Only chromium is used (see playwright.config.ts and tests/globalSetup.ts); run: npx playwright install
# firefox/webkit projects are commented out. Scoping the install to chromium
# avoids downloading + dep-validating browsers we never launch (the WebKit
# validation is what surfaces the "missing libwoff1/libflite1/..." error on
# hosts without those libs).
run: npx playwright install chromium
- 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"
bash .scripts/ci/with-nashua-lock.sh pnpm run test:e2e:coverage bun 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
@ -142,20 +122,20 @@ jobs:
run: | run: |
mkdir -p packaged-test-results mkdir -p packaged-test-results
cp -r ./tests/test-results packaged-test-results/ || true cp -r ./tests/test-results packaged-test-results/ || true
cp -r ./tests/playwright-report packaged-test-results/ || true cp ./tests/playwright-report.json packaged-test-results/ || true
- name: Upload directory of test results artifact - name: Upload directory of test results artifact
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@v4
with: with:
name: playwright-results name: playwright-results
path: packaged-test-results/ path: packaged-test-results/
retention-days: 5 retention-days: 5
- name: create the coverage report - name: create the coverage report
run: | run: |
pnpm exec nyc report --reporter=lcov --reporter=text bun nyc report --reporter=lcov --reporter=text
- name: Upload the coverage report to GitHub Actions Artifacts - name: Upload the coverage report to GitHub Actions Artifacts
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@v4
with: with:
name: coverage-report-pr name: coverage-report-pr
path: coverage path: coverage
@ -172,13 +152,12 @@ jobs:
fi fi
node .scripts/log-build-context.mjs node .scripts/log-build-context.mjs
env: env:
BUILD_TYPE: BUILD_TYPE: ${{ steps.cs3d-ref.outputs.type == 'branch' && 'ohif-downstream' || 'ohif-upstream' }}
${{ steps.cs3d-ref.outputs.type == 'branch' && 'ohif-downstream' || 'ohif-upstream' }}
CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }} CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }}
- name: Build OHIF viewer (CS3D preview) - name: Build OHIF viewer (CS3D preview)
if: steps.cs3d-check.outputs.enabled == 'true' if: steps.cs3d-check.outputs.enabled == 'true'
run: pnpm run build:ci run: bun run build:ci
- name: Deploy CS3D preview to Netlify - name: Deploy CS3D preview to Netlify
if: steps.cs3d-check.outputs.enabled == 'true' if: steps.cs3d-check.outputs.enabled == 'true'
@ -215,13 +194,11 @@ jobs:
# ── Separate job: block merge when using a CS3D branch ───────────── # ── Separate job: block merge when using a CS3D branch ─────────────
cs3d-branch-merge-guard: cs3d-branch-merge-guard:
name: 'CS3D Branch Merge Guard' name: "CS3D Branch Merge Guard"
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: Check for CS3D branch usage - name: Check for CS3D branch usage
run: bash .scripts/ci/cs3d-branch-merge-guard.sh run: bash .scripts/ci/cs3d-branch-merge-guard.sh
env: env:

3
.gitignore vendored
View File

@ -1,7 +1,6 @@
# Packages # Packages
node_modules node_modules
.cursor/ .cursor/
.claude/
.nyc_output/ .nyc_output/
# Output # Output
build build
@ -61,6 +60,8 @@ tests/playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
**/.claude/settings.local.json
# cornerstone3D local linking # cornerstone3D local linking
libs/ libs/

View File

@ -6,21 +6,27 @@ cd "$(dirname "$0")"
cd .. # Up to project root cd .. # Up to project root
# Helpful to verify which versions we're using # Helpful to verify which versions we're using
echo 'My pnpm version is... ' echo 'My yarn version is... '
pnpm -v yarn -v
node -v node -v
# Build && Move PWA Output # Build && Move PWA Output
pnpm run build:ci yarn run build:ci
mkdir -p ./.netlify/www/pwa mkdir -p ./.netlify/www/pwa
mv platform/app/dist/* .netlify/www/pwa -v mv platform/app/dist/* .netlify/www/pwa -v
echo 'Web application built and copied' echo 'Web application built and copied'
# Build && Move Docusaurus Output (for the docs themselves) # Build && Move Docusaurus Output (for the docs themselves)
pnpm --filter ohif-docs run build cd platform/docs
yarn install --frozen-lockfile
yarn run build
cd ../..
mkdir -p ./.netlify/www/docs mkdir -p ./.netlify/www/docs
mv platform/docs/build/* .netlify/www/docs -v mv platform/docs/build/* .netlify/www/docs -v
echo 'Docs built (docusaurus) and copied' echo 'Docs built (docusaurus) and copied'
# Cache all of the node_module dependencies in
# extensions, modules, and platform packages
yarn run lerna:cache
echo 'Nothing left to see here. Go home, folks.' echo 'Nothing left to see here. Go home, folks.'

View File

@ -2,8 +2,9 @@
"name": "root", "name": "root",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=24", "node": ">=14",
"pnpm": ">=11" "npm": ">=6",
"yarn": ">=1.16.0"
}, },
"scripts": { "scripts": {
"deploy": "netlify deploy --prod --dir ./../platform/app/dist" "deploy": "netlify deploy --prod --dir ./../platform/app/dist"

View File

@ -1 +1 @@
24.15.0 20.9.0

5
.npmrc
View File

@ -1,5 +0,0 @@
node-linker=hoisted
strict-peer-dependencies=false
link-workspace-packages=true
prefer-workspace-packages=true
manage-package-manager-versions=false

View File

@ -1,54 +0,0 @@
#!/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 "$@"

View File

@ -6,9 +6,9 @@ PROJECT=$1
if [ -z "$PROJECT" ] if [ -z "$PROJECT" ]
then then
# Default # Default
pnpm --filter @ohif/app run dev:viewer npx lerna run dev:viewer
else else
pnpm --filter @ohif/app run "dev:$PROJECT" eval "npx lerna run dev:$PROJECT"
fi fi
read -p 'Press [Enter] key to continue...' read -p 'Press [Enter] key to continue...'

View File

@ -1,51 +0,0 @@
const path = require('path');
// Shared module-resolution rules (alias + module search paths) used by BOTH
// build pipelines so they cannot drift apart:
// - the webpack/rspack build: webpack.base.js -> webpack.pwa.js, plus every
// per-package webpack.prod.js / webpack.dev.js that merges webpack.base.js
// - the rsbuild build: rsbuild.config.ts (dev:fast)
//
// Plugin aliases (writePluginImportsFile.getPluginResolveAliases) are NOT
// included here: they depend on pluginConfig.json and are merged in separately
// by each config.
//
// All paths are anchored to this file's location (the repo-root `.webpack/`
// directory), so the values are identical regardless of which package's build
// is doing the resolving.
const alias = {
// Some extensions import app-level utilities (e.g. history,
// preserveQueryParameters) from '@ohif/app'. pnpm's isolated layout does not
// expose the top-level app package to those extensions, and adding it as a
// workspace dependency would create an app<->default cycle, so we resolve the
// bare specifier to the app source here ($ = exact match).
'@ohif/app$': path.resolve(__dirname, '../platform/app/src/index.js'),
'@': path.resolve(__dirname, '../platform/app/src'),
'@components': path.resolve(__dirname, '../platform/app/src/components'),
'@hooks': path.resolve(__dirname, '../platform/app/src/hooks'),
'@routes': path.resolve(__dirname, '../platform/app/src/routes'),
'@state': path.resolve(__dirname, '../platform/app/src/state'),
};
// Directories to search when resolving modules. The leading bare 'node_modules'
// preserves the default importer-relative walk-up, which pnpm's isolated layout
// requires so that transitive deps (e.g. react-remove-scroll -> tslib 2.x)
// resolve to the sibling copy inside .pnpm/<pkg>/node_modules rather than a
// hoisted older version.
const moduleSearchPaths = [
'node_modules',
path.resolve(__dirname, '../node_modules'),
path.resolve(__dirname, '../../../node_modules'),
path.resolve(__dirname, '../platform/app/node_modules'),
path.resolve(__dirname, '../platform/ui/node_modules'),
];
// Build the resolve.modules array for a build. `srcDir` is the building
// package's source root; it is appended last (matching the previous inline
// behavior in webpack.base.js). Pass nothing to get just the shared paths.
function getModules(srcDir) {
return srcDir ? [...moduleSearchPaths, srcDir] : [...moduleSearchPaths];
}
module.exports = { alias, moduleSearchPaths, getModules };

View File

@ -2,14 +2,14 @@ const autoprefixer = require('autoprefixer');
const path = require('path'); const path = require('path');
const tailwindcss = require('tailwindcss'); const tailwindcss = require('tailwindcss');
const tailwindConfigPath = path.resolve('../../platform/app/tailwind.config.js'); const tailwindConfigPath = path.resolve('../../platform/app/tailwind.config.js');
const rspack = require('@rspack/core'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const devMode = process.env.NODE_ENV !== 'production'; const devMode = process.env.NODE_ENV !== 'production';
const cssToJavaScript = { const cssToJavaScript = {
test: /\.css$/, test: /\.css$/,
use: [ use: [
//'style-loader', //'style-loader',
devMode ? 'style-loader' : rspack.CssExtractRspackPlugin.loader, devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
{ loader: 'css-loader', options: { importLoaders: 1 } }, { loader: 'css-loader', options: { importLoaders: 1 } },
{ {
loader: 'postcss-loader', loader: 'postcss-loader',

View File

@ -4,8 +4,6 @@ function transpileJavaScript(mode) {
const exclude = const exclude =
mode === 'production' mode === 'production'
? excludeNodeModulesExcept([ ? excludeNodeModulesExcept([
// Workspace packages (needed for pnpm shamefully-hoist where they resolve through node_modules)
'@ohif',
// 'dicomweb-client', // 'dicomweb-client',
// https://github.com/react-dnd/react-dnd/blob/master/babel.config.js // https://github.com/react-dnd/react-dnd/blob/master/babel.config.js
'react-dnd', 'react-dnd',

View File

@ -4,10 +4,11 @@ const dotenv = require('dotenv');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const webpack = require('@rspack/core'); const webpack = require('webpack');
// ~~ PLUGINS // ~~ PLUGINS
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const TerserJSPlugin = require('terser-webpack-plugin');
// ~~ PackageJSON // ~~ PackageJSON
// const vtkRules = require('vtk.js/Utilities/config/dependency.js').webpack.core // const vtkRules = require('vtk.js/Utilities/config/dependency.js').webpack.core
@ -17,15 +18,9 @@ const webpack = require('@rspack/core');
const loadWebWorkersRule = require('./rules/loadWebWorkers.js'); const loadWebWorkersRule = require('./rules/loadWebWorkers.js');
const transpileJavaScriptRule = require('./rules/transpileJavaScript.js'); const transpileJavaScriptRule = require('./rules/transpileJavaScript.js');
const cssToJavaScript = require('./rules/cssToJavaScript.js'); const cssToJavaScript = require('./rules/cssToJavaScript.js');
// Module-resolution rules shared with the rsbuild build (see rsbuild.config.ts).
const resolveConfig = require('./resolveConfig');
// Only uncomment for old v2 stylus // Only uncomment for old v2 stylus
// const stylusToJavaScript = require('./rules/stylusToJavaScript.js'); // const stylusToJavaScript = require('./rules/stylusToJavaScript.js');
let ReactRefreshWebpackPlugin; const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
try {
const mod = require('@rspack/plugin-react-refresh');
ReactRefreshWebpackPlugin = mod.ReactRefreshRspackPlugin || mod.default || mod;
} catch { ReactRefreshWebpackPlugin = null; }
// ~~ ENV VARS // ~~ ENV VARS
const NODE_ENV = process.env.NODE_ENV; const NODE_ENV = process.env.NODE_ENV;
@ -71,12 +66,6 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
const config = { const config = {
mode: isProdBuild ? 'production' : 'development', mode: isProdBuild ? 'production' : 'development',
devtool: isProdBuild ? 'source-map' : 'cheap-module-source-map', devtool: isProdBuild ? 'source-map' : 'cheap-module-source-map',
// `rspack serve` (@rspack/cli) auto-enables lazyCompilation for web-only
// apps unless the config defines it explicitly. The on-demand proxy chunks
// it produces fail to load in the headless cypress/electron e2e run
// (ChunkLoadError on cornerstone vendor chunks), so disable it here to match
// the rsbuild build (see rsbuild.config.ts `dev.lazyCompilation: false`).
lazyCompilation: false,
entry: ENTRY, entry: ENTRY,
optimization: { optimization: {
// splitChunks: { // splitChunks: {
@ -103,7 +92,9 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
children: false, children: false,
warnings: true, warnings: true,
}, },
cache: isProdBuild ? false : { type: 'filesystem' }, cache: {
type: 'filesystem',
},
module: { module: {
noParse: [/(dicomicc)/], noParse: [/(dicomicc)/],
rules: [ rules: [
@ -177,6 +168,9 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
}, },
}, },
cssToJavaScript, cssToJavaScript,
// Note: Only uncomment the following if you are using the old style of stylus in v2
// Also you need to uncomment this platform/app/.webpack/rules/extractStyleChunks.js
// stylusToJavaScript,
{ {
test: /\.wasm/, test: /\.wasm/,
type: 'asset/resource', type: 'asset/resource',
@ -200,16 +194,27 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
}, },
resolve: { resolve: {
mainFields: ['module', 'browser', 'main'], mainFields: ['module', 'browser', 'main'],
// alias and modules are shared with the rsbuild build via ./resolveConfig
// so the two pipelines resolve identically.
alias: { alias: {
...resolveConfig.alias, // Viewer project
'@': path.resolve(__dirname, '../platform/app/src'),
'@components': path.resolve(__dirname, '../platform/app/src/components'),
'@hooks': path.resolve(__dirname, '../platform/app/src/hooks'),
'@routes': path.resolve(__dirname, '../platform/app/src/routes'),
'@state': path.resolve(__dirname, '../platform/app/src/state'),
}, },
modules: resolveConfig.getModules(SRC_DIR), // Which directories to search when resolving modules
modules: [
// Modules specific to this package
path.resolve(__dirname, '../node_modules'),
// Hoisted Yarn Workspace Modules
path.resolve(__dirname, '../../../node_modules'),
path.resolve(__dirname, '../platform/app/node_modules'),
path.resolve(__dirname, '../platform/ui/node_modules'),
SRC_DIR,
],
// Attempt to resolve these extensions in order. // Attempt to resolve these extensions in order.
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '*'], extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '*'],
// Workspace packages use relative imports between sibling packages. // symlinked resources are resolved to their real path, not their symlinked location
// Resolve symlinks to keep those imports anchored at the real package paths.
symlinks: true, symlinks: true,
fallback: { fallback: {
fs: false, fs: false,
@ -218,34 +223,24 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
buffer: require.resolve('buffer'), buffer: require.resolve('buffer'),
}, },
}, },
node: {
// Leave __filename / __dirname references alone. The previous 'mock'
// value triggers an rspack warning whenever bundled deps reference
// __dirname (e.g. Emscripten-compiled cornerstone codecs). Those refs
// sit inside `if (ENVIRONMENT_IS_NODE)` branches that never execute in
// the browser, so leaving them un-substituted is harmless at runtime.
__filename: false,
__dirname: false,
},
plugins: [ plugins: [
new webpack.DefinePlugin(defineValues), new webpack.DefinePlugin(defineValues),
new webpack.ProvidePlugin({ new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'], Buffer: ['buffer', 'Buffer'],
}), }),
new webpack.IgnorePlugin({ ...(isProdBuild ? [] : [new ReactRefreshWebpackPlugin({ overlay: false })]),
resourceRegExp: /^(fs|path)$/,
contextRegExp: /@cornerstonejs[\\/]codec-/,
}),
...(isProdBuild || IS_COVERAGE || !ReactRefreshWebpackPlugin
? []
: [new ReactRefreshWebpackPlugin({ overlay: false })]),
// Uncomment to generate bundle analyzer // Uncomment to generate bundle analyzer
// new BundleAnalyzerPlugin(), // new BundleAnalyzerPlugin(),
], ],
}; };
if (isProdBuild) { if (isProdBuild) {
config.optimization.minimizer = [new webpack.SwcJsMinimizerRspackPlugin()]; config.optimization.minimizer = [
new TerserJSPlugin({
parallel: true,
terserOptions: {},
}),
];
} }
if (isQuickBuild) { if (isQuickBuild) {

View File

@ -176,10 +176,6 @@ 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

View File

@ -3,145 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.13.0-beta.89](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.88...v3.13.0-beta.89) (2026-06-10)
### Bug Fixes
* ohif tests to run with cornerstone 3d 5.0 ([#6043](https://github.com/OHIF/Viewers/issues/6043)) ([6dd150d](https://github.com/OHIF/Viewers/commit/6dd150d401ad73d60632a23378b7dfd4b5142690))
# [3.13.0-beta.88](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.87...v3.13.0-beta.88) (2026-06-05)
### Bug Fixes
* **security:** Patch axios vulnerabilities. ([#6063](https://github.com/OHIF/Viewers/issues/6063)) ([99c6154](https://github.com/OHIF/Viewers/commit/99c615434309b113aed9ac9a977419eeae0528cb))
# [3.13.0-beta.87](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.86...v3.13.0-beta.87) (2026-05-29)
**Note:** Version bump only for package ohif-monorepo-root
# [3.13.0-beta.86](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.85...v3.13.0-beta.86) (2026-05-28)
**Note:** Version bump only for package ohif-monorepo-root
# [3.13.0-beta.85](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.84...v3.13.0-beta.85) (2026-05-28)
### Features
* **WorkList:** New Study List (WorkList based on ui-next); old study list renamed to LegacyWorklist ([#6005](https://github.com/OHIF/Viewers/issues/6005)) ([daae4c1](https://github.com/OHIF/Viewers/commit/daae4c144e7cb80bbee7c05d9fceef8b332ed2a0))
# [3.13.0-beta.84](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.83...v3.13.0-beta.84) (2026-05-27)
### Bug Fixes
* **security:** Add tmp as a resolution to fix security vulnerability. ([#6044](https://github.com/OHIF/Viewers/issues/6044)) ([bdcafc1](https://github.com/OHIF/Viewers/commit/bdcafc12de5e4f73a1a96cab9c0830d49315c044))
# [3.13.0-beta.83](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.82...v3.13.0-beta.83) (2026-05-26)
### Bug Fixes
* **segmentation:** restore navigation for duplicated contour segments ([#6038](https://github.com/OHIF/Viewers/issues/6038)) ([0c1ca6b](https://github.com/OHIF/Viewers/commit/0c1ca6b04a6c1aef478c7a7d9f360f9b9c50f188))
# [3.13.0-beta.82](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.81...v3.13.0-beta.82) (2026-05-22)
**Note:** Version bump only for package ohif-monorepo-root
# [3.13.0-beta.81](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.80...v3.13.0-beta.81) (2026-05-21)
### Features
* **tests:** Add tests for duplicating contour segments ([#6002](https://github.com/OHIF/Viewers/issues/6002)) ([2258c79](https://github.com/OHIF/Viewers/commit/2258c7957a18ed0f1a75d2bd5d379006797b2882))
# [3.13.0-beta.80](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.79...v3.13.0-beta.80) (2026-05-20)
**Note:** Version bump only for package ohif-monorepo-root
# [3.13.0-beta.79](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.78...v3.13.0-beta.79) (2026-05-19)
### Bug Fixes
* **seg:** prevent viewport orientation change when loading SEG in manual grid layout ([#6021](https://github.com/OHIF/Viewers/issues/6021)) ([95251ab](https://github.com/OHIF/Viewers/commit/95251ab6709934e4ef59931379f8d9d7629f87b2))
# [3.13.0-beta.78](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.77...v3.13.0-beta.78) (2026-05-19)
### Bug Fixes
* **measurements:** read displayName in SplineROI and PlanarFreehandROI reports ([#5908](https://github.com/OHIF/Viewers/issues/5908)) ([27af682](https://github.com/OHIF/Viewers/commit/27af6821b56f2c5be3a5d4ce38d05be6fbce68f4))
# [3.13.0-beta.77](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.76...v3.13.0-beta.77) (2026-05-19)
### Bug Fixes
* **segmentation:** remove thresholdRange label in double-range tool settings ([#5931](https://github.com/OHIF/Viewers/issues/5931)) ([527cfb0](https://github.com/OHIF/Viewers/commit/527cfb0ae8c615aeaddbc7f16892ba4460a5b596))
# [3.13.0-beta.76](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.75...v3.13.0-beta.76) (2026-05-19)
**Note:** Version bump only for package ohif-monorepo-root
# [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18) # [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18)
**Note:** Version bump only for package ohif-monorepo-root **Note:** Version bump only for package ohif-monorepo-root

View File

@ -20,33 +20,49 @@
# #
# syntax=docker/dockerfile:1.7-labs
# This dockerfile is used to publish the `ohif/app` image on dockerhub.
#
# It's a good example of how to build our static application and package it
# with a web server capable of hosting it as static content.
#
# docker build
# --------------
# If you would like to use this dockerfile to build and tag an image, make sure
# you set the context to the project's root directory:
# https://docs.docker.com/engine/reference/commandline/build/
#
#
# SUMMARY
# --------------
# This dockerfile is used as an input for a second stage to make things run faster.
#
# 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.15.0-slim as builder FROM node:20.18.1-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 build-essential python3
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@11
RUN mkdir /usr/src/app RUN mkdir /usr/src/app
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN npm install -g bun@1.2.23
RUN npm install -g lerna@7.4.2
ENV PATH=/usr/src/app/node_modules/.bin:$PATH ENV PATH=/usr/src/app/node_modules/.bin:$PATH
# Copy package manifests for install caching. preinstall.js is included because # Do an initial install and then a final install
# the root package.json's "preinstall" lifecycle script (node preinstall.js) COPY package.json yarn.lock preinstall.js lerna.json ./
# runs during `pnpm install` below -- before the full source is copied -- so the COPY --parents ./addOns/package.json ./addOns/*/*/package.json ./extensions/*/package.json ./modes/*/package.json ./platform/*/package.json ./
# script file must already be present or install fails with MODULE_NOT_FOUND. # Run the install before copying the rest of the files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc preinstall.js ./
COPY --parents ./extensions/*/package.json ./modes/*/package.json ./platform/*/package.json ./ RUN bun pm cache rm
# Run the install before copying the rest of the files. RUN bun install
# Keep --no-frozen-lockfile here (unlike CI): .dockerignore excludes RUN bun add ajv@8.12.0
# platform/docs, so the lockfile's docs importer has no manifest in the build
# context and a frozen install would fail. pnpm reconciles (drops docs) instead.
RUN pnpm install --no-frozen-lockfile
# Copy the local directory # Copy the local directory
COPY --link --exclude=pnpm-lock.yaml --exclude=package.json --exclude=Dockerfile . . COPY --link --exclude=yarn.lock --exclude=package.json --exclude=Dockerfile . .
# Build here # Build here
# After install it should hopefully be stable until the local directory changes # After install it should hopefully be stable until the local directory changes
@ -56,14 +72,14 @@ ARG APP_CONFIG=config/default.js
ARG PUBLIC_URL=/ ARG PUBLIC_URL=/
ENV PUBLIC_URL=${PUBLIC_URL} ENV PUBLIC_URL=${PUBLIC_URL}
RUN pnpm run show:config RUN bun run show:config
RUN pnpm run build RUN bun run build
# Precompress files # Precompress files
RUN chmod u+x .docker/compressDist.sh RUN chmod u+x .docker/compressDist.sh
RUN ./.docker/compressDist.sh RUN ./.docker/compressDist.sh
# Stage 2: Bundle the built application into a Docker container # Stage 3: Bundle the built application into a Docker container
# which runs Nginx using Alpine Linux # which runs Nginx using Alpine Linux
FROM nginxinc/nginx-unprivileged:1.27-alpine as final FROM nginxinc/nginx-unprivileged:1.27-alpine as final
#RUN apk add --no-cache bash #RUN apk add --no-cache bash

View File

@ -244,32 +244,6 @@ 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
@ -397,7 +371,6 @@ 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

3
addOns/README.md Normal file
View File

@ -0,0 +1,3 @@
# External Dependencies
This module contains optional dependencies and external dependencies for including in OHIF, such as the DICOM Microscopy Viewer component.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,91 @@
{
"name": "@externals/devDependencies",
"description": "External dev dependencies - put dev build dependencies here",
"version": "3.13.0-beta.75",
"license": "MIT",
"private": true,
"engines": {
"node": ">=12",
"yarn": ">=1.19.1"
},
"dependencies": {
"@babel/runtime": "7.28.2",
"@kitware/vtk.js": "34.15.1",
"clsx": "2.1.1",
"core-js": "3.45.1",
"moment": "2.30.1"
},
"peerDependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@pmmmwh/react-refresh-webpack-plugin": "0.5.17",
"@rsbuild/core": "1.5.1",
"@rsbuild/plugin-node-polyfill": "1.4.2",
"@rsbuild/plugin-react": "1.4.0",
"@svgr/webpack": "8.1.0",
"@swc/helpers": "0.5.17",
"@types/jest": "27.5.2",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"autoprefixer": "10.4.21",
"babel-loader": "8.4.1",
"clean-webpack-plugin": "3.0.0",
"copy-webpack-plugin": "9.1.0",
"cross-env": "7.0.3",
"css-loader": "6.11.0",
"dotenv": "8.6.0",
"eslint": "9.39.3",
"eslint-config-prettier": "7.2.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-cypress": "2.15.2",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "5.5.1",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-tsdoc": "0.2.17",
"execa": "8.0.1",
"extract-css-chunks-webpack-plugin": "4.10.0",
"html-webpack-plugin": "5.6.3",
"husky": "3.1.0",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
"jest-environment-jsdom": "29.7.0",
"jest-junit": "6.4.0",
"lerna": "9.0.4",
"lint-staged": "9.5.0",
"mini-css-extract-plugin": "2.9.2",
"optimize-css-assets-webpack-plugin": "6.0.1",
"postcss": "8.5.6",
"postcss-import": "14.1.0",
"postcss-loader": "6.2.1",
"postcss-preset-env": "7.8.3",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.9",
"react-refresh": "0.14.2",
"semver": "7.7.2",
"serve": "14.2.5",
"shader-loader": "1.3.1",
"shx": "0.3.4",
"source-map-loader": "4.0.2",
"style-loader": "1.3.0",
"terser-webpack-plugin": "5.3.14",
"typescript": "5.5.4",
"unused-webpack-plugin": "2.4.0",
"webpack": "5.105.0",
"webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.2",
"webpack-hot-middleware": "2.26.1",
"webpack-merge": "5.10.0",
"workbox-webpack-plugin": "6.6.1",
"worker-loader": "3.0.8"
},
"scripts": {
"build": "Included as direct dependency"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
{
"name": "@externals/dicom-microscopy-viewer",
"description": "External reference to dicom-microscopy-viewer",
"version": "3.13.0-beta.75",
"license": "MIT",
"dependencies": {
"dicom-microscopy-viewer": "0.48.17"
}
}

50
addOns/package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "ohif-monorepo-root",
"private": true,
"packageManager": "yarn@1.22.22",
"workspaces": {
"packages": [
"../platform/i18n",
"../platform/core",
"../platform/ui",
"../platform/ui-next",
"../platform/app",
"../extensions/*",
"../modes/*",
"../addOns/externals/*"
],
"nohoist": [
"**/html-minifier-terser"
]
},
"scripts": {
"preinstall": "cd .. && node preinstall.js"
},
"devDependencies": {
"@babel/core": "7.28.0",
"@babel/plugin-transform-class-properties": "7.27.1",
"@babel/plugin-transform-object-rest-spread": "7.28.0",
"@babel/plugin-transform-private-methods": "7.27.1",
"@babel/plugin-transform-private-property-in-object": "7.27.1",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/plugin-transform-arrow-functions": "7.27.1",
"@babel/plugin-transform-regenerator": "7.28.1",
"@babel/plugin-transform-runtime": "7.28.0",
"@babel/plugin-transform-typescript": "7.28.0",
"@babel/preset-env": "7.29.5",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1"
},
"resolutions": {
"**/@babel/runtime": "7.28.2",
"commander": "8.3.0",
"dcmjs": "0.49.4",
"dicomweb-client": "0.10.4",
"nth-check": "2.1.1",
"trim-newlines": "5.0.0",
"glob-parent": "6.0.2",
"trim": "1.0.1",
"package-json": "8.1.1",
"typescript": "5.5.4"
}
}

View File

@ -26,9 +26,7 @@ module.exports = {
'@babel/preset-typescript', '@babel/preset-typescript',
], ],
plugins: [ plugins: [
// jest's babel coverage provider injects babel-plugin-istanbul when 'babel-plugin-istanbul',
// --collectCoverage is set; adding it here too makes babel 7 (pulled in
// by jest 30) throw "Duplicate plugin/preset detected".
'@babel/plugin-transform-object-rest-spread', '@babel/plugin-transform-object-rest-spread',
'@babel/plugin-syntax-dynamic-import', '@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator', '@babel/plugin-transform-regenerator',

8176
bun.lock Normal file

File diff suppressed because it is too large Load Diff

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[install]
frozenLockfile = true

View File

@ -0,0 +1,2 @@
[install]
frozenLockfile = false

View File

@ -1 +1 @@
6dd150d401ad73d60632a23378b7dfd4b5142690 fe16e80cf3cd90d082729227af6edc0eedd66501

View File

@ -1,8 +1,8 @@
const webpack = require('@rspack/core'); const webpack = require('webpack');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const path = require('path'); const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js'); const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin; const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json'); const pkg = require('./../package.json');
@ -35,11 +35,9 @@ module.exports = (env, argv) => {
sideEffects: true, sideEffects: true,
}, },
output: { output: {
library: {
name: 'ohif-extension-cornerstone-dicom-pmap',
type: 'umd',
},
path: ROOT_DIR, path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-pmap',
libraryTarget: 'umd',
filename: pkg.main, filename: pkg.main,
}, },
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],

View File

@ -3,121 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.13.0-beta.89](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.88...v3.13.0-beta.89) (2026-06-10)
### Bug Fixes
* ohif tests to run with cornerstone 3d 5.0 ([#6043](https://github.com/OHIF/Viewers/issues/6043)) ([6dd150d](https://github.com/OHIF/Viewers/commit/6dd150d401ad73d60632a23378b7dfd4b5142690))
# [3.13.0-beta.88](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.87...v3.13.0-beta.88) (2026-06-05)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.87](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.86...v3.13.0-beta.87) (2026-05-29)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.86](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.85...v3.13.0-beta.86) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.85](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.84...v3.13.0-beta.85) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.84](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.83...v3.13.0-beta.84) (2026-05-27)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.83](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.82...v3.13.0-beta.83) (2026-05-26)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.82](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.81...v3.13.0-beta.82) (2026-05-22)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.81](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.80...v3.13.0-beta.81) (2026-05-21)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.80](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.79...v3.13.0-beta.80) (2026-05-20)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.79](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.78...v3.13.0-beta.79) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.78](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.77...v3.13.0-beta.78) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.77](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.76...v3.13.0-beta.77) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.76](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.75...v3.13.0-beta.76) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap
# [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18) # [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap **Note:** Version bump only for package @ohif/extension-cornerstone-dicom-pmap

View File

@ -1,37 +1,42 @@
{ {
"name": "@ohif/extension-cornerstone-dicom-pmap", "name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.13.0-beta.125", "version": "3.13.0-beta.75",
"description": "DICOM Parametric Map read workflow", "description": "DICOM Parametric Map read workflow",
"author": "OHIF", "author": "OHIF",
"license": "MIT", "license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/ohif-extension-cornerstone-dicom-pmap.umd.js", "main": "dist/ohif-extension-cornerstone-dicom-pmap.umd.js",
"module": "src/index.tsx", "module": "src/index.tsx",
"engines": {
"node": ">=24"
},
"files": [ "files": [
"dist/**", "dist/**",
"public/**", "public/**",
"README.md" "README.md"
], ],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": { "scripts": {
"clean": "shx rm -rf dist", "clean": "shx rm -rf dist",
"clean:deep": "pnpm run clean && shx rm -rf node_modules", "clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch", "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-pmap": "pnpm run dev", "dev:dicom-pmap": "yarn run dev",
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "pnpm run build", "build:package-1": "yarn run build",
"start": "pnpm run dev" "start": "yarn run dev"
}, },
"peerDependencies": { "peerDependencies": {
"@ohif/core": "workspace:*", "@ohif/core": "3.13.0-beta.75",
"@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-cornerstone": "3.13.0-beta.75",
"@ohif/extension-default": "workspace:*", "@ohif/extension-default": "3.13.0-beta.75",
"@ohif/i18n": "workspace:*", "@ohif/i18n": "3.13.0-beta.75",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@ -40,15 +45,9 @@
"react-router-dom": "6.30.3" "react-router-dom": "6.30.3"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.29.7", "@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "5.4.17", "@cornerstonejs/adapters": "4.22.8",
"@cornerstonejs/core": "5.4.17", "@cornerstonejs/core": "4.22.8",
"@kitware/vtk.js": "35.5.3" "@kitware/vtk.js": "34.15.1"
}, }
"devDependencies": {
"cross-env": "7.0.3"
},
"keywords": [
"ohif-extension"
]
} }

View File

@ -1,4 +1,4 @@
const webpack = require('@rspack/core'); const webpack = require('webpack');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const path = require('path'); const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js'); const webpackCommon = require('./../../../.webpack/webpack.base.js');
@ -32,11 +32,9 @@ module.exports = (env, argv) => {
sideEffects: false, sideEffects: false,
}, },
output: { output: {
library: {
name: 'ohif-extension-cornerstone-dicom-rt',
type: 'umd',
},
path: ROOT_DIR, path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-rt',
libraryTarget: 'umd',
filename: pkg.main, filename: pkg.main,
}, },
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],

View File

@ -3,118 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.13.0-beta.89](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.88...v3.13.0-beta.89) (2026-06-10)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.88](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.87...v3.13.0-beta.88) (2026-06-05)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.87](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.86...v3.13.0-beta.87) (2026-05-29)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.86](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.85...v3.13.0-beta.86) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.85](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.84...v3.13.0-beta.85) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.84](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.83...v3.13.0-beta.84) (2026-05-27)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.83](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.82...v3.13.0-beta.83) (2026-05-26)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.82](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.81...v3.13.0-beta.82) (2026-05-22)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.81](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.80...v3.13.0-beta.81) (2026-05-21)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.80](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.79...v3.13.0-beta.80) (2026-05-20)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.79](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.78...v3.13.0-beta.79) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.78](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.77...v3.13.0-beta.78) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.77](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.76...v3.13.0-beta.77) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.76](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.75...v3.13.0-beta.76) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt
# [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18) # [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt **Note:** Version bump only for package @ohif/extension-cornerstone-dicom-rt

View File

@ -1,15 +1,11 @@
{ {
"name": "@ohif/extension-cornerstone-dicom-rt", "name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.13.0-beta.125", "version": "3.13.0-beta.75",
"description": "DICOM RT read workflow", "description": "DICOM RT read workflow",
"author": "OHIF", "author": "OHIF",
"license": "MIT", "license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/ohif-extension-cornerstone-dicom-rt.umd.js", "main": "dist/ohif-extension-cornerstone-dicom-rt.umd.js",
"module": "src/index.tsx", "module": "src/index.tsx",
"engines": {
"node": ">=24"
},
"files": [ "files": [
"dist/**", "dist/**",
"public/**", "public/**",
@ -18,20 +14,29 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": { "scripts": {
"clean": "shx rm -rf dist", "clean": "shx rm -rf dist",
"clean:deep": "pnpm run clean && shx rm -rf node_modules", "clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch", "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-seg": "pnpm run dev", "dev:dicom-seg": "yarn run dev",
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "pnpm run build", "build:package-1": "yarn run build",
"start": "pnpm run dev" "start": "yarn run dev"
}, },
"peerDependencies": { "peerDependencies": {
"@ohif/core": "workspace:*", "@ohif/core": "3.13.0-beta.75",
"@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-cornerstone": "3.13.0-beta.75",
"@ohif/extension-default": "workspace:*", "@ohif/extension-default": "3.13.0-beta.75",
"@ohif/i18n": "workspace:*", "@ohif/i18n": "3.13.0-beta.75",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@ -40,12 +45,6 @@
"react-router-dom": "6.30.3" "react-router-dom": "6.30.3"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.29.7" "@babel/runtime": "7.28.2"
}, }
"devDependencies": {
"cross-env": "7.0.3"
},
"keywords": [
"ohif-extension"
]
} }

View File

@ -1,8 +1,8 @@
const webpack = require('@rspack/core'); const webpack = require('webpack');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const path = require('path'); const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js'); const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin; const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json'); const pkg = require('./../package.json');
@ -35,11 +35,9 @@ module.exports = (env, argv) => {
sideEffects: true, sideEffects: true,
}, },
output: { output: {
library: {
name: 'ohif-extension-cornerstone-dicom-seg',
type: 'umd',
},
path: ROOT_DIR, path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-seg',
libraryTarget: 'umd',
filename: pkg.main, filename: pkg.main,
}, },
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],

View File

@ -3,121 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.13.0-beta.89](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.88...v3.13.0-beta.89) (2026-06-10)
### Bug Fixes
* ohif tests to run with cornerstone 3d 5.0 ([#6043](https://github.com/OHIF/Viewers/issues/6043)) ([6dd150d](https://github.com/OHIF/Viewers/commit/6dd150d401ad73d60632a23378b7dfd4b5142690))
# [3.13.0-beta.88](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.87...v3.13.0-beta.88) (2026-06-05)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.87](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.86...v3.13.0-beta.87) (2026-05-29)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.86](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.85...v3.13.0-beta.86) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.85](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.84...v3.13.0-beta.85) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.84](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.83...v3.13.0-beta.84) (2026-05-27)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.83](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.82...v3.13.0-beta.83) (2026-05-26)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.82](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.81...v3.13.0-beta.82) (2026-05-22)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.81](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.80...v3.13.0-beta.81) (2026-05-21)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.80](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.79...v3.13.0-beta.80) (2026-05-20)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.79](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.78...v3.13.0-beta.79) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.78](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.77...v3.13.0-beta.78) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.77](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.76...v3.13.0-beta.77) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.76](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.75...v3.13.0-beta.76) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg
# [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18) # [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg **Note:** Version bump only for package @ohif/extension-cornerstone-dicom-seg

View File

@ -1,37 +1,42 @@
{ {
"name": "@ohif/extension-cornerstone-dicom-seg", "name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.13.0-beta.125", "version": "3.13.0-beta.75",
"description": "DICOM SEG read workflow", "description": "DICOM SEG read workflow",
"author": "OHIF", "author": "OHIF",
"license": "MIT", "license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/ohif-extension-cornerstone-dicom-seg.umd.js", "main": "dist/ohif-extension-cornerstone-dicom-seg.umd.js",
"module": "src/index.tsx", "module": "src/index.tsx",
"engines": {
"node": ">=24"
},
"files": [ "files": [
"dist/**", "dist/**",
"public/**", "public/**",
"README.md" "README.md"
], ],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": { "scripts": {
"clean": "shx rm -rf dist", "clean": "shx rm -rf dist",
"clean:deep": "pnpm run clean && shx rm -rf node_modules", "clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch", "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-seg": "pnpm run dev", "dev:dicom-seg": "yarn run dev",
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "pnpm run build", "build:package-1": "yarn run build",
"start": "pnpm run dev" "start": "yarn run dev"
}, },
"peerDependencies": { "peerDependencies": {
"@ohif/core": "workspace:*", "@ohif/core": "3.13.0-beta.75",
"@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-cornerstone": "3.13.0-beta.75",
"@ohif/extension-default": "workspace:*", "@ohif/extension-default": "3.13.0-beta.75",
"@ohif/i18n": "workspace:*", "@ohif/i18n": "3.13.0-beta.75",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@ -40,15 +45,9 @@
"react-router-dom": "6.30.3" "react-router-dom": "6.30.3"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.29.7", "@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "5.4.17", "@cornerstonejs/adapters": "4.22.8",
"@cornerstonejs/core": "5.4.17", "@cornerstonejs/core": "4.22.8",
"@kitware/vtk.js": "35.5.3" "@kitware/vtk.js": "34.15.1"
}, }
"devDependencies": {
"cross-env": "7.0.3"
},
"keywords": [
"ohif-extension"
]
} }

View File

@ -6,11 +6,6 @@ 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();
@ -33,12 +28,13 @@ 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, customizationService } = const { segmentationService, displaySetService, viewportGridService } =
servicesManager.services as AppTypes.Services; servicesManager.services as AppTypes.Services;
const actions = { const actions = {
@ -92,39 +88,16 @@ 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 = const predecessorImageId = options.predecessorImageId ?? segmentation.predecessorImageId;
generateOptions.predecessorImageId ?? segmentation.predecessorImageId;
// A data source may override the app-wide `segmentation.store.*` defaults const { imageIds } = segmentation.representationData.Labelmap;
// 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 labelmapData = segmentation.representationData.Labelmap; const segImages = imageIds.map(imageId => cache.getImage(imageId));
const referencedImages = segImages.map(image => cache.getImage(image.referencedImageId));
// 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 = []; const labelmaps2D = [];
// 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;
let z = 0; let z = 0;
for (const segImage of segImages) { for (const segImage of segImages) {
@ -140,15 +113,7 @@ const commandsModule = ({
} }
} }
const frameIndex = referencedFrameIndexById labelmaps2D[z++] = {
? referencedFrameIndexById.get(segImage.referencedImageId) ?? -1
: z++;
if (frameIndex < 0) {
continue;
}
labelmaps2D[frameIndex] = {
segmentsOnLabelmap: Array.from(segmentsOnLabelmap), segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
pixelData, pixelData,
rows, rows,
@ -156,21 +121,16 @@ const commandsModule = ({
}; };
} }
const allSegmentsOnLabelmap = labelmaps2D const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap);
.filter(Boolean)
.map(labelmap => labelmap.segmentsOnLabelmap);
return { const labelmap3D = {
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())), segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
metadata, metadata: [],
labelmaps2D, 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
@ -191,7 +151,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));
metadata[segmentIndex] = { const segmentMetadata = {
SegmentNumber: segmentIndex.toString(), SegmentNumber: segmentIndex.toString(),
SegmentLabel: label, SegmentLabel: label,
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL', SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
@ -208,76 +168,13 @@ const commandsModule = ({
CodeMeaning: 'Tissue', CodeMeaning: 'Tissue',
}, },
}; };
labelmap3D.metadata[segmentIndex] = segmentMetadata;
}); });
// Multi-layer (overlapping) SEGs register one labelmap layer per conflict-free const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
// 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,
...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride), ...options,
...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;
}, },
@ -343,7 +240,9 @@ const commandsModule = ({
} }
const defaultFileName = const defaultFileName =
modality === 'RTSTRUCT' ? `rtss-${segmentationId}.dcm` : `${label || 'segmentation'}.dcm`; modality === 'RTSTRUCT'
? `rtss-${segmentationId}.dcm`
: `${label || 'segmentation'}.dcm`;
const storeFn = commandsManager.runCommand('createStoreFunction', { const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource: dataSourceName, dataSource: dataSourceName,
@ -358,8 +257,6 @@ 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,
@ -392,8 +289,6 @@ 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
@ -406,11 +301,10 @@ const commandsModule = ({
Number(segmentIndex) Number(segmentIndex)
); );
}); });
const predecessorImageId = const predecessorImageId = options?.predecessorImageId ?? segmentations.predecessorImageId;
contourOptions.predecessorImageId ?? segmentations.predecessorImageId;
const dataset = await generateRTSSFromRepresentation(segmentations, { const dataset = await generateRTSSFromRepresentation(segmentations, {
predecessorImageId, predecessorImageId,
...contourOptions, ...options,
}); });
return { dataset }; return { dataset };
}, },

View File

@ -70,10 +70,7 @@ function SegmentSelector({
onValueChange={onValueChange} onValueChange={onValueChange}
value={value} value={value}
> >
<SelectTrigger <SelectTrigger className="overflow-hidden">
className="overflow-hidden"
data-cy={`logical-contour-segment-${label.toLowerCase()}-trigger`}
>
<SelectValue placeholder={t(placeholder)} /> <SelectValue placeholder={t(placeholder)} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -183,7 +180,6 @@ 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>
@ -211,7 +207,6 @@ 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={() => {
@ -226,7 +221,6 @@ 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">

View File

@ -1,29 +0,0 @@
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;

View File

@ -1,10 +0,0 @@
import segmentationCustomization from './customizations/segmentationCustomization';
export default function getCustomizationModule() {
return [
{
name: 'default',
value: segmentationCustomization,
},
];
}

View File

@ -1,237 +1,16 @@
import { utils, Types as OhifTypes, DicomMetadataStore, classes, log } from '@ohif/core'; import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n'; import i18n from '@ohif/i18n';
import { metaData, eventTarget, utilities as csUtils } from '@cornerstonejs/core'; import { metaData, eventTarget } 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,
@ -392,11 +171,6 @@ 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];
} }
@ -404,18 +178,16 @@ async function _loadSegments({
extensionManager, extensionManager,
servicesManager, servicesManager,
segDisplaySet, segDisplaySet,
}: withAppTypes<{ segDisplaySet: AppTypes.DisplaySet }>) { headers,
const { segmentationService, uiNotificationService, customizationService } = }: withAppTypes) {
servicesManager.services; const utilityModule = extensionManager.getModuleEntry(
const instance = segDisplaySet.instance as Record<string, unknown>; '@ohif/extension-cornerstone.utilityModule.common'
const dataSource = _getSegDataSource(extensionManager, instance);
const segImageIdStr = _getSegImageIdFromInstance(instance, dataSource);
if (!segImageIdStr) {
throw new Error(
'Could not get imageId for SEG instance (no local wadouri url and getImageIdsForInstance returned nothing).'
); );
}
const { segmentationService, uiNotificationService } = servicesManager.services;
const { dicomLoaderService } = utilityModule.exports;
const arrayBuffer = await dicomLoaderService.findDicomDataPromise(segDisplaySet, null, headers);
const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID( const referencedDisplaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
segDisplaySet.referencedDisplaySetInstanceUID segDisplaySet.referencedDisplaySetInstanceUID
@ -425,114 +197,31 @@ 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?.length) { if (!imageIds) {
imageIds = dataSource.getImageIdsForDisplaySet?.(referencedDisplaySet); // try images
const { images } = referencedDisplaySet;
imageIds = images.map(image => image.imageId);
} }
if (!imageIds?.length) { // Todo: what should be defaults here
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;
const onProgress = evt => { eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, 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);
// Fetch the whole SEG instance as a single Part 10 object and register its
// per-frame compressed pixels into the Cornerstone3D frame registry, so the
// per-frame loads below are served locally instead of one network request
// 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) { const results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer(
await prefetch.done;
}
}
let results;
try {
results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDicomSegImageId(
imageIds, imageIds,
segImageIdForMetadata, arrayBuffer,
{ { metadataProvider: metaData, tolerance }
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;
const resultsTyped = results as { results.segMetadata.data.forEach((data, i) => {
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;
@ -557,17 +246,6 @@ 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) {

View File

@ -4,7 +4,6 @@ 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(() => {
@ -29,7 +28,6 @@ const extension = {
*/ */
id, id,
getCommandsModule, getCommandsModule,
getCustomizationModule,
getToolbarModule, getToolbarModule,
getViewportModule({ servicesManager, extensionManager, commandsManager }) { getViewportModule({ servicesManager, extensionManager, commandsManager }) {
const ExtendedOHIFCornerstoneSEGViewport = props => { const ExtendedOHIFCornerstoneSEGViewport = props => {

View File

@ -1,32 +0,0 @@
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;
}

View File

@ -1,100 +0,0 @@
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,
};
}

View File

@ -95,13 +95,10 @@ 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={[referencedDisplaySet, segDisplaySet]} displaySets={[segDisplaySet]}
viewportOptions={{ viewportOptions={{
viewportType: viewportOptions.viewportType, viewportType: viewportOptions.viewportType,
toolGroupId: toolGroupId, toolGroupId: toolGroupId,
@ -114,14 +111,7 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
}} }}
/> />
); );
}, [ }, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]);
viewportId,
segDisplaySet,
referencedDisplaySet,
toolGroupId,
props,
viewportOptions,
]);
useEffect(() => { useEffect(() => {
if (segIsLoading) { if (segIsLoading) {

View File

@ -1,8 +1,8 @@
const webpack = require('@rspack/core'); const webpack = require('webpack');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const path = require('path'); const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js'); const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin; const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json'); const pkg = require('./../package.json');
@ -36,11 +36,9 @@ module.exports = (env, argv) => {
sideEffects: true, sideEffects: true,
}, },
output: { output: {
library: {
name: 'ohif-extension-cornerstone-dicom-sr',
type: 'umd',
},
path: ROOT_DIR, path: ROOT_DIR,
library: 'ohif-extension-cornerstone-dicom-sr',
libraryTarget: 'umd',
filename: pkg.main, filename: pkg.main,
}, },
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],

View File

@ -3,121 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.13.0-beta.89](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.88...v3.13.0-beta.89) (2026-06-10)
### Bug Fixes
* ohif tests to run with cornerstone 3d 5.0 ([#6043](https://github.com/OHIF/Viewers/issues/6043)) ([6dd150d](https://github.com/OHIF/Viewers/commit/6dd150d401ad73d60632a23378b7dfd4b5142690))
# [3.13.0-beta.88](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.87...v3.13.0-beta.88) (2026-06-05)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.87](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.86...v3.13.0-beta.87) (2026-05-29)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.86](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.85...v3.13.0-beta.86) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.85](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.84...v3.13.0-beta.85) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.84](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.83...v3.13.0-beta.84) (2026-05-27)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.83](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.82...v3.13.0-beta.83) (2026-05-26)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.82](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.81...v3.13.0-beta.82) (2026-05-22)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.81](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.80...v3.13.0-beta.81) (2026-05-21)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.80](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.79...v3.13.0-beta.80) (2026-05-20)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.79](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.78...v3.13.0-beta.79) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.78](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.77...v3.13.0-beta.78) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.77](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.76...v3.13.0-beta.77) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.76](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.75...v3.13.0-beta.76) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr
# [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18) # [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18)
**Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr **Note:** Version bump only for package @ohif/extension-cornerstone-dicom-sr

View File

@ -1,6 +1,6 @@
{ {
"name": "@ohif/extension-cornerstone-dicom-sr", "name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.13.0-beta.125", "version": "3.13.0-beta.75",
"description": "OHIF extension for an SR Cornerstone Viewport", "description": "OHIF extension for an SR Cornerstone Viewport",
"author": "OHIF", "author": "OHIF",
"license": "MIT", "license": "MIT",
@ -8,7 +8,9 @@
"main": "dist/ohif-extension-cornerstone-dicom-sr.umd.js", "main": "dist/ohif-extension-cornerstone-dicom-sr.umd.js",
"module": "src/index.tsx", "module": "src/index.tsx",
"engines": { "engines": {
"node": ">=24" "node": ">=14",
"npm": ">=6",
"yarn": ">=1.16.0"
}, },
"files": [ "files": [
"dist", "dist",
@ -17,38 +19,36 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"keywords": [
"ohif-extension"
],
"scripts": { "scripts": {
"clean": "shx rm -rf dist", "clean": "shx rm -rf dist",
"clean:deep": "pnpm run clean && shx rm -rf node_modules", "clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch", "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:cornerstone": "pnpm run dev", "dev:cornerstone": "yarn run dev",
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "pnpm run build", "build:package-1": "yarn run build",
"start": "pnpm run dev", "start": "yarn run dev",
"test:unit": "jest --watchAll", "test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
}, },
"peerDependencies": { "peerDependencies": {
"@ohif/core": "workspace:*", "@ohif/core": "3.13.0-beta.75",
"@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-cornerstone": "3.13.0-beta.75",
"@ohif/ui": "workspace:*", "@ohif/extension-measurement-tracking": "3.13.0-beta.75",
"dcmjs": "0.52.0", "@ohif/ui": "3.13.0-beta.75",
"dcmjs": "0.49.4",
"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",
"react": "18.3.1" "react": "18.3.1"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.29.7", "@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "5.4.17", "@cornerstonejs/adapters": "4.22.8",
"@cornerstonejs/core": "5.4.17", "@cornerstonejs/core": "4.22.8",
"@cornerstonejs/tools": "5.4.17", "@cornerstonejs/tools": "4.22.8",
"classnames": "2.5.1" "classnames": "2.5.1"
}, }
"devDependencies": {
"cross-env": "7.0.3"
},
"keywords": [
"ohif-extension"
]
} }

View File

@ -69,12 +69,7 @@ export default class DICOMSRDisplayTool extends AnnotationTool {
trackingUniqueIdentifiers.includes(annotation.data?.TrackingUniqueIdentifier) trackingUniqueIdentifiers.includes(annotation.data?.TrackingUniqueIdentifier)
); );
const hasActors = if (!viewport._actors?.size) {
typeof viewport.getActors === 'function'
? viewport.getActors().length > 0
: Boolean(viewport._actors?.size);
if (!hasActors) {
return; return;
} }

View File

@ -142,22 +142,15 @@ 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 = getReferencedDisplaySet( const ds = chooseDisplaySet(displaySetsFOR, FrameOfReferenceUID);
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);
} }
@ -181,7 +174,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, displaySetsByFrameOfReferenceUID); return getReferenceData3D(toolData, servicesManager);
} }
const instance = metaData.get('instance', imageId); const instance = metaData.get('instance', imageId);
@ -323,60 +316,21 @@ function chooseDisplaySet(displaySets, reference) {
console.warn('No display set found for', reference); console.warn('No display set found for', reference);
return; return;
} }
const sortedDisplaySets = OHIF.utils.sortDisplaySetsCopy(displaySets); if (displaySets.length === 1) {
if (sortedDisplaySets.length === 1) { return displaySets[0];
return sortedDisplaySets[0];
} }
const volumeDs = sortedDisplaySets.find(ds => ds.isReconstructable); const volumeDs = displaySets.find(ds => ds.isReconstructable);
if (volumeDs) { if (volumeDs) {
return volumeDs; return volumeDs;
} }
return sortedDisplaySets[0]; return displaySets[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( function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
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;
@ -388,9 +342,7 @@ function getReferenceData3D(
FrameOfReferenceUID, FrameOfReferenceUID,
}; };
} }
const ds = const ds = chooseDisplaySet(displaySetsFOR, toolData.annotation);
displaySetsByFrameOfReferenceUID.get(FrameOfReferenceUID) ||
chooseDisplaySet(displaySetsFOR, toolData.annotation);
const cameraView = chooseCameraView(ds, points); const cameraView = chooseCameraView(ds, points);
const viewReference = { const viewReference = {

View File

@ -1,8 +1,8 @@
const webpack = require('@rspack/core'); const webpack = require('webpack');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const path = require('path'); const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js'); const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin; const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json'); const pkg = require('./../package.json');
@ -35,11 +35,9 @@ module.exports = (env, argv) => {
sideEffects: true, sideEffects: true,
}, },
output: { output: {
library: {
name: 'ohif-extension-cornerstone',
type: 'umd',
},
path: ROOT_DIR, path: ROOT_DIR,
library: 'ohif-extension-cornerstone',
libraryTarget: 'umd',
filename: pkg.main, filename: pkg.main,
}, },
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],

View File

@ -3,121 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.13.0-beta.89](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.88...v3.13.0-beta.89) (2026-06-10)
### Bug Fixes
* ohif tests to run with cornerstone 3d 5.0 ([#6043](https://github.com/OHIF/Viewers/issues/6043)) ([6dd150d](https://github.com/OHIF/Viewers/commit/6dd150d401ad73d60632a23378b7dfd4b5142690))
# [3.13.0-beta.88](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.87...v3.13.0-beta.88) (2026-06-05)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.87](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.86...v3.13.0-beta.87) (2026-05-29)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.86](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.85...v3.13.0-beta.86) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.85](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.84...v3.13.0-beta.85) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.84](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.83...v3.13.0-beta.84) (2026-05-27)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.83](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.82...v3.13.0-beta.83) (2026-05-26)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.82](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.81...v3.13.0-beta.82) (2026-05-22)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.81](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.80...v3.13.0-beta.81) (2026-05-21)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.80](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.79...v3.13.0-beta.80) (2026-05-20)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.79](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.78...v3.13.0-beta.79) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.78](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.77...v3.13.0-beta.78) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.77](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.76...v3.13.0-beta.77) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.76](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.75...v3.13.0-beta.76) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume
# [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18) # [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18)
**Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume **Note:** Version bump only for package @ohif/extension-cornerstone-dynamic-volume

View File

@ -1,15 +1,12 @@
{ {
"name": "@ohif/extension-cornerstone-dynamic-volume", "name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.13.0-beta.125", "version": "3.13.0-beta.75",
"description": "OHIF extension for 4D volumes data", "description": "OHIF extension for 4D volumes data",
"author": "OHIF", "author": "OHIF",
"license": "MIT", "license": "MIT",
"repository": "OHIF/Viewers", "repository": "OHIF/Viewers",
"main": "dist/ohif-extension-cornerstone-dynamic-volume.umd.js", "main": "dist/ohif-extension-cornerstone-dynamic-volume.umd.js",
"module": "src/index.ts", "module": "src/index.ts",
"engines": {
"node": ">=24"
},
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./types": "./src/types/index.ts" "./types": "./src/types/index.ts"
@ -22,34 +19,31 @@
"access": "public" "access": "public"
}, },
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch", "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "pnpm run build", "build:package": "yarn run build",
"clean": "shx rm -rf dist", "clean": "shx rm -rf dist",
"clean:deep": "pnpm run clean && shx rm -rf node_modules", "clean:deep": "yarn run clean && shx rm -rf node_modules",
"start": "pnpm run dev", "start": "yarn run dev",
"test:unit": "jest --watchAll", "test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
}, },
"peerDependencies": { "peerDependencies": {
"@ohif/core": "workspace:*", "@ohif/core": "3.13.0-beta.75",
"@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-cornerstone": "3.13.0-beta.75",
"@ohif/extension-default": "workspace:*", "@ohif/extension-default": "3.13.0-beta.75",
"@ohif/i18n": "workspace:*", "@ohif/i18n": "3.13.0-beta.75",
"@ohif/ui": "workspace:*", "@ohif/ui": "3.13.0-beta.75",
"dcmjs": "0.52.0", "dcmjs": "0.49.4",
"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",
"react": "18.3.1" "react": "18.3.1"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.29.7", "@babel/runtime": "7.28.2",
"@cornerstonejs/core": "5.4.17", "@cornerstonejs/core": "4.22.8",
"@cornerstonejs/tools": "5.4.17", "@cornerstonejs/tools": "4.22.8",
"classnames": "2.5.1" "classnames": "2.5.1"
},
"devDependencies": {
"cross-env": "7.0.3"
} }
} }

View File

@ -1,8 +1,8 @@
const webpack = require('@rspack/core'); const webpack = require('webpack');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const path = require('path'); const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js'); const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = webpack.CssExtractRspackPlugin; const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json'); const pkg = require('./../package.json');
@ -35,11 +35,9 @@ module.exports = (env, argv) => {
sideEffects: true, sideEffects: true,
}, },
output: { output: {
library: {
name: 'ohif-extension-cornerstone',
type: 'umd',
},
path: ROOT_DIR, path: ROOT_DIR,
library: 'ohif-extension-cornerstone',
libraryTarget: 'umd',
filename: pkg.main, filename: pkg.main,
}, },
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],

View File

@ -3,130 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.13.0-beta.89](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.88...v3.13.0-beta.89) (2026-06-10)
### Bug Fixes
* ohif tests to run with cornerstone 3d 5.0 ([#6043](https://github.com/OHIF/Viewers/issues/6043)) ([6dd150d](https://github.com/OHIF/Viewers/commit/6dd150d401ad73d60632a23378b7dfd4b5142690))
# [3.13.0-beta.88](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.87...v3.13.0-beta.88) (2026-06-05)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.87](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.86...v3.13.0-beta.87) (2026-05-29)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.86](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.85...v3.13.0-beta.86) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.85](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.84...v3.13.0-beta.85) (2026-05-28)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.84](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.83...v3.13.0-beta.84) (2026-05-27)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.83](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.82...v3.13.0-beta.83) (2026-05-26)
### Bug Fixes
* **segmentation:** restore navigation for duplicated contour segments ([#6038](https://github.com/OHIF/Viewers/issues/6038)) ([0c1ca6b](https://github.com/OHIF/Viewers/commit/0c1ca6b04a6c1aef478c7a7d9f360f9b9c50f188))
# [3.13.0-beta.82](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.81...v3.13.0-beta.82) (2026-05-22)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.81](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.80...v3.13.0-beta.81) (2026-05-21)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.80](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.79...v3.13.0-beta.80) (2026-05-20)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.79](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.78...v3.13.0-beta.79) (2026-05-19)
### Bug Fixes
* **seg:** prevent viewport orientation change when loading SEG in manual grid layout ([#6021](https://github.com/OHIF/Viewers/issues/6021)) ([95251ab](https://github.com/OHIF/Viewers/commit/95251ab6709934e4ef59931379f8d9d7629f87b2))
# [3.13.0-beta.78](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.77...v3.13.0-beta.78) (2026-05-19)
### Bug Fixes
* **measurements:** read displayName in SplineROI and PlanarFreehandROI reports ([#5908](https://github.com/OHIF/Viewers/issues/5908)) ([27af682](https://github.com/OHIF/Viewers/commit/27af6821b56f2c5be3a5d4ce38d05be6fbce68f4))
# [3.13.0-beta.77](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.76...v3.13.0-beta.77) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.76](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.75...v3.13.0-beta.76) (2026-05-19)
**Note:** Version bump only for package @ohif/extension-cornerstone
# [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18) # [3.13.0-beta.75](https://github.com/OHIF/Viewers/compare/v3.13.0-beta.74...v3.13.0-beta.75) (2026-05-18)
**Note:** Version bump only for package @ohif/extension-cornerstone **Note:** Version bump only for package @ohif/extension-cornerstone

View File

@ -5,6 +5,8 @@ module.exports = {
moduleNameMapper: { moduleNameMapper: {
...base.moduleNameMapper, ...base.moduleNameMapper,
'@ohif/(.*)': '<rootDir>/../../platform/$1/src', '@ohif/(.*)': '<rootDir>/../../platform/$1/src',
'^@cornerstonejs/([^/]+)/(.*)$': '<rootDir>/../../node_modules/@cornerstonejs/$1/dist/esm/$2',
'^@cornerstonejs/([^/]+)$': '<rootDir>/../../node_modules/@cornerstonejs/$1/dist/esm',
}, },
// rootDir: "../.." // rootDir: "../.."
// testMatch: [ // testMatch: [

View File

@ -1,6 +1,6 @@
{ {
"name": "@ohif/extension-cornerstone", "name": "@ohif/extension-cornerstone",
"version": "3.13.0-beta.125", "version": "3.13.0-beta.75",
"description": "OHIF extension for Cornerstone", "description": "OHIF extension for Cornerstone",
"author": "OHIF", "author": "OHIF",
"license": "MIT", "license": "MIT",
@ -13,7 +13,9 @@
"./types": "./src/types/index.ts" "./types": "./src/types/index.ts"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=10",
"npm": ">=6",
"yarn": ">=1.16.0"
}, },
"files": [ "files": [
"dist", "dist",
@ -24,12 +26,12 @@
}, },
"scripts": { "scripts": {
"clean": "shx rm -rf dist", "clean": "shx rm -rf dist",
"clean:deep": "pnpm run clean && shx rm -rf node_modules", "clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development rspack build --config .webpack/webpack.dev.js --watch", "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:cornerstone": "pnpm run dev", "dev:cornerstone": "yarn run dev",
"build": "cross-env NODE_ENV=production rspack build --config .webpack/webpack.prod.js", "build": "cross-env NODE_ENV=production webpack --progress --config .webpack/webpack.prod.js",
"build:package-1": "pnpm run build", "build:package-1": "yarn run build",
"start": "pnpm run dev", "start": "yarn run dev",
"test:unit": "jest --watchAll", "test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
}, },
@ -38,11 +40,10 @@
"@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.4.17", "@cornerstonejs/dicom-image-loader": "4.22.8",
"@ohif/core": "workspace:*", "@ohif/core": "3.13.0-beta.75",
"@ohif/extension-default": "workspace:*", "@ohif/ui": "3.13.0-beta.75",
"@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",
@ -51,17 +52,16 @@
"react-resize-detector": "10.0.1" "react-resize-detector": "10.0.1"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.29.7", "@babel/runtime": "7.28.2",
"@cornerstonejs/adapters": "5.4.17", "@cornerstonejs/adapters": "4.22.8",
"@cornerstonejs/ai": "5.4.17", "@cornerstonejs/ai": "4.22.8",
"@cornerstonejs/core": "5.4.17", "@cornerstonejs/core": "4.22.8",
"@cornerstonejs/labelmap-interpolation": "5.4.17", "@cornerstonejs/labelmap-interpolation": "4.22.8",
"@cornerstonejs/metadata": "5.4.17", "@cornerstonejs/polymorphic-segmentation": "4.22.8",
"@cornerstonejs/polymorphic-segmentation": "5.4.17", "@cornerstonejs/tools": "4.22.8",
"@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": "34.15.1",
"html2canvas": "1.4.1", "html2canvas": "1.4.1",
"immutability-helper": "3.1.1", "immutability-helper": "3.1.1",
"lodash.compact": "3.0.1", "lodash.compact": "3.0.1",
@ -70,8 +70,5 @@
"lodash.zip": "4.2.0", "lodash.zip": "4.2.0",
"shader-loader": "1.3.1", "shader-loader": "1.3.1",
"worker-loader": "3.0.8" "worker-loader": "3.0.8"
},
"devDependencies": {
"cross-env": "7.0.3"
} }
} }

View File

@ -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, eventTarget } from '@cornerstonejs/core'; import { metaData, Enums, utilities, 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,14 +9,12 @@ 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, formatValue } = utils; const { formatPN } = utils;
type ViewportData = StackViewportData | VolumeViewportData; type ViewportData = StackViewportData | VolumeViewportData;
@ -186,12 +184,7 @@ function CustomizableViewportOverlay({
} else { } else {
const renderItem = customizationService.transform(item); const renderItem = customizationService.transform(item);
if ( if (typeof renderItem.contentF === 'function') {
renderItem &&
typeof renderItem === 'object' &&
'contentF' in renderItem &&
typeof renderItem.contentF === 'function'
) {
return renderItem.contentF(overlayItemProps); return renderItem.contentF(overlayItemProps);
} }
} }
@ -271,7 +264,7 @@ function getDisplaySets(viewportData, displaySetService) {
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => { const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
let instanceNumber; let instanceNumber;
switch (getViewportDataShapeType(viewportData)) { switch (viewportData.viewportType) {
case Enums.ViewportType.STACK: case Enums.ViewportType.STACK:
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex); instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
break; break;
@ -338,11 +331,8 @@ function _getInstanceNumberFromVolume(
return; return;
} }
const viewPlaneNormal = getViewportAdapter(cornerstoneViewport).getViewPlaneNormal(); const camera = cornerstoneViewport.getCamera();
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);
@ -355,10 +345,7 @@ function _getInstanceNumberFromVolume(
const imageId = imageIds[imageIndex]; const imageId = imageIds[imageIndex];
if (!imageId) { if (!imageId) {
// No image at this index (e.g. a single-image volume scrolled out of return {};
// 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) || {};
@ -370,8 +357,7 @@ 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];
const displayValue = formatValue(value); if (value === undefined || value === null) {
if (displayValue === null || displayValue === '') {
return null; return null;
} }
return ( return (
@ -381,7 +367,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">{displayValue}</span> <span className="ml-0 shrink-0">{value}</span>
</div> </div>
); );
} }

View File

@ -1,9 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { utilities as csUtils } from '@cornerstonejs/core'; import { Enums, VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
import { ImageScrollbar } from '@ohif/ui-next'; import { ImageScrollbar } from '@ohif/ui-next';
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
import { getSliceEventName, getViewportSliceCount } from '../../utils/viewportDataShape';
function CornerstoneImageScrollbar({ function CornerstoneImageScrollbar({
viewportData, viewportData,
@ -42,16 +40,16 @@ function CornerstoneImageScrollbar({
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || isVolume3DViewportType(viewport)) { if (!viewport || viewport instanceof VolumeViewport3D) {
return; return;
} }
try { try {
const imageIndex = viewport.getCurrentImageIdIndex(); const imageIndex = viewport.getCurrentImageIdIndex();
const numberOfSlices = getViewportSliceCount(viewportData, viewport); const numberOfSlices = viewport.getNumberOfSlices();
setImageSliceData({ setImageSliceData({
imageIndex, imageIndex: imageIndex,
numberOfSlices, numberOfSlices,
}); });
} catch (error) { } catch (error) {
@ -63,11 +61,15 @@ function CornerstoneImageScrollbar({
if (!viewportData) { if (!viewportData) {
return; return;
} }
const eventId = getSliceEventName(viewportData); const { viewportType } = 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);
if (!viewport || isVolume3DViewportType(viewport)) { if (!viewport || viewport instanceof VolumeViewport3D) {
return; return;
} }
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail; const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;

View File

@ -6,7 +6,6 @@ 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({
@ -47,9 +46,7 @@ function ViewportOrientationMarkers({
return ''; return '';
} }
// Use the persisted data shape, not viewportType: a native stack reports if (viewportData.viewportType === 'stack') {
// 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];

View File

@ -1,7 +1,6 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { utilities as csUtils } from '@cornerstonejs/core'; import { VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
import { import {
SmartScrollbar, SmartScrollbar,
SmartScrollbarTrack, SmartScrollbarTrack,
@ -103,7 +102,7 @@ function ViewportSliceProgressScrollbar({
const onScrollbarValueChange = targetImageIndex => { const onScrollbarValueChange = targetImageIndex => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || isVolume3DViewportType(viewport)) { if (!viewport || viewport instanceof VolumeViewport3D) {
return; return;
} }

View File

@ -1,6 +1,5 @@
import { Enums, VolumeViewport3D } from '@cornerstonejs/core';
import { ViewportData } from './types'; import { ViewportData } from './types';
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;
@ -20,21 +19,16 @@ export function getViewportImageIds(viewportData: ViewportData): string[] {
} }
export function isProgressFullMode(viewportData: ViewportData, viewport): boolean { export function isProgressFullMode(viewportData: ViewportData, viewport): boolean {
if (!viewportData || !viewport || isVolume3DViewportType(viewport)) { if (!viewportData || !viewport || viewport instanceof VolumeViewport3D) {
return false; return false;
} }
// A stack renders the full progress UI; an acquisition-plane volume is the if (viewportData.viewportType === Enums.ViewportType.STACK) {
// 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') {
return adapter.isInAcquisitionPlane(); if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
return !!viewport.isInAcquisitionPlane?.();
} }
return false; return false;

View File

@ -1,8 +1,12 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { cache as cornerstoneCache, Enums, eventTarget, utilities } from '@cornerstonejs/core'; import {
cache as cornerstoneCache,
Enums,
eventTarget,
utilities,
VolumeViewport3D,
} from '@cornerstonejs/core';
import { useByteArray } from '@ohif/ui-next'; import { useByteArray } from '@ohif/ui-next';
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';
@ -93,52 +97,30 @@ export function useViewportSliceSync({
return; return;
} }
// Last values we pushed, so re-seeding on camera changes does not churn React
// 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); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || isVolume3DViewportType(viewport)) { if (viewport && !(viewport instanceof VolumeViewport3D)) {
return;
}
try { try {
const currentImageIndex = viewport.getCurrentImageIdIndex(); const currentImageIndex = viewport.getCurrentImageIdIndex();
const currentNumberOfSlices = getViewportSliceCount(viewportData, viewport); const currentNumberOfSlices = viewport.getNumberOfSlices();
pushSliceData(currentImageIndex, currentNumberOfSlices); setImageSliceData({
imageIndex: currentImageIndex,
numberOfSlices: currentNumberOfSlices,
});
} catch (error) { } catch (error) {
console.warn(error); console.warn(error);
} }
}; }
syncFromViewport(); const { viewportType } = viewportData;
const eventId =
// A post-mount camera carry (e.g. the layout-selector MPR protocol restoring (viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
// the prior stack slice onto the freshly-mounted volume viewport) moves the (viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
// camera and fires its slice events synchronously during the mount — before Enums.Events.IMAGE_RENDERED;
// 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);
if (!viewport || isVolume3DViewportType(viewport)) { if (!viewport || viewport instanceof VolumeViewport3D) {
return; return;
} }
@ -148,22 +130,16 @@ export function useViewportSliceSync({
} }
const nextNumberOfSlices = viewport.getNumberOfSlices(); const nextNumberOfSlices = viewport.getNumberOfSlices();
pushSliceData(nextImageIndex, nextNumberOfSlices); setImageSliceData({
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]);
} }

View File

@ -1,7 +1,11 @@
import { import {
getEnabledElement, getEnabledElement,
StackViewport,
VolumeViewport,
utilities as csUtils, utilities as csUtils,
Enums as CoreEnums,
Types as CoreTypes, Types as CoreTypes,
BaseVolumeViewport,
getRenderingEngines, getRenderingEngines,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { import {
@ -11,7 +15,6 @@ import {
annotation, annotation,
Types as ToolTypes, Types as ToolTypes,
SplineContourSegmentationTool, SplineContourSegmentationTool,
cancelActiveManipulations,
} from '@cornerstonejs/tools'; } from '@cornerstonejs/tools';
import { import {
SegmentInfo, SegmentInfo,
@ -29,22 +32,12 @@ 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';
import toggleVOISliceSync from './utils/toggleVOISliceSync'; import toggleVOISliceSync from './utils/toggleVOISliceSync';
import {
isStackViewportType,
isOrthographicViewportType,
isVolume3DViewportType,
isVolumeViewportType,
} from './utils/getLegacyViewportType';
import { viewportOperations as ops } from './services/ViewportService/backends/viewportOperations';
import { getViewportAdapter } from './services/ViewportService/adapter';
import { import {
usePositionPresentationStore, usePositionPresentationStore,
useSegmentationPresentationStore, useSegmentationPresentationStore,
@ -56,6 +49,8 @@ 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';
@ -148,15 +143,6 @@ 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);
@ -249,11 +235,23 @@ function commandsModule({
viewport.setViewReference(metadata); viewport.setViewReference(metadata);
viewport.render(); viewport.render();
// If the measurement is not visible inside the current viewport, move the /**
// camera to it. The operations backend handles the lane: legacy re-centers * If the measurement is not visible inside the current viewport,
// in-plane (getCamera/setCamera), native skips it (no in-plane pan yet, CS-14) * we need to move the camera to the measurement.
// since setViewReference above already navigated to the measurement's slice. */
if (ops.centerOnMeasurement(viewport, measurement)) { if (!isMeasurementWithinViewport(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,20 +304,6 @@ 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;
@ -340,7 +324,7 @@ function commandsModule({
// Todo: check if PMAP modality should be handled such as SEG // Todo: check if PMAP modality should be handled such as SEG
displaySet.Modality !== 'SEG' displaySet.Modality !== 'SEG'
? SegmentationRepresentations.Contour ? SegmentationRepresentations.Contour
: isVolume3DViewportType(viewport) : viewport.type === CoreEnums.ViewportType.VOLUME_3D
? SegmentationRepresentations.Surface ? SegmentationRepresentations.Surface
: SegmentationRepresentations.Labelmap; : SegmentationRepresentations.Labelmap;
@ -371,9 +355,6 @@ 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(
@ -923,28 +904,34 @@ 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);
// Stale/invalid viewport ids resolve to undefined; bail out before the VOI const { lower, upper } = csUtils.windowLevel.toLowHighRange(windowWidthNum, windowCenterNum);
// apply + render below would throw.
if (!viewport) {
return;
}
// Legacy volume viewports target a specific volume; the command owns that if (viewport instanceof BaseVolumeViewport) {
// resolution (it needs the service). The operations backend applies the VOI const volumeId = actions.getVolumeIdForDisplaySet({
// (legacy setProperties vs native setDisplaySetPresentation on the active binding). viewportId,
const volumeId = isVolumeViewportType(viewport)
? actions.getVolumeIdForDisplaySet({ viewportId, displaySetInstanceUID })
: undefined;
ops.setWindowLevel(viewport, {
windowWidth: windowWidthNum,
windowCenter: windowCenterNum,
volumeId,
displaySetInstanceUID, displaySetInstanceUID,
}); });
viewport.setProperties(
{
voiRange: {
upper,
lower,
},
},
volumeId
);
} else {
viewport.setProperties({
voiRange: {
upper,
lower,
},
});
}
viewport.render(); viewport.render();
}, },
toggleViewportColorbar: ({ viewportId, displaySetInstanceUIDs, options = {} }) => { toggleViewportColorbar: ({ viewportId, displaySetInstanceUIDs, options = {} }) => {
@ -999,12 +986,8 @@ function commandsModule({
}, },
getVolumeIdForDisplaySet: ({ viewportId, displaySetInstanceUID }) => { getVolumeIdForDisplaySet: ({ viewportId, displaySetInstanceUID }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
// `instanceof BaseVolumeViewport` is false for GenericViewport compat adapters, if (viewport instanceof BaseVolumeViewport) {
// so use the capability guard instead (cornerstone 5.x generic-viewport guide). const volumeIds = viewport.getAllVolumeIds();
// A stack-mode adapter passes the guard but getAllVolumeIds() returns [], so the
// result is the same null as before on stacks.
if (csUtils.viewportSupportsVolumeId(viewport)) {
const volumeIds = (viewport as CoreTypes.IVolumeViewport).getAllVolumeIds();
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID)); const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
return volumeId; return volumeId;
} }
@ -1183,11 +1166,25 @@ function commandsModule({
viewportId?: string; viewportId?: string;
newValue?: 'toggle' | boolean; newValue?: 'toggle' | boolean;
}) => { }) => {
const viewport = _resolveViewport(viewportId); const enabledElement = viewportId
if (!viewport) { ? _getViewportEnabledElement(viewportId)
: _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: ({
@ -1197,36 +1194,78 @@ function commandsModule({
viewportId?: string; viewportId?: string;
newValue?: 'toggle' | boolean; newValue?: 'toggle' | boolean;
}) => { }) => {
const viewport = _resolveViewport(viewportId); const enabledElement = viewportId
if (!viewport) { ? _getViewportEnabledElement(viewportId)
: _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 }) => {
const viewport = element === undefined ? _resolveViewport() : element.viewport; let enabledElement;
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 viewport = _resolveViewport(); const enabledElement = _getActiveViewportEnabledElement();
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 viewport = _resolveViewport(); const enabledElement = _getActiveViewportEnabledElement();
if (!viewport) { const scaleFactor = direction > 0 ? 0.9 : 1.1;
if (!enabledElement) {
return; return;
} }
ops.scaleBy(viewport, direction); const { viewport } = enabledElement;
if (viewport instanceof StackViewport) {
if (direction) {
const { parallelScale } = viewport.getCamera();
viewport.setCamera({ parallelScale: parallelScale * scaleFactor });
viewport.render(); 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 */
@ -1247,9 +1286,9 @@ function commandsModule({
// -> Copied from cornerstone3D jumpToSlice\_getImageSliceData() // -> Copied from cornerstone3D jumpToSlice\_getImageSliceData()
let numberOfSlices = 0; let numberOfSlices = 0;
if (isStackViewportType(viewport)) { if (viewport instanceof StackViewport) {
numberOfSlices = viewport.getImageIds().length; numberOfSlices = viewport.getImageIds().length;
} else if (isOrthographicViewportType(viewport)) { } else if (viewport instanceof VolumeViewport) {
numberOfSlices = csUtils.getImageSliceDataForVolumeViewport(viewport).numberOfSlices; numberOfSlices = csUtils.getImageSliceDataForVolumeViewport(viewport).numberOfSlices;
} else { } else {
throw new Error('Unsupported viewport type'); throw new Error('Unsupported viewport type');
@ -1305,15 +1344,24 @@ 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 };
// The legacy orthographic branch resolves the volumeId from the display set; if (viewport instanceof StackViewport) {
// fall back to the viewport's first display set (needs viewportGridService, so viewport.setProperties({ colormap });
// it is resolved here in the command rather than in the operations backend). }
if (isOrthographicViewportType(viewport) && !displaySetInstanceUID) {
if (viewport instanceof VolumeViewport) {
if (!displaySetInstanceUID) {
const { viewports } = viewportGridService.getState(); const { viewports } = viewportGridService.getState();
displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0]; displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0];
} }
ops.setColormap(viewport, { colormap, displaySetInstanceUID }); // 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();
@ -1419,7 +1467,9 @@ function commandsModule({
if (!viewport) { if (!viewport) {
return; return;
} }
ops.setPreset(viewport, preset); viewport.setProperties({
preset,
});
viewport.render(); viewport.render();
}, },
@ -1431,10 +1481,20 @@ function commandsModule({
setVolumeRenderingQulaity: ({ viewportId, volumeQuality }) => { setVolumeRenderingQulaity: ({ viewportId, volumeQuality }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) { const { actor } = viewport.getActors()[0];
return; const mapper = actor.getMapper();
} const image = mapper.getInputData();
ops.setVolumeRenderingQuality(viewport, volumeQuality); 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);
viewport.render(); viewport.render();
}, },
@ -1445,10 +1505,27 @@ function commandsModule({
*/ */
shiftVolumeOpacityPoints: ({ viewportId, shift }) => { shiftVolumeOpacityPoints: ({ viewportId, shift }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) { const { actor } = viewport.getActors()[0];
return; const ofun = actor.getProperty().getScalarOpacity(0);
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);
} }
ops.shiftVolumeOpacityPoints(viewport, shift); // Add offset
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();
}, },
@ -1464,10 +1541,25 @@ function commandsModule({
setVolumeLighting: ({ viewportId, options }) => { setVolumeLighting: ({ viewportId, options }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) { const { actor } = viewport.getActors()[0];
return; const property = actor.getProperty();
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 }) => {
@ -1475,13 +1567,7 @@ function commandsModule({
const getCrosshairInstances = toolGroupId => { const getCrosshairInstances = toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId); const toolGroup = toolGroupService.getToolGroup(toolGroupId);
// 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')); crosshairInstances.push(toolGroup.getToolInstance('Crosshairs'));
}
}; };
if (!viewportId) { if (!viewportId) {
@ -1489,10 +1575,8 @@ function commandsModule({
toolGroupIds.forEach(getCrosshairInstances); toolGroupIds.forEach(getCrosshairInstances);
} else { } else {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (toolGroup) {
getCrosshairInstances(toolGroup.id); getCrosshairInstances(toolGroup.id);
} }
}
crosshairInstances.forEach(ins => { crosshairInstances.forEach(ins => {
ins?.computeToolCenter(); ins?.computeToolCenter();
@ -1877,15 +1961,8 @@ 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 { customizationService } = servicesManager.services; const appConfig = extensionManager.appConfig;
const segmentLabelConfig = customizationService.getCustomization( const segmentLabelConfig = appConfig.segmentation?.segmentLabel;
'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 ?? [];
@ -1945,24 +2022,6 @@ 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);
@ -2060,11 +2119,7 @@ function commandsModule({
} }
segmentationService.addSegment(activeSegmentation.segmentationId); segmentationService.addSegment(activeSegmentation.segmentationId);
}, },
loadSegmentationDisplaySetsForViewport: ({ loadSegmentationDisplaySetsForViewport: ({ viewportId, displaySetInstanceUIDs }) => {
viewportId,
displaySetInstanceUIDs,
viewportType,
}) => {
const updatedViewports = getUpdatedViewportsForSegmentation({ const updatedViewports = getUpdatedViewportsForSegmentation({
viewportId, viewportId,
servicesManager, servicesManager,
@ -2084,21 +2139,13 @@ 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);
// Accept any viewport already rendering volume content (legacy ORTHOGRAPHIC if (!viewport || viewport.type !== CoreEnums.ViewportType.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;
} }
@ -2170,12 +2217,50 @@ function commandsModule({
viewportId?: string; viewportId?: string;
rotationMode?: 'apply' | 'set'; rotationMode?: 'apply' | 'set';
}) => { }) => {
const viewport = _resolveViewport(viewportId); const enabledElement = viewportId
if (!viewport) { ? _getViewportEnabledElement(viewportId)
: _getActiveViewportEnabledElement();
if (!enabledElement) {
return; return;
} }
ops.rotate(viewport, rotation, rotationMode);
const { viewport } = enabledElement;
if (viewport instanceof BaseVolumeViewport) {
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(); 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();
@ -2318,25 +2403,14 @@ function commandsModule({
targetSegmentInfo?: SegmentInfo; targetSegmentInfo?: SegmentInfo;
}) => { }) => {
if (!targetSegmentInfo) { if (!targetSegmentInfo) {
const sourceSegmentation = segmentationService.getSegmentation(
sourceSegmentInfo.segmentationId
);
const sourceCachedStats =
sourceSegmentation?.segments?.[sourceSegmentInfo.segmentIndex]?.cachedStats;
targetSegmentInfo = { targetSegmentInfo = {
segmentationId: sourceSegmentInfo.segmentationId, segmentationId: sourceSegmentInfo.segmentationId,
segmentIndex: segmentationService.getNextAvailableSegmentIndex( segmentIndex: segmentationService.getNextAvailableSegmentIndex(
sourceSegmentInfo.segmentationId sourceSegmentInfo.segmentationId
), ),
}; };
// Copy source cachedStats so jump-to-segment navigation works on the duplicate segment.
segmentationService.addSegment(targetSegmentInfo.segmentationId, { segmentationService.addSegment(targetSegmentInfo.segmentationId, {
segmentIndex: targetSegmentInfo.segmentIndex, segmentIndex: targetSegmentInfo.segmentIndex,
...(sourceCachedStats && {
cachedStats: csUtils.deepClone(sourceCachedStats) as Record<string, unknown>,
}),
}); });
} }
@ -2459,9 +2533,6 @@ function commandsModule({
removeMeasurement: { removeMeasurement: {
commandFn: actions.removeMeasurement, commandFn: actions.removeMeasurement,
}, },
cancelMeasurement: {
commandFn: actions.cancelMeasurement,
},
toggleLockMeasurement: { toggleLockMeasurement: {
commandFn: actions.toggleLockMeasurement, commandFn: actions.toggleLockMeasurement,
}, },
@ -2710,7 +2781,6 @@ 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,

View File

@ -60,11 +60,11 @@ const DicomUploadProgressItem = memo(
/> />
); );
case UploadStatus.InProgress: case UploadStatus.InProgress:
return <Icons.ByName name="icon-transferring" className="text-highlight" />; return <Icons.ByName name="icon-transferring" />;
case UploadStatus.Failed: case UploadStatus.Failed:
return <Icons.ByName name="icon-alert-small" className="text-destructive" />; return <Icons.ByName name="icon-alert-small" />;
case UploadStatus.Cancelled: case UploadStatus.Cancelled:
return <Icons.ByName name="icon-alert-outline" className="text-highlight" />; return <Icons.ByName name="icon-alert-outline" />;
default: default:
return <></>; return <></>;
} }

View File

@ -18,7 +18,7 @@ function TrackingStatus({ viewportId }: { viewportId: string }) {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>
<Icons.StatusTracking className="h-4 w-4 text-highlight" /> <Icons.StatusTracking className="h-4 w-4" />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">

View File

@ -83,16 +83,10 @@ 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 === 0 || opacity == null
? backgroundDisplaySet?.displaySetInstanceUID ? backgroundDisplaySet?.displaySetInstanceUID
: foregroundDisplaySets[0]?.displaySetInstanceUID; : foregroundDisplaySets[0].displaySetInstanceUID;
return dsUID === targetUID; return dsUID === targetUID;
}); });

View File

@ -2,7 +2,6 @@ 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({
@ -37,6 +36,8 @@ 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;
@ -71,17 +72,8 @@ function ViewportOrientationMenu({
const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID); const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID);
// A viewport already rendering in volume mode (legacy ORTHOGRAPHIC OR a next // If viewport is not already a volume type, we need to convert it
// viewport that reports planarNext but renders volume actors, e.g. a CT+PET if (currentViewportType !== Enums.ViewportType.ORTHOGRAPHIC) {
// 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,

View File

@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useState, ReactElement, useMemo } from '
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
import { PanelSection, WindowLevel } from '@ohif/ui-next'; import { PanelSection, WindowLevel } from '@ohif/ui-next';
import { Enums, eventTarget, utilities as csUtils, Types } from '@cornerstonejs/core'; import { BaseVolumeViewport, Enums, eventTarget } from '@cornerstonejs/core';
import { useActiveViewportDisplaySets } from '@ohif/core'; import { useActiveViewportDisplaySets } from '@ohif/core';
import { import {
getNodeOpacity, getNodeOpacity,
@ -27,16 +27,10 @@ const ViewportWindowLevel = ({
const getViewportsWithVolumeIds = useCallback( const getViewportsWithVolumeIds = useCallback(
(volumeIds: string[]) => { (volumeIds: string[]) => {
const renderingEngine = cornerstoneViewportService.getRenderingEngine(); const renderingEngine = cornerstoneViewportService.getRenderingEngine();
// getVolumeViewports() was removed in the GenericViewport architecture const viewports = renderingEngine.getVolumeViewports();
// (a PLANAR_NEXT viewport can be volume-capable without being a VolumeViewport).
// Official replacement: getViewports() + the viewportSupportsVolumeCompatibility
// capability guard (cornerstone codemod cornerstone3d/5/generic-viewport).
const viewports = renderingEngine
.getViewports()
.filter(csUtils.viewportSupportsVolumeCompatibility);
return viewports.filter(vp => { return viewports.filter(vp => {
const viewportVolumeIds = (vp as Types.IVolumeViewport).getAllVolumeIds(); const viewportVolumeIds = vp instanceof BaseVolumeViewport ? vp.getAllVolumeIds() : [];
return ( return (
volumeIds.length === viewportVolumeIds.length && volumeIds.length === viewportVolumeIds.length &&
volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId)) volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId))
@ -130,9 +124,8 @@ const ViewportWindowLevel = ({
return; return;
} }
const viewportVolumeIds = csUtils.viewportSupportsVolumeId(viewport) const viewportVolumeIds =
? (viewport as Types.IVolumeViewport).getAllVolumeIds() viewport instanceof BaseVolumeViewport ? viewport.getAllVolumeIds() : [];
: [];
const viewports = getViewportsWithVolumeIds(viewportVolumeIds); const viewports = getViewportsWithVolumeIds(viewportVolumeIds);
viewports.forEach(vp => { viewports.forEach(vp => {

View File

@ -2,7 +2,6 @@ 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
@ -102,18 +101,8 @@ export const getWindowLevelsData = async (
return []; return [];
} }
// The per-volume histogram WL panel is a legacy volume-viewport feature; the const volumeIds = (viewport as Types.IBaseVolumeViewport).getAllVolumeIds();
// adapter reports no volumeIds for native viewports (and legacy stacks), so const viewportProperties = viewport.getProperties();
// 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
? { ? {

View File

@ -1,54 +1,15 @@
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, foregroundDisplaySets } = useViewportDisplaySets(viewportId); const { viewportDisplaySets } = useViewportRendering(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>(
defaultDisplaySetUID viewportDisplaySets?.[0]?.displaySetInstanceUID
); );
// 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, {
@ -88,7 +49,6 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React
<Tabs <Tabs
value={activeDisplaySetUID} value={activeDisplaySetUID}
onValueChange={displaySetUID => { onValueChange={displaySetUID => {
userSelectedRef.current = true;
setActiveDisplaySetUID(displaySetUID); setActiveDisplaySetUID(displaySetUID);
}} }}
> >

View File

@ -1,204 +0,0 @@
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;

View File

@ -8,9 +8,6 @@ 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';
@ -35,9 +32,6 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan
...colorbarCustomization, ...colorbarCustomization,
...modalityColorMapCustomization, ...modalityColorMapCustomization,
...windowLevelPresetsCustomization, ...windowLevelPresetsCustomization,
...toolbarButtonsCustomization,
...segmentationToolbarCustomization,
...getToolGroupToolsCustomization({ commandsManager }),
...miscCustomization, ...miscCustomization,
...captureViewportModalCustomization, ...captureViewportModalCustomization,
...viewportDownloadWarningCustomization, ...viewportDownloadWarningCustomization,

View File

@ -8,11 +8,7 @@ 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;
// NOTE: access transferDenaturalizedDataset / fixMultiValueKeys lazily (at call const { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils;
// 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',
@ -143,8 +139,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 dicomWebUtils.transferDenaturalizedDataset( return transferDenaturalizedDataset(
denaturalizeDataset(dicomWebUtils.fixMultiValueKeys(instanceMap.get(imageId))) denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId)))
); );
} }

View File

@ -1,6 +1,5 @@
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';
@ -310,11 +309,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
}; };
} }
// Recognize native "next" volume viewports too. A next MPR/volume viewport if (viewport.type !== 'orthographic') {
// 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,
}; };
@ -337,7 +332,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
evaluate: ({ viewportId }) => { evaluate: ({ viewportId }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || !isVolumeRenderingViewport(viewport)) { if (!viewport || viewport.type !== 'orthographic') {
return { return {
disabled: true, disabled: true,
}; };
@ -454,7 +449,8 @@ 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 = toolBindings?.some(binding => binding.modifierKey != null) ?? false; const hasModifierKey =
toolBindings?.some(binding => binding.modifierKey != null) ?? false;
return { return {
disabled: false, disabled: false,
@ -463,7 +459,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,
}; };
}, },
}, },
@ -547,9 +543,8 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
const propId = button.id; const propId = button.id;
const adapter = getViewportAdapter(viewport); const properties = viewport.getProperties();
const properties = adapter.getPresentation(); const camera = viewport.getCamera();
const camera = adapter.getViewState();
const prop = camera?.[propId] || properties?.[propId]; const prop = camera?.[propId] || properties?.[propId];

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useSystem } from '@ohif/core/src'; import { useSystem } from '@ohif/core/src';
import { useViewportDisplaySets } from './useViewportDisplaySets'; import { useViewportDisplaySets } from './useViewportDisplaySets';
import { isVolumeViewportType } from '../utils/getLegacyViewportType'; import { BaseVolumeViewport } from '@cornerstonejs/core';
/** /**
* Hook that provides measurement tracking information for a viewport * Hook that provides measurement tracking information for a viewport
@ -54,7 +54,7 @@ export function useMeasurementTracking({ viewportId }: { viewportId: string }) {
const viewport = cornerstoneViewportService?.getCornerstoneViewport(viewportId); const viewport = cornerstoneViewportService?.getCornerstoneViewport(viewportId);
const SeriesInstanceUID = backgroundDisplaySet.SeriesInstanceUID; const SeriesInstanceUID = backgroundDisplaySet.SeriesInstanceUID;
if (isVolumeViewportType(viewport)) { if (viewport instanceof BaseVolumeViewport) {
const currentImageId = viewport?.getCurrentImageId(); const currentImageId = viewport?.getCurrentImageId();
if (!currentImageId) { if (!currentImageId) {

View File

@ -1,9 +1,16 @@
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 } from '@cornerstonejs/core'; import {
import { isVolume3DViewportType } from '../utils/getLegacyViewportType'; StackViewport,
import { getViewportAdapter, LEGACY_OPACITY_GAMMA } from '../services/ViewportService/adapter'; Types,
VolumeViewport3D,
utilities,
Enums,
BaseVolumeViewport,
cache,
} from '@cornerstonejs/core';
import { getDataIdForViewport } from '../utils/getDataIdForViewport';
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';
@ -88,26 +95,14 @@ 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;
}
if (Array.isArray(opacityVal)) { const linearToOpacity = (linearValue: number): number => {
return opacityVal.reduce((max: number, point) => { return Math.pow(linearValue, GAMMA);
const value = typeof point === 'number' ? point : (point?.opacity ?? 0); };
return Math.max(max, value);
}, 0);
}
return opacityVal as number; const opacityToLinear = (opacityValue: number): number => {
return Math.pow(opacityValue, 1.0 / GAMMA);
}; };
/** /**
@ -135,29 +130,13 @@ export function useViewportRendering(
const [viewport, setViewport] = useState<Types.IViewport | null>(() => const [viewport, setViewport] = useState<Types.IViewport | null>(() =>
viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null
); );
const [is3DVolume, setIs3DVolume] = useState(isVolume3DViewportType(viewport)); const [is3DVolume, setIs3DVolume] = useState(viewport instanceof VolumeViewport3D);
// 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, foregroundDisplaySets } = useViewportDisplaySets(viewportId); const { viewportDisplaySets } = 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)
@ -166,21 +145,12 @@ 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, foregroundDisplaySets]); }, [options?.displaySetInstanceUID, viewportDisplaySets]);
const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null; const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null;
@ -249,14 +219,28 @@ export function useViewportRendering(
return; return;
} }
const voxelManager = getViewportAdapter(viewport).getVoxelManagerForDisplaySet( if (!(viewport instanceof BaseVolumeViewport)) {
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] });
@ -280,15 +264,18 @@ export function useViewportRendering(
}, [allWindowLevelPresets, activeDisplaySetInstanceUID]); }, [allWindowLevelPresets, activeDisplaySetInstanceUID]);
useEffect(() => { useEffect(() => {
setIs3DVolume(isVolume3DViewportType(viewport)); setIs3DVolume(viewport instanceof VolumeViewport3D);
if (!viewport || !activeDisplaySetInstanceUID) { if (!viewport || !activeDisplaySetInstanceUID) {
return; return;
} }
try { try {
const adapter = getViewportAdapter(viewport); const dataId = getDataIdForViewport(viewport as unknown, activeDisplaySetInstanceUID);
const dataId = adapter.getDataIdForDisplaySet(activeDisplaySetInstanceUID);
const properties = adapter.getPresentation(dataId ?? activeDisplaySetInstanceUID); const properties =
dataId != null
? (viewport as Types.IBaseVolumeViewport).getProperties(dataId)
: viewport.getProperties();
if (!properties) { if (!properties) {
return; return;
@ -297,27 +284,19 @@ 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 opacity = resolveOpacityScalar(properties.colormap.opacity); const opacityVal = properties.colormap.opacity;
if (opacity !== undefined) { const opacity = Array.isArray(opacityVal)
? (opacityVal as unknown as number[]).reduce(
(max, current) => Math.max(max, current),
0
)
: opacityVal;
setOpacityState(opacity); setOpacityState(opacity);
setOpacityLinearState(opacityToLinear(opacity)); setOpacityLinearState(opacityToLinear(opacity));
} }
}
if (properties.colormap?.threshold !== undefined) { if (properties.colormap?.threshold !== undefined) {
setThresholdState(properties.colormap.threshold); setThresholdState(properties.colormap.threshold);
@ -386,11 +365,8 @@ export function useViewportRendering(
} }
if (colormap.opacity !== undefined) { if (colormap.opacity !== undefined) {
const opacity = resolveOpacityScalar(colormap.opacity); setOpacityState(colormap.opacity);
if (opacity !== undefined) { setOpacityLinearState(opacityToLinear(colormap.opacity));
setOpacityState(opacity);
setOpacityLinearState(opacityToLinear(opacity));
}
} }
}; };
@ -597,7 +573,7 @@ export function useViewportRendering(
const setOpacity = useCallback( const setOpacity = useCallback(
(opacityValue: number) => { (opacityValue: number) => {
if (!viewport) { if (!viewport || !(viewport instanceof BaseVolumeViewport)) {
return; return;
} }
@ -607,10 +583,32 @@ 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 (getViewportAdapter(viewport).setLayerOpacity(displaySetInstanceUID, opacityValue)) { if (!volumeId) {
viewport.render(); return;
} }
// 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]
); );
@ -626,16 +624,32 @@ export function useViewportRendering(
const setThreshold = useCallback( const setThreshold = useCallback(
(thresholdValue: number) => { (thresholdValue: number) => {
if (!viewport) { if (!viewport || !(viewport instanceof BaseVolumeViewport)) {
return; return;
} }
const displaySetInstanceUID = validateActiveDisplaySet();
setThresholdState(thresholdValue); setThresholdState(thresholdValue);
if (getViewportAdapter(viewport).setLayerThreshold(displaySetInstanceUID, thresholdValue)) { const displaySetInstanceUID = validateActiveDisplaySet();
viewport.render(); const volumeIds = viewport.getAllVolumeIds();
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
if (!volumeId) {
return;
} }
console.debug('🚀 ~ thresholdValue:', thresholdValue);
viewport.setProperties(
{
colormap: {
threshold: thresholdValue,
},
},
volumeId
);
viewport.render();
}, },
[validateActiveDisplaySet, viewport] [validateActiveDisplaySet, viewport]
); );
@ -651,13 +665,41 @@ export function useViewportRendering(
return null; return null;
} }
const colormap = getViewportAdapter(viewport).getColormap(activeDisplaySetInstanceUID); if (viewport instanceof StackViewport) {
const { colormap } = viewport.getProperties();
if (!colormap) {
return ( return (
colormap ||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
colorbarProperties?.colormaps?.[0] colorbarProperties?.colormaps?.[0]
); );
}
return colormap;
}
const actorEntries = viewport.getActors();
const actorEntry = actorEntries?.find(entry =>
entry.referencedId?.includes(activeDisplaySetInstanceUID)
);
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 (

View File

@ -7,8 +7,7 @@ import {
imageRetrievalPoolManager, imageRetrievalPoolManager,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools'; import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata'; import { Types } from '@ohif/core';
import { Types, AnnotationPersistenceService } from '@ohif/core';
import Enums from './enums'; import Enums from './enums';
import init from './init'; import init from './init';
@ -37,18 +36,6 @@ 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';
@ -115,7 +102,11 @@ const cornerstoneExtension: Types.Extensions.Extension = {
*/ */
id, id,
onModeEnter: ({ servicesManager, commandsManager, extensionManager }: withAppTypes): void => { onModeEnter: ({
servicesManager,
commandsManager,
extensionManager,
}: withAppTypes): void => {
const { cornerstoneViewportService, toolbarService, segmentationService } = const { cornerstoneViewportService, toolbarService, segmentationService } =
servicesManager.services; servicesManager.services;
@ -161,10 +152,7 @@ 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( const stackOptions = update(DEFAULT_STACK_RETRIEVE_OPTIONS, toUpdateSpec(config)) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS;
DEFAULT_STACK_RETRIEVE_OPTIONS,
toUpdateSpec(config)
) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS;
imageRetrieveMetadataProvider.add('stack', stackOptions); imageRetrieveMetadataProvider.add('stack', stackOptions);
}, },
getPanelModule, getPanelModule,
@ -181,11 +169,6 @@ 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();
@ -216,7 +199,6 @@ 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);
@ -298,14 +280,6 @@ 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,

View File

@ -27,12 +27,6 @@ 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';
@ -68,72 +62,21 @@ 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: {
...cornerstone.getConfiguration().rendering, ...cornerstone.getConfiguration().rendering,
strictZSpacingForVolumeViewport: appConfig.strictZSpacingForVolumeViewport, strictZSpacingForVolumeViewport: appConfig.strictZSpacingForVolumeViewport,
// Opt-in: route legacy viewport types through the new GenericViewport render
// paths while keeping the legacy public API via compatibility adapters.
// No-op on cornerstone builds that predate the GenericViewport architecture.
useGenericViewport: Boolean(appConfig.useGenericViewport),
}, },
}); });
// 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) {
@ -235,6 +178,14 @@ export default async function init({
); );
}); });
// add metadata providers
metaData.addProvider(
csUtilities.calibratedPixelSpacingMetadataProvider.get.bind(
csUtilities.calibratedPixelSpacingMetadataProvider
)
); // this provider is required for Calibration tool
metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999);
// These are set reasonably low to allow for interleaved retrieves and slower // These are set reasonably low to allow for interleaved retrieves and slower
// connections. // connections.
imageLoadPoolManager.maxNumRequests = { imageLoadPoolManager.maxNumRequests = {
@ -246,16 +197,6 @@ export default async function init({
initWADOImageLoader(userAuthenticationService, appConfig, extensionManager); initWADOImageLoader(userAuthenticationService, appConfig, extensionManager);
// Add OHIF metadata providers after dicomImageLoader.init().
// The linked metadata branch clears providers during loader init.
metaData.addProvider(csUtilities.genericMetadataProvider.get, 9998);
metaData.addProvider(
csUtilities.calibratedPixelSpacingMetadataProvider.get.bind(
csUtilities.calibratedPixelSpacingMetadataProvider
)
); // this provider is required for Calibration tool
metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999);
/* Measurement Service */ /* Measurement Service */
this.measurementServiceSource = connectToolsToMeasurementService({ this.measurementServiceSource = connectToolsToMeasurementService({
servicesManager, servicesManager,

View File

@ -3,7 +3,6 @@ import {
WindowLevelTool, WindowLevelTool,
SegmentBidirectionalTool, SegmentBidirectionalTool,
StackScrollTool, StackScrollTool,
PlanarRotateTool,
VolumeRotateTool, VolumeRotateTool,
ZoomTool, ZoomTool,
MIPJumpToClickTool, MIPJumpToClickTool,
@ -40,7 +39,7 @@ import {
OrientationMarkerTool, OrientationMarkerTool,
WindowLevelRegionTool, WindowLevelRegionTool,
SegmentSelectTool, SegmentSelectTool,
ClickSegmentTool, RegionSegmentPlusTool,
SegmentLabelTool, SegmentLabelTool,
LivewireContourSegmentationTool, LivewireContourSegmentationTool,
SculptorTool, SculptorTool,
@ -75,7 +74,6 @@ 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);
@ -114,7 +112,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(SegmentLabelTool); addTool(SegmentLabelTool);
addTool(LabelmapSlicePropagationTool); addTool(LabelmapSlicePropagationTool);
addTool(MarkerLabelmapTool); addTool(MarkerLabelmapTool);
addTool(ClickSegmentTool); addTool(RegionSegmentPlusTool);
addTool(LivewireContourSegmentationTool); addTool(LivewireContourSegmentationTool);
addTool(SculptorTool); addTool(SculptorTool);
addTool(SplineContourSegmentationTool); addTool(SplineContourSegmentationTool);
@ -140,7 +138,6 @@ 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,
@ -178,7 +175,7 @@ const toolNames = {
SegmentLabel: SegmentLabelTool.toolName, SegmentLabel: SegmentLabelTool.toolName,
LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName, LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName,
MarkerLabelmap: MarkerLabelmapTool.toolName, MarkerLabelmap: MarkerLabelmapTool.toolName,
ClickSegment: ClickSegmentTool.toolName, RegionSegmentPlus: RegionSegmentPlusTool.toolName,
LivewireContourSegmentation: LivewireContourSegmentationTool.toolName, LivewireContourSegmentation: LivewireContourSegmentationTool.toolName,
SculptorTool: SculptorTool.toolName, SculptorTool: SculptorTool.toolName,
SplineContourSegmentation: SplineContourSegmentationTool.toolName, SplineContourSegmentation: SplineContourSegmentationTool.toolName,

View File

@ -462,12 +462,6 @@ 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,
@ -512,11 +506,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
*/ */
...(persistedData || {}), ...(data.annotation.data || {}),
text: persistedData.text, text: data.annotation.data.text,
handles: { ...persistedHandles, points: handlePoints }, handles: { ...data.annotation.data.handles },
cachedStats: { ...(persistedData.cachedStats || {}) }, cachedStats: { ...data.annotation.data.cachedStats },
label: persistedData.label, label: data.annotation.data.label,
frameNumber, frameNumber,
}, },
}; };
@ -537,12 +531,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.
commandsManager.run('cancelMeasurement'); const element = getActiveViewportEnabledElement(viewportGridService)?.viewport?.element;
if (element) {
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId); cancelActiveManipulations(element);
if (removedAnnotation) {
removeAnnotation(removedMeasurementId);
} }
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
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) {

View File

@ -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 { getViewportAdapter } from '../ViewportService/adapter'; import { getDataIdForViewport } from '../../utils/getDataIdForViewport';
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 adapter = getViewportAdapter(viewport); const actorEntries = viewport.getActors();
if (!adapter.hasContent()) { if (!actorEntries || actorEntries.length === 0) {
return; return;
} }
@ -74,8 +74,8 @@ export default class ColorbarService extends PubSubService {
return; return;
} }
const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID); const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
const properties = adapter.getPresentation(dataId); const properties = dataId ? viewport.getProperties(dataId) : viewport.getProperties();
const colormap = properties?.colormap; const colormap = properties?.colormap;
if (activeColormapName && !colormap) { if (activeColormapName && !colormap) {
@ -222,18 +222,16 @@ 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);
if (!viewport) { const actorEntries = viewport?.getActors();
return; if (!viewport || !actorEntries || actorEntries.length === 0) {
}
const adapter = getViewportAdapter(viewport);
if (!adapter.hasContent()) {
return; return;
} }
// Address the display set's binding (volumeId on legacy multi-volume, bare // Get the appropriate dataId for this viewport/displaySet combination
// UID on native, active binding otherwise) const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID);
adapter.setPresentation({ colormap }, dataId); // Set properties with or without dataId based on what the viewport supports
viewport.setProperties({ colormap }, dataId);
if (immediate) { if (immediate) {
viewport.render(); viewport.render();

View File

@ -41,39 +41,12 @@ 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 (
isReconstructableFusion && cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC ||
dataShapeType === Enums.ViewportType.STACK && cs3DViewportType === Enums.ViewportType.VOLUME_3D
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 (dataShapeType === Enums.ViewportType.STACK) { } else if (cs3DViewportType === 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,
@ -91,9 +64,6 @@ 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;
} }
@ -104,13 +74,7 @@ class CornerstoneCacheService {
dataSource, dataSource,
displaySetService displaySetService
): Promise<VolumeViewportData | StackViewportData> { ): Promise<VolumeViewportData | StackViewportData> {
// Decide stack-vs-volume rebuild from the persisted data shape, NOT viewportType: if (viewportData.viewportType === Enums.ViewportType.STACK) {
// 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);
@ -122,10 +86,7 @@ class CornerstoneCacheService {
}); });
return { return {
// Preserve the original viewportType (legacy STACK or native PLANAR_NEXT); viewportType: Enums.ViewportType.STACK,
// 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,
@ -167,7 +128,6 @@ class CornerstoneCacheService {
displaySets, displaySets,
viewportData.viewportType viewportData.viewportType
); );
newViewportData.dataShapeType = dataShapeType;
return newViewportData; return newViewportData;
} }

View File

@ -19,7 +19,6 @@ 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'),
@ -915,101 +914,6 @@ 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
@ -1567,136 +1471,6 @@ 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', () => {
@ -2025,10 +1799,6 @@ 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',
}, },
@ -2924,47 +2694,6 @@ 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;

View File

@ -10,7 +10,6 @@ import {
metaData, metaData,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { ViewportType } from '@cornerstonejs/core/enums'; import { ViewportType } from '@cornerstonejs/core/enums';
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
import { import {
Enums as csToolsEnums, Enums as csToolsEnums,
@ -27,17 +26,6 @@ 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;
@ -50,7 +38,7 @@ const {
const { const {
getLabelmapImageIds, getLabelmapImageIds,
helpers: { convertStackToVolumeLabelmap }, helpers: { convertStackToVolumeLabelmap },
state: { addColorLUT }, state: { addColorLUT, updateLabelmapSegmentationImageReferences },
triggerSegmentationEvents: { triggerSegmentationRepresentationModified }, triggerSegmentationEvents: { triggerSegmentationRepresentationModified },
} = cstSegmentation; } = cstSegmentation;
@ -106,7 +94,7 @@ const EVENTS = {
const VALUE_TYPES = {}; const VALUE_TYPES = {};
class SegmentationService extends PubSubService implements ISegmentationServiceInternals { class SegmentationService extends PubSubService {
static REGISTRATION = { static REGISTRATION = {
name: 'segmentationService', name: 'segmentationService',
altName: 'SegmentationService', altName: 'SegmentationService',
@ -117,8 +105,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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;
@ -131,24 +117,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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 {
@ -325,21 +293,12 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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);
@ -348,53 +307,29 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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;
const defaultRepresentationType: csToolsEnums.SegmentationRepresentations = const defaultRepresentationType: csToolsEnums.SegmentationRepresentations =
isVolume3DViewportType(csViewport) ? SURFACE : LABELMAP; csViewport.type === ViewportType.VOLUME_3D ? SURFACE : LABELMAP;
let representationTypeToUse = type || defaultRepresentationType; let representationTypeToUse = type || defaultRepresentationType;
if (representationTypeToUse === LABELMAP) { if (representationTypeToUse === LABELMAP) {
({ representationTypeToUse, isConverted } = await this._segBackend( const { isVolumeViewport, isVolumeSegmentation } = this.determineViewportAndSegmentationType(
csViewport csViewport,
).classifyAndPrepareLabelmapAdd( segmentation
) || { 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(
@ -549,22 +484,10 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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');
} }
// Use the same imageIds as SEG parse (_loadSegments stores these on segDisplaySet). const imageIds = images.map(image => image.imageId);
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),
@ -645,36 +568,32 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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;
} }
@ -849,16 +768,9 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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 if ( } else {
'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`
);
} }
} }
@ -984,7 +896,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
active?: boolean; active?: boolean;
color?: csTypes.Color; // Add color type color?: csTypes.Color; // Add color type
visibility?: boolean; // Add visibility option visibility?: boolean; // Add visibility option
cachedStats?: Record<string, unknown>;
} = {} } = {}
): void { ): void {
if (config?.segmentIndex === 0) { if (config?.segmentIndex === 0) {
@ -1567,16 +1478,12 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
viewportIds.forEach(viewportId => { viewportIds.forEach(viewportId => {
const { viewport } = getEnabledElementByViewportId(viewportId); const { viewport } = getEnabledElementByViewportId(viewportId);
if (!viewport) { if (!viewport?.jumpToWorld) {
return; return;
} }
// Recenter via the backend twin: legacy jumpToWorld, or native setViewReference viewport.jumpToWorld(world);
// (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);
didJump &&
highlightSegment && highlightSegment &&
this.highlightSegment( this.highlightSegment(
segmentationId, segmentationId,
@ -1682,11 +1589,82 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
); );
} }
// Labelmap-add classification (determineViewportAndSegmentationType + private determineViewportAndSegmentationType(csViewport, segmentation) {
// handleViewportConversion + the stack/volume case handlers) now lives in the const isVolumeViewport =
// segmentation backend twins (backends/{Legacy,Next}SegmentationBackend), routed csViewport.type === ViewportType.ORTHOGRAPHIC || csViewport.type === ViewportType.VOLUME_3D;
// per viewport via _segBackend(). The legacy twin reaches the viewport-recreation const isVolumeSegmentation = 'volumeId' in segmentation.representationData[LABELMAP];
// and data-volume-conversion helpers below through ISegmentationServiceInternals. return { isVolumeViewport, isVolumeSegmentation };
}
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 (csViewport.type === ViewportType.VOLUME_3D) {
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,
@ -1696,7 +1674,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
isConverted: boolean, isConverted: boolean,
config?: { config?: {
blendMode?: csEnums.BlendModes; blendMode?: csEnums.BlendModes;
useSliceRendering?: boolean;
} }
): Promise<void> { ): Promise<void> {
const representation = { const representation = {
@ -1724,7 +1701,7 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
addRepresentation(); addRepresentation();
} }
} }
public async handleVolumeViewport( private async handleVolumeViewport(
viewport: csTypes.IVolumeViewport, viewport: csTypes.IVolumeViewport,
segmentation: SegmentationData, segmentation: SegmentationData,
isVolumeSegmentation: boolean isVolumeSegmentation: boolean
@ -1742,7 +1719,7 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
} }
} }
public async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean> { private 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);
@ -1781,7 +1758,7 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
return true; return true;
} }
public async attemptStackToVolumeConversion( private async attemptStackToVolumeConversion(
viewport: csTypes.IStackViewport, viewport: csTypes.IStackViewport,
segmentation: SegmentationData, segmentation: SegmentationData,
viewportId: string, viewportId: string,
@ -1801,10 +1778,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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) {

View File

@ -1,102 +0,0 @@
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;
}

View File

@ -1,44 +0,0 @@
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>;
}

View File

@ -1,139 +0,0 @@
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;
}
}

View File

@ -1,159 +0,0 @@
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;
}
}

View File

@ -1,4 +0,0 @@
export type { ISegmentationBackend, LabelmapAddClassification } from './ISegmentationBackend';
export type { ISegmentationServiceInternals } from './ISegmentationServiceInternals';
export { LegacySegmentationBackend } from './LegacySegmentationBackend';
export { NextSegmentationBackend } from './NextSegmentationBackend';

Some files were not shown because too many files have changed in this diff Show More