[WIP] feat: Add ability to use new display set split rules - #6137
[WIP] feat: Add ability to use new display set split rules#6137wayfarer3130 wants to merge 15 commits into
Conversation
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
❌ Deploy Preview for ohif-dev failed. Why did it fail? →
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds opt-in metadata-driven display-set splitting with safe ChangesMetadata display-set splitting
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant DisplaySetService
participant CustomizationService
participant SplitRulesEngine
participant DisplaySetFactory
participant displaySetStore
DisplaySetService->>CustomizationService: read useMetadataDisplaySet
DisplaySetService->>SplitRulesEngine: group instances by splitRules
SplitRulesEngine-->>DisplaySetService: matched groups and unmatched instances
DisplaySetService->>DisplaySetFactory: createDisplaySetFromGroup
DisplaySetFactory->>displaySetStore: store split display set
DisplaySetService->>DisplaySetService: route unmatched instances to SOP handlers
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Full details: Description checkExplanation The description provides extensive context, change details, dependency information, testing instructions, and documentation of known limitations. However, it omits the required Checklist section and does not mark the required PR, Code, Public Documentation Updates, and Tested Environment items. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
extensions/default/src/getSopClassHandlerModule.js (1)
15-15: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueRemove the unused second argument from
makeDisplaySetcalls
makeDisplaySetonly forwardsinstancesandappContexttomakeImageSetDisplaySet, soinstanceIndex/displaySets.lengthare dead arguments here. Remove them from the three call sites to avoid confusion.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@extensions/default/src/getSopClassHandlerModule.js` at line 15, Update the three call sites of makeDisplaySet to pass only the required instances argument, removing the unused instanceIndex and displaySets.length arguments while preserving the existing makeDisplaySet implementation.platform/core/src/types/DisplaySet.ts (1)
93-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding a type annotation for the
displaySetServiceparameter.The
displaySetServiceparameter onupdateInstanceshas no type annotation, making it implicitlyany. ImportingDisplaySetServicedirectly would create a circular dependency (the services layer imports fromtypes/), so a minimal interface would preserve type safety without the architectural concern.♻️ Suggested interface to avoid circular dependency
export type DisplaySet = { displaySetInstanceUID: string; instances: InstanceMetadata[]; isReconstructable?: boolean; StudyInstanceUID: string; SeriesInstanceUID?: string; SeriesNumber?: number; SeriesDescription?: string; numImages?: number; unsupported?: boolean; Modality?: string; imageIds?: string[]; images?: unknown[]; label?: string; /** Flag indicating if this is an overlay display set (e.g., SEG, RTSTRUCT) */ isOverlayDisplaySet?: boolean; /** Flag indicating this is a derived dataset */ isDerived?: boolean; /** flag indicating if it supports window level */ supportsWindowLevel?: boolean; // Details about how to display: /** * A URL that can be used to display the thumbnail. Typically a data url * This can be set to null to avoid trying to display a thumbnail, eg for * display sets without a thumbnail. */ thumbnailSrc?: string; /** A fetch method to get the thumbnail */ getThumbnailSrc?(imageId?: string): Promise<string>; /** An opaque type of this viewport, used internally to specify which viewport to use */ viewportType; /** * A fetch URL to display the content. This is used for content such as * pdf display. */ renderedUrl?: string; /** * The instance UID of the display set that this display set references. * This is used to determine if the display set is a referenced display set. * It usually is for SEG, RTSTRUCT, etc. */ referencedDisplaySetInstanceUID?: string; /** * The FrameOfReferenceUID shared by every frame within this display set. * It will be undefined if the frames do not all share the same Frame of Reference. */ FrameOfReferenceUID?: string; SeriesDate?: string; SeriesTime?: string; instance?: InstanceMetadata; /** * The predecessor image id refers to the SOP instance that is currently loaded * into this display set for SEG/SR/RTSTRUCT type values. The name is chosen * for consistency when this value is used as the origin instance * for saving a new instance intended to replace this instance where the * new instance has a "predecessor sequence". */ predecessorImageId?: string; /** * isLoaded is used for display sets containing a load operation that * is required before the display set can be shown. This is separate from * isHydrated, which means it is loaded into view. */ isLoaded?: boolean; isHydrated?: boolean; isRehydratable?: boolean; /** * The name of the comparison function (for sort) to use when comparing display * sets that are coming from same series instanceUID. */ compareSameSeries?: string; + /** + * Minimal interface for the DisplaySetService methods that + * `updateInstances` needs, avoiding a circular import from + * `types/` into the services layer. + */ /** * The deterministic, rule-namespaced group key assigned by the * `@cornerstonejs/metadata` split-rules engine when this display set was * created via the `useMetadataDisplaySet` customization. Used to reconcile * re-splits of the same series with already-created display sets. */ splitKey?: string; /** The id of the split rule that created this display set, when applicable. */ splitRuleId?: string; /** * Incremental-merge hook for split-rule display sets. Intentionally named * differently from `addInstances` (the SOP-class-handler merge hook) so the * legacy handler loop never feeds unmatched instances into split-rule * display sets. Returns the updated display set, or undefined when the * display set cannot merge the instances. */ - updateInstances?(instances: InstanceMetadata[], displaySetService): DisplaySet | undefined; + updateInstances?( + instances: InstanceMetadata[], + displaySetService: DisplaySetServiceLike + ): DisplaySet | undefined; }; + +/** + * Minimal interface for the DisplaySetService methods that `updateInstances` + * callers need, avoiding a circular import from `types/` into the services layer. + */ +export interface DisplaySetServiceLike { + setDisplaySetMetadataInvalidated(displaySetInstanceUID: string): void; + getDisplaySetsForSeries(seriesInstanceUID: string): DisplaySet[]; +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@platform/core/src/types/DisplaySet.ts` around lines 93 - 112, Update the DisplaySet.updateInstances signature to replace the implicit-any displaySetService parameter with a minimal local interface describing the service members this hook uses. Define or reuse that interface within the types layer rather than importing DisplaySetService, preserving type safety without introducing a circular dependency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@extensions/default/src/displaySetSplitting/makeImageSetDisplaySet.ts`:
- Around line 36-42: Update the volumeLoaderUtility lookup in
makeImageSetDisplaySet to check whether getModuleEntry returns undefined before
accessing exports. If the utility is unavailable, throw a clear descriptive
error; otherwise preserve the existing getDynamicVolumeInfo extraction and
invocation.
In
`@platform/core/src/services/CustomizationService/expression/expression.test.ts`:
- Around line 137-149: Rename the test around compileExpression to describe
graceful null property access in templates rather than runtime errors, warnings,
or an undefined result. Keep the existing `${a.b.c}` assertion and setup
unchanged.
---
Nitpick comments:
In `@extensions/default/src/getSopClassHandlerModule.js`:
- Line 15: Update the three call sites of makeDisplaySet to pass only the
required instances argument, removing the unused instanceIndex and
displaySets.length arguments while preserving the existing makeDisplaySet
implementation.
In `@platform/core/src/types/DisplaySet.ts`:
- Around line 93-112: Update the DisplaySet.updateInstances signature to replace
the implicit-any displaySetService parameter with a minimal local interface
describing the service members this hook uses. Define or reuse that interface
within the types layer rather than importing DisplaySetService, preserving type
safety without introducing a circular dependency.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f0e9f3f4-e8d2-47ba-94e0-8102288831c9
📒 Files selected for processing (25)
extensions/default/package.jsonextensions/default/src/customizations/metadataDisplaySetCustomization.tsextensions/default/src/displaySetSplitting/makeDisplaySetFromInstanceGroup.tsextensions/default/src/displaySetSplitting/makeImageSetDisplaySet.tsextensions/default/src/displaySetSplitting/ohifDefaultSplitRules.test.tsextensions/default/src/displaySetSplitting/ohifDefaultSplitRules.tsextensions/default/src/getCustomizationModule.tsxextensions/default/src/getSopClassHandlerModule.jsplatform/app/public/customizations/index.htmlplatform/app/public/customizations/split/enableNewSplit.jsoncplatform/app/public/customizations/split/scoutSeries.jsoncplatform/core/src/services/CustomizationService/CustomizationService.function.test.tsplatform/core/src/services/CustomizationService/CustomizationService.tsplatform/core/src/services/CustomizationService/expression/compiler.tsplatform/core/src/services/CustomizationService/expression/expression.test.tsplatform/core/src/services/CustomizationService/expression/index.tsplatform/core/src/services/CustomizationService/expression/parser.tsplatform/core/src/services/CustomizationService/expression/tokenizer.tsplatform/core/src/services/DisplaySetService/DisplaySetService.test.tsplatform/core/src/services/DisplaySetService/DisplaySetService.tsplatform/core/src/services/DisplaySetService/displaySetStore.test.tsplatform/core/src/services/DisplaySetService/displaySetStore.tsplatform/core/src/services/DisplaySetService/normalizeSplitRules.tsplatform/core/src/types/DisplaySet.tsplatform/docs/docs/platform/services/customization-service/displaySetSplitting.md
…use-metadata-display-set
Viewers
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Project |
Viewers
|
| Branch Review |
feat/customization-use-metadata-display-set
|
| Run status |
|
| Run duration | 02m 30s |
| Commit |
|
| Committer | Bill Wallace |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
10
|
|
|
0
|
|
|
0
|
|
|
16
|
|
|
2
|
| View all changes introduced in this branch ↗︎ | |
Tests for review

measurement-tracking/OHIFCornerstoneToolbar.spec.js • 1 failed test
| Test | Artifacts | |
|---|---|---|
| OHIF Cornerstone Toolbar > checks if all primary buttons are being displayed |
Test Replay
Screenshots
Video
|
|

measurement-tracking/OHIFMeasurementPanel.spec.js • 1 failed test
| Test | Artifacts | |
|---|---|---|
| OHIF Measurement Panel > checks if Measurements right panel can be hidden/displayed |
Test Replay
Screenshots
Video
|
|

customization/HangingProtocol.spec.js • 1 failed test
| Test | Artifacts | |
|---|---|---|
| OHIF HP > Should display 3 up |
Test Replay
Screenshots
Video
|
|

measurement-tracking/OHIFCornerstoneHotkeys.spec.js • 1 failed test
| Test | Artifacts | |
|---|---|---|
| OHIF Cornerstone Hotkeys > checks if hotkeys "R" and "L" can rotate the image |
Test Replay
Screenshots
Video
|
|

measurement-tracking/OHIFStudyBrowser.spec.js • 1 failed test
| Test | Artifacts | |
|---|---|---|
| OHIF Study Browser > checks if series thumbnails are being displayed |
Test Replay
Screenshots
Video
|
|
The first 5 failed specs are shown, see all 10 specs in Cypress Cloud.
…use-metadata-display-set
…use-metadata-display-set # Conflicts: # extensions/default/src/getCustomizationModule.tsx # platform/docs/docs/migration-guide/3p13-to-3p14/index.md
…etadata The safe function expression language was written on this branch and then ported to `@cornerstonejs/metadata`, where it belongs: it exists to express display set split rules, both sides of the wire have to compile the same rules, and a viewer-only copy cannot serve a server building a study index. Until now both copies existed, semantically identical bar formatting — two copies of one sandbox, so a hardening fix would land in one and silently miss the other. Deletes `CustomizationService/expression/` (~750 lines plus its 22-test suite, which came across with the port) and imports `compileExpression` from the package. Its only two non-test consumers were `$function` and one convenience line in `normalizeSplitRules`; nothing in any extension or mode used it. Also gates `$function` with a policy, read from `appConfig.customizationFunctionPolicy` and — like `customizationUrlPrefixes` — never from a customization, since a customization able to define the policy could lift its own restrictions. `denyAttributes` lists attribute paths where a marker is refused, as dotted patterns (`*` = one segment, trailing `**` = any depth). Array indices are not path segments, so a pattern describes the shape of a customization rather than a position in a list and survives a rule list being reordered. Nothing is denied by default; `['**']` disables `$function` entirely. A deny list rather than an allow list: the set of attributes a rule may legitimately compute is not knowable in advance — `customAttributes` keys are chosen by the rule's author — so an allow list would refuse working configurations by default, a worse failure than the one it prevents. An earlier draft of this work justified an allow list by claiming a computed `customAttributes.SeriesInstanceUID` could redirect where measurements are saved. That is not so: `storeMeasurements` builds its report from the measurement data plus the dialog's explicit destination, and no write path reads a display set attribute for its target. Documents two things that are deliberate rather than oversights: composing text from any attribute is the point of template literals in a rule (and so a rule set is content a reviewer reads, since it decides what the study browser says), and an expression naming an attribute the instance lacks resolves to `undefined` rather than being validated against a dictionary — a naturalized instance carries private and vendor attributes no dictionary enumerates, so validating would reject expressions that work. Records in `ohifDefaultSplitRules` that OHIF's hand-written rules are transitional and why they are not being converted to raw selector form yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things stopped this branch's new dependency from being releasable. `extensions/default` pinned `@cornerstonejs/metadata` at 5.6.8 while `@OHIF/core` and `extensions/cornerstone` were at 5.8.2. As an exact-pinned peer dependency that is a peer conflict for anyone installing `@ohif/extension-default` beside `@OHIF/core`. Aligned at 5.8.2. `cs3d-set-version.mjs` did not list `metadata`, so a bump would have moved the other eight packages and left it behind — two CS3D builds in one install, which surfaces as a missing export rather than a version error. That script also updated nothing but the root `package.json`. It read the root `workspaces` field, which went away when the repo moved to pnpm, so `workspaceGlobs` was `[]` and it reported success having changed no pin. It now reads `pnpm-workspace.yaml` (falling back to the `workspaces` field) and exits non-zero rather than performing a silent no-op. Verified: 31 package files discovered where it previously found 1, 28 pins updated. Its closing advice still told the reader to run `bun install --config=./bunfig.update-lockfile.toml`; replaced with the pnpm command the "version" path in playwright.yml actually uses. The three `metadata` pins still have to move to the CS3D release that carries the safe functions. They are left at 5.8.2 deliberately — bumping to an unpublished version would break `pnpm install` on this branch, and CI resolves it through the CS3D_REF link path meanwhile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rule's `compareInstances` had no effect on the resulting display set. The
split engine ordered each group by it, then `makeImageSetDisplaySet` called
`imageSet.sort(customizationService)`, which ignores the incoming order and
re-sorts from scratch — so the rule's order was computed and then thrown away.
Nothing warned, and because no default rule declares a comparator, no test
exercised it.
Two orderings met there and only one could win. Now they compose, with the
precedence the engine defines: OHIF's default order is the base, the rule's
comparator overrides it where it has an opinion, and a comparator returning 0
leaves the base alone.
- `ImageSet.sortInstances(images, customizationService)` is `sort()`'s body
applied to a supplied list. Extracted rather than reimplemented on the
split-rule side so OHIF's default order has one definition — and it has to be
a whole-list sort, because `sortImagesByPatientPosition` picks a reference
instance (the middle one, to avoid a scout) and projects onto its normal,
which no pairwise comparator expresses. `sort()` delegates to it.
- The split-rule factory orders once, through
`orderInstancesForRule(images, matchedRule, { sortInstances: <OHIF's> })`, on
both the initial build and the incremental merge. It runs after the image-list
attributes because the base order reads `isReconstructable`, which is why the
base is supplied here rather than to the engine.
- `makeImageSetDisplaySet` takes `skipSort`, set by the split-rule path. The
legacy SOP class handler path is unchanged: it has no rule to consult, so
OHIF's default order is the whole answer and is still applied there.
- The `useMetadataDisplaySet` customization gains optional `sortInstances` /
`compareInstances`, forwarded to the engine. These change the order the engine
walks runs in — and so which display sets a `runBy` rule produces — rather
than the final frame order; unset, the engine's acquisition order applies as
before.
No default rule declares a comparator, so no shipped behaviour changes.
Requires the ordering hooks added in cornerstonejs/cornerstone3D#2861.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`$function` compiled with the params the *data* declared. So a marker written
`params: ['a', 'b']` at a site the consumer invokes as `(instance, context)`
compiled cleanly and then computed nonsense, with nothing anywhere to warn
about it — the data author is the wrong party to state a calling convention it
cannot see.
`customizationService.registerFunctionSignatures({ '<path pattern>': params })`
moves that declaration to the code that calls the closure. Paths use the same
dotted patterns as `denyAttributes` (`*` for one segment, trailing `**` for any
depth) and the most specific match wins, so a `series.*` convention can be
overridden for one named fact. A marker whose own `params` disagree with the
registered signature is compiled with the registered one and warns; a marker
that spells the same signature out is accepted silently. With nothing registered
the previous behaviour stands: the default `['instance', 'context']`, and a
marker's own `params` honoured.
Deliberately a method rather than a customization or an app-config value.
Signatures are a property of the code doing the calling, and a customization
able to declare them could hand itself a different convention — the same reason
`denyAttributes` is app-config-only. Registering also clears the transformed
cache, since a signature changes how an already-resolved marker compiles.
`@ohif/extension-default` registers the split-rule signatures, which is what
makes an instance-ordering comparator declarable as data:
{ "compareInstances": { "$function": "a.SliceLocation - b.SliceLocation" } }
Both instances are in scope because the caller said they would be, not because
the rule guessed. Returning 0 declines to have an opinion, so OHIF's default
order carries whatever the comparator does not decide.
Note the safety of an expression was already settled before this: it is parsed
against a closed vocabulary with a fixed helper whitelist, once, at
customization-read time. What was missing was never safety but agreement about
the calling convention, which is what this adds.
Documents the one footgun it does not fix: bare identifiers still resolve
against the first argument, so in a comparator `SliceLocation` silently means
`a.SliceLocation`. Suppressing the implicit scope for comparator-shaped
signatures needs a compiler option upstream.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit moved the `@cornerstonejs/metadata` peer dependency of
`extensions/default` from 5.6.8 to 5.8.2, and did not update `pnpm-lock.yaml`.
CI runs `pnpm install --frozen-lockfile`, and pnpm then refuses the install:
ERR_PNPM_OUTDATED_LOCKFILE
specifiers in the lockfile don't match specifiers in package.json:
- @cornerstonejs/metadata (lockfile: 5.6.8, manifest: 5.8.2)
The install is the first step of every OHIF job, so every job failed.
The lockfile now records 5.8.2 for that specifier, and
`pnpm install --frozen-lockfile` completes.
CS3D_REF: fix/display-set-split-key-stability
Context
SOP class handlers build the display sets in OHIF. The stack handler decides how a series becomes one or more display sets, and it makes that decision in hand-written code.
@cornerstonejs/metadatacan now do the same work from rules that a deployment authors as data. Data rules make the same split available to two consumers: a server that builds a study index, and the viewer. Today each consumer implements the split separately, and the two implementations drift apart.This PR adopts the metadata engine. A customization controls the engine, and that customization is off by default. This PR also adds
$function.$functionis the mechanism that a data-only customization needs to describe a rule.Changes and results
1.
useMetadataDisplaySet— the metadata engine splits the series, as an opt-inWhen you enable the customization,
DisplaySetServicesplits the instances of a series with the split-rule engine of@cornerstonejs/metadata. The service no longer uses the stack SOP class handler for these instances. Instances that no rule claims fall through to the registered handler loop without a change. Thedicom-video,dicom-microscopy,cornerstone-dicom-seg,-sr,-rt,-pmapanddicom-pdfextensions therefore continue to work.You can enable the customization in three ways:
@ohif/extension-default.customizationModule.metadataDisplaySet;?customization=split/enableNewSplit.extensions/default/src/displaySetSplitting/holds three items:ImageSet;stackSopClassUids.ts.stackSopClassUids.tsmakes one list serve two purposes: the registration list of the stack handler, and the ownership test of the split rules. The two purposes cannot disagree about which instances belong to the stack path.getSopClassHandlerModule.jsloses 227 lines to the shared factory.Three rules diverge from the upstream defaults on purpose. Each file records the reason:
singleImageModalitymultiFrameSliceLocationrequirement of the upstream rule. That requirement collapses ultrasound clips into one stack.defaultImageRuleisImageInstancegate of the upstream rule is narrower than the SOP class list of the stack handler.2. The typed metadata cache holds the display sets
displaySetStoreputs the display sets in the DISPLAY_SET module of@cornerstonejs/metadata.DisplaySetService.getDisplaySetCache()is deprecated, and it returns a read-only snapshot. The migration guide isplatform/docs/docs/migration-guide/3p13-to-3p14/display-set-store.md.3.
$function— behaviour from data, and the policy that limits itA JSONC URL module is data. A JSON app config is data. Data cannot contain a function. But a split rule is mostly predicates.
{ "$function": "<expression>" }closes this gap.CustomizationServicecompiles the expression into a closure, one time, at read time.{ "requires": ["split/enableNewSplit"], "global": { "useMetadataDisplaySet": { "splitRules": { "$unshift": [{ "id": "ctScout", "viewportTypes": ["stack"], "series": { "frameCount": { "$function": "sumOf(instances, defined(NumberOfFrames) ? NumberOfFrames : 1)" }, "firstInstanceNumber": { "$function": "minOf(instances, InstanceNumber)" } }, "matches": { "$function": "Modality === 'CT' && context.series.frameCount >= 10 && InstanceNumber == context.series.firstInstanceNumber" }, "groupBy": ["SeriesInstanceUID"], "customAttributes": { "label": "SCOUT", "SeriesDescription": { "$function": "`SCOUT ${SeriesDescription}`" } } }] } } } }This PR does not contain the expression language. The language is in
@cornerstonejs/metadata, ascompileExpression, and it is part of the safe functions of that package. I wrote the language here first, and then I moved the language. Both sides of the wire must compile the same rules. A copy that only the viewer holds cannot serve a server that builds an index. OHIF supplies only the$functionmarker that connects the language to the customizations. Noevaland nonew Functionexist on the path from the data to the executed code.A deployment can withhold specific attributes from the data. The deployment lists the attribute paths in
appConfig.customizationFunctionPolicy.denyAttributes, and the service then refuses a marker at a listed path. A*segment matches one segment. A**segment at the end of a pattern matches any depth. Array indices are not path segments. One pattern therefore covers every rule in a list, and the pattern stays correct after somebody reorders that list.['**']switches$functionoff completely.The default policy denies nothing. The policy is a control, and not a guard. A deployment can compose the series labels centrally. The policy then stops a split rule that replaces those labels.
The policy is a deny list, and not an allow list. Nobody can know the full set of attributes that a rule computes correctly, because the rule author chooses the
customAttributeskeys. An allow list would therefore refuse configurations that work, and that failure is worse than the failure that an allow list prevents.The app config holds the policy, and a customization never holds the policy.
customizationUrlPrefixesfollows the same rule. A customization can come from the URL, so a customization that could define the policy could also remove its own limits.An expression that names an attribute that the instance does not carry evaluates to
undefined. This behaviour makes the sparse DICOM tags usable (DiffusionBValue != undefined). The same behaviour letsModallity === 'CT'compile correctly and then match nothing. The compiler does not validate the identifiers against a list of known attributes, and that is a decision. A naturalized instance carries private tags, vendor additions, and per-frame data that the naturalizer folds in. No dictionary lists all of these attributes. A false rejection stops a deployment, and a silent no-match only confuses one person.collectIdentifiersin@cornerstonejs/metadatareports the attributes that an expression reads. UsecollectIdentifiersto find a misspelled name. A host that has a closed subject can also build its own check withcollectIdentifiers.4. Failure is safe where safety matters
normalizeSplitRulesdrops a rule when thematchesor thegroupByof that rule does not resolve to a callable value. These two fields fail open, and the result is severe:matchesas a rule that matches every instance;groupByentry that is not callable readsinstance[undefined]for every instance, and collapses the study into one group.When the service drops the rule, the other rules stay in control. A misspelled name therefore degrades to "my rule did nothing", and you can diagnose that result. A
seriesfact that isundefinedfails closed instead, and the service only writes a warning.5. The caller declares the calling convention of a
$function, and the data does notBefore this PR, the service compiled a marker with the parameters that the data declared. A marker with
params: ['a', 'b'], at an attribute that the consumer calls as(instance, context), therefore compiled correctly and then computed a wrong value. Nothing wrote a warning. The author of a data file is the wrong person to declare a calling convention. That convention is not a property of the data. The convention belongs to the code that calls the closure.customizationService.registerFunctionSignatures()moves the declaration to that code:The paths use the same dotted patterns as
denyAttributes. The most specific match wins. You can therefore register a convention forseries.*, and then override that convention for one named fact.paramsof a marker disagree with the registered signature, the service uses the registered signature. The service also writes a warning.['instance', 'context'], and honours theparamsof the marker.registerFunctionSignaturesis a method. It is not a customization, and it is not an app-config value. A customization that could declare a signature could give itself a different signature.denyAttributesstays in the app config for the same reason.Be precise about what this change adds. The expression language was already safe: the compiler parses each expression against a closed vocabulary and a fixed list of helper functions, one time, at customization-read time. Safety was not the problem. Agreement about the call was the problem.
@ohif/extension-defaultregisters the split-rule signatures. A comparator therefore becomes expressible as data. A comparator needs both instances in the scope, and no default convention supplies both instances:{ "id": "spatial", "matches": { "$function": "Modality === 'CT'" }, "compareInstances": { "$function": "a.SliceLocation - b.SliceLocation" } }One trap remains, and this PR records the trap instead of a fix. A bare identifier still resolves against the first argument. In a comparator,
SliceLocation - b.SliceLocationtherefore meansa.SliceLocation - b.SliceLocation, and the expression gives no warning. A fix must suppress the implicit scope for the comparator signatures, and that fix needs a new compiler option in@cornerstonejs/metadata.6. The display set keeps the instance order that the rule declares
The
compareInstancesof a rule had no effect on the display set. The split engine ordered the instances of each group withcompareInstances. ThenmakeImageSetDisplaySetcalledimageSet.sort(customizationService).imageSet.sortignores the order of the list that it receives, and sorts that list again from the start. The engine therefore computed the order of the rule, and OHIF discarded that order at once. Nothing wrote a warning. No default rule declares a comparator, so no test found the defect.Two orders met at this point, and only one order could survive. The two orders now combine, and the engine defines the precedence:
The changes:
ImageSet.sortInstances(images, customizationService)is the body ofsort(), and it applies to a list that the caller supplies.sort()now callssortInstances. I extracted the body, and I did not write a second implementation on the split-rule side, so the default order of OHIF keeps one definition. The hook must sort a whole list, and a comparator cannot replace the hook.sortImagesByPatientPositionselects a reference instance — the middle instance, to avoid a scout — and projects the other instances onto the normal of that reference instance. No(a, b)function expresses that operation.orderInstancesForRule(images, matchedRule, { sortInstances: <the OHIF sort> }). The factory does this for the first build and for the incremental merge. The factory applies the order after it applies the image-list attributes, because the base order readsisReconstructable. For that reason the factory supplies the base sort, and the engine does not.makeImageSetDisplaySetaccepts askipSortoption, and the split-rule path sets that option. The legacy SOP class handler path does not change. That path has no rule to read, so the default order of OHIF is the complete answer, andmakeImageSetDisplaySetstill applies it there.useMetadataDisplaySetcustomization accepts an optionalsortInstancesand an optionalcompareInstances, andDisplaySetServicesends both to the engine. These two options change the order in which the engine walks the runs, and therefore change the display sets that arunByrule produces. The two options do not change the final frame order. When you supply neither option, the engine uses acquisition order, as before.No default rule declares a comparator, so no shipped behaviour changes.
Text composition from attributes is intended
A rule can build the
labelor theSeriesDescriptionof a display set from any attribute that the instance carries. That capability is the purpose of the template literals. A rule that renames a split is a main reason to write a rule, for exampleSCOUT ${SeriesDescription}, orb=0andb=1000.The capability has a consequence, and you must know the consequence before you write rules. A rule decides the text in the study browser and in the viewport overlays, and a viewer template does not decide that text. The instance that the rule reads carries the patient identifiers beside the acquisition tags. Treat a split-rule set as content that a reviewer reads, at the same level as the overlay configuration.
Two results follow:
customizationUrlPrefixesnames a prefix. That prefix must have the same write controls as any other deployed configuration.customAttributes.labelandcustomAttributes.SeriesDescription. The rules then split the series, and the rules do not supply the text.displaySetSplitting.mdrecords this section.Dependency and release setup
@ohif/core,extensions/cornerstoneandextensions/defaultdepend on@cornerstonejs/metadata. Three defects stopped the release of that dependency:extensions/defaultpinnedmetadataat5.6.8. The rest of the repository used5.8.2. The pin is an exact peer dependency, so the two pins conflict for a user who installs@ohif/extension-defaultbeside@ohif/core. Both now use5.8.2..scripts/cs3d-set-version.mjsdid not listmetadata. A CS3D version bump therefore moved the other eight packages, and leftmetadataat the old version. One install then holds two CS3D builds, and the mismatch appears as a missing export, and not as a version error. The script now listsmetadata.workspacesfield of the rootpackage.json. That field left the repository when the repository moved to pnpm.workspaceGlobswas therefore[], and the script rewrote only the rootpackage.jsonand reported success. The script now readspnpm-workspace.yaml, and falls back to theworkspacesfield. The script exits with an error when it finds no globs. I verified the fix: the script found 31 package files, where it found 1 file before, and it updated 28 pins.Work to do before merge: all three
metadatapins must move to the CS3D release that contains the safe functions and the instance-order hooks. The pins stay at5.8.2on purpose. A pin to an unpublished version breakspnpm installfor every user of this branch, and CI resolves the dependency through theCS3D_REFlink path.How to test
The
ohif-integrationlabel and theCS3D_REFline at the top of this description tell.github/workflows/playwright.ymlto clonefix/display-set-split-key-stability. The workflow builds that branch withpnpm run build:esm, and links the branch intonode_modulesbefore the OHIF install.To test on your own machine:
Then open the viewer on a study that has a multi-slice CT series:
?customization=split/enableNewSplit— the metadata engine performs the split. An MR DWI series that mixes instances with and withoutDiffusionBValuenow splits into two display sets. Before this PR, the viewer applied 4D rendering to the 3D part of that series.?customization=split/scoutSeries— the first CT image of each series that has 10 frames or more becomes a separate display set with the labelSCOUT. This example shows a rule that a deployment authors completely in JSONC.customizationUrlPrefixesin the config. The default config does not set that property.config/dev.jssets it.To see the instance order of a rule take effect, add a
compareInstancesto the scout rule:{ "$function": "b.InstanceNumber - a.InstanceNumber" }. The frames of the scout display set then come back in the reverse order. Before the order fix in this PR, the same rule changed nothing.To see the deny policy work, set
customizationFunctionPolicy: { denyAttributes: ['useMetadataDisplaySet.splitRules.customAttributes.SeriesDescription'] }in the config. Then reload?customization=split/scoutSeries. The scout display sets still split from the series, and they keep the original description. The console shows a warning that names the refused path.Not in this PR
The split rules of OHIF are still hand-written closures, and not a raw selector. The actual defaults of OHIF are therefore the rules that cannot cross the wire. I did not convert those rules, because they are transitional. Each rule exists to reproduce the legacy stack SOP class handler exactly, and the rules leave when that handler leaves. A conversion now gives two forms of the same legacy-parity behaviour to maintain. The seam for a conversion exists:
createDisplaySetSplitRulesaccepts namedclassifiers, and one classifier (isStackHandledInstance) covers everything that is specific to OHIF. The module documentation ofohifDefaultSplitRulesrecords this plan.The
customAttributesof a rule can still overwrite display set fields that are not reserved.RESERVED_ATTRIBUTESinmakeDisplaySetFromInstanceGroupprotects the identity and the content of the display set:images,instances,uid,displaySetInstanceUIDandsplitKey. A rule therefore cannot redirect the pixel requests. The data source derivesimageIdsfromimages, and it does not storeimageIds.RESERVED_ATTRIBUTESdoes not listStudyInstanceUID,SeriesInstanceUID,SOPClassHandlerIdorupdateInstances.An earlier version of this description said that the gap matters because
SeriesInstanceUIDselects the series that receives a saved report. That statement was wrong.storeMeasurementsbuilds the report from the measurement data, and from the explicit destination that the dialog supplies (predecessorImageId,SeriesNumber,SeriesDescription). No write path reads an attribute of a display set to select its target. The real consequences of an overwrite of one of these four fields are:A fix belongs in
RESERVED_ATTRIBUTES, because the gap applies to a literal custom attribute and to a computed custom attribute equally. The gap does not leak data, and it does not misroute a request.