Skip to content

[WIP] feat: Add ability to use new display set split rules - #6137

Open
wayfarer3130 wants to merge 15 commits into
masterfrom
feat/customization-use-metadata-display-set
Open

[WIP] feat: Add ability to use new display set split rules#6137
wayfarer3130 wants to merge 15 commits into
masterfrom
feat/customization-use-metadata-display-set

Conversation

@wayfarer3130

@wayfarer3130 wayfarer3130 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

CS3D_REF: fix/display-set-split-key-stability

This PR depends on cornerstonejs/cornerstone3D#2861. CS3D #2861 must merge and release before this PR can merge. The CS3D_REF line above and the ohif-integration label tell CI to clone, build and link that branch. CI then does not install a published @cornerstonejs/* package, so you can test this PR now. The dependency goes one way only: CS3D #2861 validates against OHIF master, and not against this branch.

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/metadata can 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. $function is 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-in

When you enable the customization, DisplaySetService splits 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. The dicom-video, dicom-microscopy, cornerstone-dicom-seg, -sr, -rt, -pmap and dicom-pdf extensions therefore continue to work.

You can enable the customization in three ways:

  • per mode;
  • with the named module entry @ohif/extension-default.customizationModule.metadataDisplaySet;
  • from the URL, with ?customization=split/enableNewSplit.

extensions/default/src/displaySetSplitting/ holds three items:

  • the OHIF rule set;
  • the factory that converts an instance group into an OHIF ImageSet;
  • stackSopClassUids.ts.

stackSopClassUids.ts makes 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.js loses 227 lines to the shared factory.

Three rules diverge from the upstream defaults on purpose. Each file records the reason:

Rule Why this rule is not the upstream rule
singleImageModality This rule splits per instance. The upstream rule uses a coarse size bucket, and that bucket merges mammography views of the same resolution (RCC/LCC/RMLO/LMLO).
multiFrame This rule drops the SliceLocation requirement of the upstream rule. That requirement collapses ultrasound clips into one stack.
defaultImageRule The isImageInstance gate of the upstream rule is narrower than the SOP class list of the stack handler.

2. The typed metadata cache holds the display sets

displaySetStore puts 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 is platform/docs/docs/migration-guide/3p13-to-3p14/display-set-store.md.

3. $function — behaviour from data, and the policy that limits it

A 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. CustomizationService compiles 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, as compileExpression, 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 $function marker that connects the language to the customizations. No eval and no new Function exist 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 $function off 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 customAttributes keys. 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. customizationUrlPrefixes follows 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 lets Modallity === '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. collectIdentifiers in @cornerstonejs/metadata reports the attributes that an expression reads. Use collectIdentifiers to find a misspelled name. A host that has a closed subject can also build its own check with collectIdentifiers.

4. Failure is safe where safety matters

normalizeSplitRules drops a rule when the matches or the groupBy of that rule does not resolve to a callable value. These two fields fail open, and the result is severe:

  • the engine treats a rule that has no matches as a rule that matches every instance;
  • a groupBy entry that is not callable reads instance[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 series fact that is undefined fails closed instead, and the service only writes a warning.

5. The caller declares the calling convention of a $function, and the data does not

Before 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:

customizationService.registerFunctionSignatures({
  'useMetadataDisplaySet.splitRules.matches': ['instance', 'context'],
  'useMetadataDisplaySet.splitRules.compareInstances': ['a', 'b', 'context'],
});

The paths use the same dotted patterns as denyAttributes. The most specific match wins. You can therefore register a convention for series.*, and then override that convention for one named fact.

  • When the params of a marker disagree with the registered signature, the service uses the registered signature. The service also writes a warning.
  • When a marker states the same signature as the registered signature, the service accepts the marker in silence.
  • When no signature exists for a path, the previous behaviour applies: the service uses the default ['instance', 'context'], and honours the params of the marker.

registerFunctionSignatures is 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. denyAttributes stays 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-default registers 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.SliceLocation therefore means a.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 compareInstances of a rule had no effect on the display set. The split engine ordered the instances of each group with compareInstances. Then makeImageSetDisplaySet called imageSet.sort(customizationService). imageSet.sort ignores 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 default order of OHIF is the base order;
  • the comparator of the rule overrides the base order at each pair where the comparator has an opinion;
  • a comparator that returns 0 declines to have an opinion. The comparator does not state that the two instances are equal. The base order therefore holds for each pair that the comparator does not decide.

The changes:

  • ImageSet.sortInstances(images, customizationService) is the body of sort(), and it applies to a list that the caller supplies. sort() now calls sortInstances. 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. sortImagesByPatientPosition selects 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.
  • The split-rule factory orders the instances one time, with 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 reads isReconstructable. For that reason the factory supplies the base sort, and the engine does not.
  • makeImageSetDisplaySet accepts a skipSort option, 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, and makeImageSetDisplaySet still applies it there.
  • The useMetadataDisplaySet customization accepts an optional sortInstances and an optional compareInstances, and DisplaySetService sends both to the engine. These two options change the order in which the engine walks the runs, and therefore change the display sets that a runBy rule 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 label or the SeriesDescription of 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 example SCOUT ${SeriesDescription}, or b=0 and b=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:

  • The viewer does not load a customization from the URL until customizationUrlPrefixes names a prefix. That prefix must have the same write controls as any other deployed configuration.
  • A deployment that does not want the rename can deny customAttributes.label and customAttributes.SeriesDescription. The rules then split the series, and the rules do not supply the text.

displaySetSplitting.md records this section.

Dependency and release setup

@ohif/core, extensions/cornerstone and extensions/default depend on @cornerstonejs/metadata. Three defects stopped the release of that dependency:

  • extensions/default pinned metadata at 5.6.8. The rest of the repository used 5.8.2. The pin is an exact peer dependency, so the two pins conflict for a user who installs @ohif/extension-default beside @ohif/core. Both now use 5.8.2.
  • .scripts/cs3d-set-version.mjs did not list metadata. A CS3D version bump therefore moved the other eight packages, and left metadata at 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 lists metadata.
  • The same script found no workspace packages. The script read the workspaces field of the root package.json. That field left the repository when the repository moved to pnpm. workspaceGlobs was therefore [], and the script rewrote only the root package.json and reported success. The script now reads pnpm-workspace.yaml, and falls back to the workspaces field. 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 metadata pins must move to the CS3D release that contains the safe functions and the instance-order hooks. The pins stay at 5.8.2 on purpose. A pin to an unpublished version breaks pnpm install for every user of this branch, and CI resolves the dependency through the CS3D_REF link path.

How to test

The ohif-integration label and the CS3D_REF line at the top of this description tell .github/workflows/playwright.yml to clone fix/display-set-split-key-stability. The workflow builds that branch with pnpm run build:esm, and links the branch into node_modules before the OHIF install.

To test on your own machine:

pnpm cs3d:checkout fix/display-set-split-key-stability
pnpm cs3d:install && pnpm cs3d:build && pnpm cs3d:link
pnpm test:unit          # 108 suites, 1295 tests

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 without DiffusionBValue now 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 label SCOUT. This example shows a rule that a deployment authors completely in JSONC.
  • Both URLs need customizationUrlPrefixes in the config. The default config does not set that property. config/dev.js sets it.

To see the instance order of a rule take effect, add a compareInstances to 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: createDisplaySetSplitRules accepts named classifiers, and one classifier (isStackHandledInstance) covers everything that is specific to OHIF. The module documentation of ohifDefaultSplitRules records this plan.

The customAttributes of a rule can still overwrite display set fields that are not reserved. RESERVED_ATTRIBUTES in makeDisplaySetFromInstanceGroup protects the identity and the content of the display set: images, instances, uid, displaySetInstanceUID and splitKey. A rule therefore cannot redirect the pixel requests. The data source derives imageIds from images, and it does not store imageIds. RESERVED_ATTRIBUTES does not list StudyInstanceUID, SeriesInstanceUID, SOPClassHandlerId or updateInstances.

An earlier version of this description said that the gap matters because SeriesInstanceUID selects the series that receives a saved report. That statement was wrong. storeMeasurements builds 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 display set match fails, for example a SEG does not find the series that it references;
  • an exported CSV holds wrong reference text;
  • the merge hook breaks.

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.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@netlify

netlify Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploy Preview for ohif-dev failed. Why did it fail? →

Name Link
🔨 Latest commit efa1f5a
🔍 Latest deploy log https://app.netlify.com/projects/ohif-dev/deploys/6aa0620782afa00009851654

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds opt-in metadata-driven display-set splitting with safe $function expressions, ordered OHIF rules, incremental reconciliation, metadata-backed storage, legacy fallback handling, customization presets, tests, and documentation.

Changes

Metadata display-set splitting

Layer / File(s) Summary
Safe expression runtime
platform/core/src/services/CustomizationService/expression/*, platform/core/src/services/CustomizationService/CustomizationService.ts, platform/core/src/services/CustomizationService/CustomizationService.function.test.ts
Adds tokenization, parsing, CSP-safe compilation, aggregate/helper evaluation, $function resolution, memoization, error handling, and tests.
Declarative and default split rules
platform/core/src/services/DisplaySetService/normalizeSplitRules.ts, extensions/default/src/displaySetSplitting/ohifDefaultSplitRules.ts, extensions/default/src/displaySetSplitting/ohifDefaultSplitRules.test.ts
Normalizes declarative rules and adds ordered image, multiframe, diffusion, volume, and specialized-instance handling.
ImageSet factories and handler integration
extensions/default/src/displaySetSplitting/*, extensions/default/src/getSopClassHandlerModule.js, platform/core/src/types/DisplaySet.ts
Centralizes ImageSet construction and creates split display sets with rule attributes, dynamic-volume metadata, and incremental merging.
DisplaySetService orchestration and storage
platform/core/src/services/DisplaySetService/*, platform/core/src/types/DisplaySet.ts
Integrates grouping, reconciliation, stale-set removal, legacy fallback, and typed metadata-backed storage.
Customization entrypoints and presets
extensions/default/package.json, extensions/default/src/customizations/*, extensions/default/src/getCustomizationModule.tsx, platform/app/public/customizations/*
Registers the default customization, adds launch parameters, and provides metadata-splitting and CT SCOUT presets.
Display-set splitting documentation
platform/docs/docs/platform/services/customization-service/displaySetSplitting.md
Documents enablement, rule structure, expressions, precedence, examples, and overrides.

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
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description provides extensive context, change details, dependency information, testing instructions, and documentation of known limitations. However, it omits the required Checklist section and d… Add the repository's complete Checklist section and mark each applicable checkbox. Include the tested OS, Node version, and browser details.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: adding support for new display set split rules. The [WIP] prefix adds minor noise but does not make the title unclear or unrelated.
Full details: Description check

Explanation

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)
  • Create PR with unit tests
  • Commit unit tests in branch feat/customization-use-metadata-display-set

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
extensions/default/src/getSopClassHandlerModule.js (1)

15-15: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Remove the unused second argument from makeDisplaySet calls

makeDisplaySet only forwards instances and appContext to makeImageSetDisplaySet, so instanceIndex / displaySets.length are 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 win

Consider adding a type annotation for the displaySetService parameter.

The displaySetService parameter on updateInstances has no type annotation, making it implicitly any. Importing DisplaySetService directly would create a circular dependency (the services layer imports from types/), 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

📥 Commits

Reviewing files that changed from the base of the PR and between 110b293 and 14cfb7b.

📒 Files selected for processing (25)
  • extensions/default/package.json
  • extensions/default/src/customizations/metadataDisplaySetCustomization.ts
  • extensions/default/src/displaySetSplitting/makeDisplaySetFromInstanceGroup.ts
  • extensions/default/src/displaySetSplitting/makeImageSetDisplaySet.ts
  • extensions/default/src/displaySetSplitting/ohifDefaultSplitRules.test.ts
  • extensions/default/src/displaySetSplitting/ohifDefaultSplitRules.ts
  • extensions/default/src/getCustomizationModule.tsx
  • extensions/default/src/getSopClassHandlerModule.js
  • platform/app/public/customizations/index.html
  • platform/app/public/customizations/split/enableNewSplit.jsonc
  • platform/app/public/customizations/split/scoutSeries.jsonc
  • platform/core/src/services/CustomizationService/CustomizationService.function.test.ts
  • platform/core/src/services/CustomizationService/CustomizationService.ts
  • platform/core/src/services/CustomizationService/expression/compiler.ts
  • platform/core/src/services/CustomizationService/expression/expression.test.ts
  • platform/core/src/services/CustomizationService/expression/index.ts
  • platform/core/src/services/CustomizationService/expression/parser.ts
  • platform/core/src/services/CustomizationService/expression/tokenizer.ts
  • platform/core/src/services/DisplaySetService/DisplaySetService.test.ts
  • platform/core/src/services/DisplaySetService/DisplaySetService.ts
  • platform/core/src/services/DisplaySetService/displaySetStore.test.ts
  • platform/core/src/services/DisplaySetService/displaySetStore.ts
  • platform/core/src/services/DisplaySetService/normalizeSplitRules.ts
  • platform/core/src/types/DisplaySet.ts
  • platform/docs/docs/platform/services/customization-service/displaySetSplitting.md

Comment thread platform/core/src/services/CustomizationService/expression/expression.test.ts Outdated
@cypress

cypress Bot commented Jul 15, 2026

Copy link
Copy Markdown

Viewers    Run #6743

Run Properties:  status check failed Failed #6743  •  git commit efa1f5abcc: fix(build): update the lockfile for the metadata version alignment
Project Viewers
Branch Review feat/customization-use-metadata-display-set
Run status status check failed Failed #6743
Run duration 02m 30s
Commit git commit efa1f5abcc: fix(build): update the lockfile for the metadata version alignment
Committer Bill Wallace
View all properties for this run ↗︎

Test results
Tests that failed  Failures 10
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 16
Tests that passed  Passing 2
View all changes introduced in this branch ↗︎

Tests for review

Failed  measurement-tracking/OHIFCornerstoneToolbar.spec.js • 1 failed test

View Output Video

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

View Output Video

Test Artifacts
OHIF Measurement Panel > checks if Measurements right panel can be hidden/displayed Test Replay Screenshots Video
Failed  customization/HangingProtocol.spec.js • 1 failed test

View Output Video

Test Artifacts
OHIF HP > Should display 3 up Test Replay Screenshots Video
Failed  measurement-tracking/OHIFCornerstoneHotkeys.spec.js • 1 failed test

View Output Video

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

View Output Video

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.

@wayfarer3130 wayfarer3130 changed the title feat: Add ability to use new display set split rules [WIP] feat: Add ability to use new display set split rules Sep 2, 2026
…use-metadata-display-set

# Conflicts:
#	extensions/default/src/getCustomizationModule.tsx
#	platform/docs/docs/migration-guide/3p13-to-3p14/index.md
wayfarer3130 and others added 4 commits September 8, 2026 13:15
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ohif-integration Causes a CS3D/OHIF integraiton build to be run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant