From e32ae63d1438a2addfdaac2b0b401fd4d8bbeafe Mon Sep 17 00:00:00 2001
From: "Martin Bellehumeur, M. Eng."
<23396581+mbellehumeur@users.noreply.github.com>
Date: Sun, 29 Mar 2026 17:29:31 +0200
Subject: [PATCH 01/20] init cast client
---
lerna.json | 3 +-
package.json | 1 +
packages/cast/README.md | 21 +
packages/cast/examples/castClient/index.ts | 716 ++++++++++++++++++
packages/cast/package.json | 54 ++
packages/cast/src/CastClient.ts | 405 ++++++++++
packages/cast/src/constants.ts | 5 +
packages/cast/src/generateMessageId.ts | 9 +
packages/cast/src/index.ts | 5 +
packages/cast/src/types.ts | 45 ++
packages/cast/src/version.ts | 5 +
packages/cast/tsconfig.json | 8 +
.../examples/volumeCroppingTool/index.ts | 86 ++-
.../src/tools/OrientationControllerTool.ts | 113 ++-
.../tools/src/tools/VolumeCroppingTool.ts | 13 +-
.../OrientationControllerWidget/index.ts | 430 +++++++++--
.../vtkjs/RhombicuboctahedronSource/index.js | 3 +-
utils/ExampleRunner/build-all-examples-cli.js | 4 +
utils/ExampleRunner/example-info.json | 9 +
utils/ExampleRunner/example-runner-cli.js | 4 +
utils/ExampleRunner/template-config.js | 2 +
.../template-multiexample-config.js | 2 +
22 files changed, 1811 insertions(+), 132 deletions(-)
create mode 100644 packages/cast/README.md
create mode 100644 packages/cast/examples/castClient/index.ts
create mode 100644 packages/cast/package.json
create mode 100644 packages/cast/src/CastClient.ts
create mode 100644 packages/cast/src/constants.ts
create mode 100644 packages/cast/src/generateMessageId.ts
create mode 100644 packages/cast/src/index.ts
create mode 100644 packages/cast/src/types.ts
create mode 100644 packages/cast/src/version.ts
create mode 100644 packages/cast/tsconfig.json
diff --git a/lerna.json b/lerna.json
index 5daf946c93..45c3b416f1 100644
--- a/lerna.json
+++ b/lerna.json
@@ -8,7 +8,8 @@
"packages/dicomImageLoader",
"packages/ai",
"packages/labelmap-interpolation",
- "packages/polymorphic-segmentation"
+ "packages/polymorphic-segmentation",
+ "packages/cast"
],
"npmClient": "yarn"
}
diff --git a/package.json b/package.json
index a787a70210..68ca10ddbe 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"packages/tools",
"packages/ai",
"packages/labelmap-interpolation",
+ "packages/cast",
"addOns/externals/*",
"addOns/local/*"
],
diff --git a/packages/cast/README.md b/packages/cast/README.md
new file mode 100644
index 0000000000..f53519ccdd
--- /dev/null
+++ b/packages/cast/README.md
@@ -0,0 +1,21 @@
+# @cornerstonejs/cast
+
+Cast hub networking for browser-based viewers: OAuth client-credentials token, HTTP subscribe/unsubscribe, WebSocket receive, and JSON publish. This package contains transport only; application-specific message handling (e.g. OHIF `CastMessageHandler`) stays in the host app to implent FHIRcast or other messaging.
+
+## Example
+
+The **Cast client** example under `examples/castClient` mirrors the Slicer Cast `test-client.html` flow (subscribe, log incoming events, publish) using `CastClient` for all network calls.
+
+From the monorepo root:
+
+```bash
+yarn install
+yarn workspace @cornerstonejs/cast build:esm
+yarn example castClient
+```
+
+Then open the dev server URL (default port from `CS3D_PORT` or `3000`). Set **Token endpoint** and **Hub endpoint** to your Cast hub (defaults target `127.0.0.1:2016`). If the hub runs on another origin, ensure CORS allows the example origin.
+
+Optional query parameter: `?topic=your-topic` pre-fills the topic fields.
+
+The full static example index is built with `yarn build-all-examples` (output under `.static-examples/castClient.html`).
diff --git a/packages/cast/examples/castClient/index.ts b/packages/cast/examples/castClient/index.ts
new file mode 100644
index 0000000000..efd9cd5ace
--- /dev/null
+++ b/packages/cast/examples/castClient/index.ts
@@ -0,0 +1,716 @@
+import { CastClient } from '@cornerstonejs/cast';
+import type { HubConfig } from '@cornerstonejs/cast';
+import { setTitleAndDescription } from '../../../../utils/demo/helpers';
+
+setTitleAndDescription(
+ 'Cast Client',
+ 'Demontrate connecting, messaging and conferencing with the 3D Slicer hub.'
+);
+
+/** Default actor keyword for subscribe list and publish preset. */
+const DEFAULT_ACTOR_KEYWORD = 'WORKLIST_CLIENT';
+/** Default subscribe actors field: JSON array of actor keywords. */
+const DEFAULT_SUBSCRIBE_ACTORS_JSON = `["${DEFAULT_ACTOR_KEYWORD}"]`;
+/** Default actor keyword for Get preset. */
+const DEFAULT_GET_ACTOR_KEYWORD = 'HUB';
+/** Default Cast subscriber name (Subscribe + Get). */
+const DEFAULT_SUBSCRIBER_NAME = 'CS3D-EXAMPLE';
+
+const root = document.getElementById('content');
+if (!root) throw new Error('Missing #content');
+
+const css = `
+.cast-demo { max-width: 1100px; margin: 0 auto; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
+.cast-demo .container { background:#3d3d3d; border-radius:8px; padding:20px; box-shadow:0 2px 8px rgba(0,0,0,.5); }
+.cast-demo h1 { margin-top:0; color:#e0e0e0; display:flex; justify-content:space-between; align-items:flex-start; gap:28px; flex-wrap:wrap; }
+.cast-demo .header-left { display:flex; flex-direction:column; align-items:flex-start; gap:14px; }
+.cast-demo .header-controls-row { display:flex; align-items:center; gap:32px; flex-wrap:wrap; }
+.cast-demo .header-hub-row { display:inline-flex; align-items:center; gap:12px; flex-wrap:wrap; }
+.cast-demo .header-hub-row label { display:inline-block; margin-bottom:0; color:#b0b0b0; font-size:15px; }
+.cast-demo .header-hub-select { width:auto; min-width:140px; padding:6px 10px; font-size:14px; }
+.cast-demo .header-token-btn { padding:6px 12px; font-size:14px; }
+.cast-demo .header-right { display:flex; flex-direction:column; align-items:flex-end; gap:10px; text-align:right; }
+.cast-demo .header-id { font-weight:normal; color:#90A4AE; font-size:17px; }
+.cast-demo .status.status-header { margin:0; padding:11px 14px; font-size:15px; line-height:1.45; border-left-width:4px; border-radius:6px; min-width:0; max-width:min(380px,100%); text-align:left; }
+.cast-demo .status.status-header strong { font-weight:600; }
+.cast-demo h2 { color:#e0e0e0; margin:0 0 12px; }
+.cast-demo .section { margin-top:20px; padding-bottom:16px; border-bottom:1px solid #555; }
+.cast-demo .section:last-child { border-bottom:none; }
+.cast-demo .grid { display:grid; grid-template-columns: repeat(2, minmax(220px,1fr)); gap:12px; }
+.cast-demo label { display:block; margin-bottom:6px; color:#b0b0b0; font-size:13px; }
+.cast-demo input,.cast-demo textarea,.cast-demo select { width:100%; box-sizing:border-box; border:1px solid #666; border-radius:4px; background:#4a4a4a; color:#e0e0e0; padding:9px 10px; font-size:13px; }
+.cast-demo textarea { min-height:110px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize:vertical; }
+.cast-demo input.subscribe-actors-json { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+.cast-demo .status { padding:10px 12px; border-radius:4px; margin:12px 0; background:#4a4a4a; border-left:4px solid #777; }
+.cast-demo .connected { border-left-color:#90A4AE; }
+.cast-demo .disconnected { border-left-color:#d32f2f; }
+.cast-demo .connecting { border-left-color:#ff9800; }
+.cast-demo .success { border-left-color:#4CAF50; }
+.cast-demo .error { border-left-color:#ff9800; background:#5d4037; }
+.cast-demo .actions { display:flex; flex-wrap:wrap; gap:10px; margin-top:10px; }
+.cast-demo button { padding:9px 14px; border:1px solid #666; border-radius:4px; background:#90A4AE; color:white; cursor:pointer; font-size:13px; }
+.cast-demo button:hover:not(:disabled){ background:#78909C; }
+.cast-demo button:disabled{ background:#3a3a3a; color:#777; border-color:#555; cursor:not-allowed; }
+.cast-demo .messages { margin-top:12px; max-height:380px; overflow:auto; background:#2b2b2b; border-radius:4px; padding:10px; font-size:12px; }
+.cast-demo .msg { border-left:3px solid #90A4AE; background:#3d3d3d; border-radius:4px; margin-bottom:8px; padding:8px; white-space:pre-wrap; word-break:break-word; }
+.cast-demo .msg.received { border-left-color:#4CAF50; }
+.cast-demo .msg.sent { border-left-color:#2196F3; }
+.cast-demo .msg.err { border-left-color:#d32f2f; background:#5d4037; }
+.cast-demo .subscribe-events-topic-actors,
+.cast-demo .publish-event-topic-actor-row { grid-column:1/-1; display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:12px; align-items:start; }
+.cast-demo .cast-hidden-endpoint { display:none !important; }
+`;
+
+const style = document.createElement('style');
+style.textContent = css;
+document.head.appendChild(style);
+
+root.className = 'cast-demo';
+root.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Subscribe
+
+
+
+
+
+
+
+
+
Publish
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Get
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Collaborate
+
+
+
+
+
+
+
+
Messages received (0)
+
+
+
+
`;
+
+function byId(id: string): T {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`Missing #${id}`);
+ return el as T;
+}
+
+const tokenEndpointEl = byId('tokenEndpoint');
+const hubEndpointEl = byId('hubEndpoint');
+const hubSelectEl = byId('hubSelect');
+const subscriberNameEl = byId('subscriberName');
+const subscribeActorsEl = byId('subscribeActors');
+const topicEl = byId('topic');
+const eventsEl = byId('events');
+const productNameEl = byId('productName');
+const publishTopicEl = byId('publishTopic');
+const publishActorPresetEl = byId('publishActorPreset');
+const eventTypeEl = byId('eventType');
+const eventTypeCustomEl = byId('eventTypeCustom');
+const eventDataEl = byId('eventData');
+const getEndpointEl = byId('getEndpoint');
+const getSubscriberEl = byId('getSubscriber');
+const getTopicEl = byId('getTopic');
+const getActorPresetEl = byId('getActorPreset');
+const getDataTypeEl = byId('getDataType');
+const conferenceEndpointEl = byId('conferenceEndpoint');
+const subscribeBtnEl = byId('subscribeBtn');
+const messagesEl = byId('messages');
+const statusTextEl = byId('statusText');
+const connectionStatusEl = byId('connectionStatus');
+const topicDisplayEl = byId('topicDisplay');
+const messageCountEl = byId('messageCount');
+
+const defaultTopic =
+ new URLSearchParams(window.location.search).get('topic') ?? 'test-topic';
+
+/** Tooltip for Get actor preset OpenIGTLink (option + closed select when selected). */
+const OPENIGT_LINK_ACTOR_TOOLTIP =
+ 'Image Guided Therapy link\nA system that handles navigation and other dataTypes';
+
+/** IHE / DICOM actor presets: value sent is `keyword` only; name/description are tooltips. */
+const ACTOR_PRESETS = [
+ {
+ keyword: 'REPORT_CREATOR',
+ name: 'Report Creator',
+ description:
+ 'A system that generates and transmits preliminary, final, or amended diagnostic results (i.e., reports).',
+ },
+ {
+ keyword: 'EC',
+ name: 'Evidence Creator',
+ description:
+ 'A system that creates evidence data such as images or measurements, through a process other than data acquisition.',
+ },
+ {
+ keyword: 'ID',
+ name: 'Image Display',
+ description:
+ 'A system that presents medical images and associated imaging data.',
+ },
+ {
+ keyword: 'CONTENT_CREATOR',
+ name: 'Content Creator',
+ description:
+ 'The Content Creator Actor creates content and transmits to a Content Consumer.',
+ },
+ {
+ keyword: 'WATCHER',
+ name: 'Watcher',
+ description:
+ 'Subscribes and receives notifications of events associated with a workitem (such as modification, cancelation or completion).',
+ },
+ {
+ keyword: 'HUB',
+ name: 'Hub',
+ description:
+ 'Manages event flows between Subscribers in a session and maintains the current context and transaction of content sharing in each session.',
+ },
+ {
+ keyword: 'WORKLIST_CLIENT',
+ name: 'Worklist Client',
+ description: 'Providing a reporting worklist to the user.',
+ },
+ {
+ keyword: 'STATELESS_EC',
+ name: 'Stateless Evidence Creator',
+ description:
+ 'An Evidence Creator that is not responsible for maintaining its application state when its operations are suspended and resumed.',
+ },
+] as const;
+
+function fillActorPresetSelect(
+ select: HTMLSelectElement,
+ firstOption?: { value: string; label: string; title?: string }
+): void {
+ select.replaceChildren();
+ if (firstOption) {
+ const head = document.createElement('option');
+ head.value = firstOption.value;
+ head.textContent = firstOption.label;
+ if (firstOption.title) {
+ head.title = firstOption.title;
+ }
+ select.append(head);
+ }
+ for (const p of ACTOR_PRESETS) {
+ const o = document.createElement('option');
+ o.value = p.keyword;
+ o.textContent = p.keyword;
+ o.title = `${p.name}\n\n${p.description}`;
+ select.append(o);
+ }
+}
+
+fillActorPresetSelect(publishActorPresetEl);
+fillActorPresetSelect(getActorPresetEl, {
+ value: 'OpenIGTLink',
+ label: 'OpenIGTLink',
+ title: OPENIGT_LINK_ACTOR_TOOLTIP,
+});
+publishActorPresetEl.value = DEFAULT_ACTOR_KEYWORD;
+getActorPresetEl.value = DEFAULT_GET_ACTOR_KEYWORD;
+
+function syncGetActorSelectTooltip() {
+ getActorPresetEl.title =
+ getActorPresetEl.value === 'OpenIGTLink' ? OPENIGT_LINK_ACTOR_TOOLTIP : '';
+}
+
+getActorPresetEl.addEventListener('change', syncGetActorSelectTooltip);
+syncGetActorSelectTooltip();
+
+const HUB_DEFINITIONS = {
+ local: {
+ hubEndpoint: 'http://127.0.0.1:2017/api/hub',
+ authEndpoint: 'http://127.0.0.1:2017/oauth/token',
+ client_id: 'client_id_3d_Slicer',
+ client_secret: 'client_secret_3d_Slicer',
+ },
+ cloud: {
+ hubEndpoint:
+ 'https://cast-hub-g6abetanhjesb6cx.westeurope-01.azurewebsites.net/api/hub',
+ authEndpoint:
+ 'https://cast-hub-g6abetanhjesb6cx.westeurope-01.azurewebsites.net/oauth/token',
+ client_id: 'client_id_3d_Slicer',
+ client_secret: 'client_secret_3d_Slicer',
+ },
+ agfa: {
+ hubEndpoint: 'https://10.251.1.21/fhircast-hub',
+ authEndpoint:
+ 'https://10.251.1.21/auth/realms/EI/protocol/openid-connect/token',
+ client_id: 'desktop-integration',
+ client_secret: 'hBcEN8Da5GTDywkeFNMmLnuhUx1i5o1U',
+ },
+} as const;
+let selectedClientId = '';
+let selectedClientSecret = '';
+
+function applyHubPreset(hubKey: keyof typeof HUB_DEFINITIONS): void {
+ const hubDef = HUB_DEFINITIONS[hubKey];
+ hubEndpointEl.value = hubDef.hubEndpoint;
+ tokenEndpointEl.value = hubDef.authEndpoint;
+ selectedClientId = hubDef.client_id;
+ selectedClientSecret = hubDef.client_secret;
+ try {
+ const hubUrl = new URL(hubEndpointEl.value.trim());
+ getEndpointEl.value = `${hubUrl.origin}/api/hub/cast-get`;
+ conferenceEndpointEl.value = `${hubUrl.origin}/api/hub/conference-client`;
+ } catch {
+ // Ignore malformed URL.
+ }
+}
+
+hubSelectEl.value = 'local';
+applyHubPreset('local');
+topicEl.value = defaultTopic;
+publishTopicEl.value = defaultTopic;
+subscriberNameEl.value = DEFAULT_SUBSCRIBER_NAME;
+getSubscriberEl.value = DEFAULT_SUBSCRIBER_NAME;
+getTopicEl.value = topicEl.value.trim();
+eventDataEl.value = `[
+ {
+ "key": "patient",
+ "resource": {
+ "resourceType": "Patient",
+ "identifier": [{ "value": "NEW_PATIENT_ID" }]
+ }
+ },
+ {
+ "key": "study",
+ "resource": {
+ "resourceType": "ImagingStudy",
+ "uid": "urn:oid:2.16.840.1.114362.1.11972228.22789312658.616067305.306.2",
+ "status": "available"
+ }
+ }
+]`;
+topicDisplayEl.textContent = defaultTopic;
+
+function refreshDerivedUrls(): void {
+ try {
+ const hub = new URL(hubEndpointEl.value.trim());
+ tokenEndpointEl.value = `${hub.origin}/oauth/token`;
+ getEndpointEl.value = `${hub.origin}/api/hub/cast-get`;
+ conferenceEndpointEl.value = `${hub.origin}/api/hub/conference-client`;
+ } catch {
+ // Keep user-entered values if parse fails.
+ }
+}
+refreshDerivedUrls();
+
+topicEl.addEventListener('input', () => {
+ const t = topicEl.value.trim();
+ topicDisplayEl.textContent = t;
+ publishTopicEl.value = t;
+ getTopicEl.value = t;
+});
+
+hubEndpointEl.addEventListener('change', refreshDerivedUrls);
+hubSelectEl.addEventListener('change', () => {
+ applyHubPreset(hubSelectEl.value as keyof typeof HUB_DEFINITIONS);
+});
+
+eventTypeEl.addEventListener('change', () => {
+ eventTypeCustomEl.style.display =
+ eventTypeEl.value === 'custom' ? 'block' : 'none';
+});
+
+let messageCount = 0;
+function addMessage(
+ kind: 'received' | 'sent' | 'err',
+ label: string,
+ payload: unknown
+): void {
+ const line = document.createElement('div');
+ line.className = `msg ${kind}`;
+ const ts = new Date().toLocaleTimeString();
+ line.textContent = `${label} - ${ts}\n${typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)}`;
+ messagesEl.insertBefore(line, messagesEl.firstChild);
+ messageCount += 1;
+ messageCountEl.textContent = `(${messageCount})`;
+ while (messagesEl.children.length > 60) {
+ messagesEl.removeChild(messagesEl.lastChild as ChildNode);
+ }
+}
+
+function setConnection(
+ status: 'connected' | 'disconnected' | 'connecting',
+ text: string
+): void {
+ connectionStatusEl.className = `status status-header ${status}`;
+ statusTextEl.textContent = text;
+}
+
+let client: CastClient | null = null;
+let hasToken = false;
+
+function parseEvents(raw: string): string[] {
+ const value = raw.trim();
+ if (!value) return ['*'];
+ return value
+ .split(',')
+ .map((v) => v.trim())
+ .filter(Boolean);
+}
+
+/** Values sent as repeated `subscriber.actor` fields (each must be a string). */
+function parseSubscribeActorsList(raw: string): string[] {
+ const value = raw.trim();
+ if (!value) return [];
+ try {
+ const parsed = JSON.parse(value) as unknown;
+ if (Array.isArray(parsed)) {
+ return parsed
+ .map((item) =>
+ typeof item === 'string' ? item.trim() : JSON.stringify(item)
+ )
+ .filter(Boolean);
+ }
+ return [
+ typeof parsed === 'string' ? parsed.trim() : JSON.stringify(parsed),
+ ].filter(Boolean);
+ } catch {
+ return value
+ .split(/[\n,]+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ }
+}
+
+function parseActorField(raw: string): unknown | undefined {
+ const value = raw.trim();
+ if (!value) return undefined;
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ return value;
+ }
+}
+
+function buildHubConfig(): HubConfig {
+ const actorsList = parseSubscribeActorsList(subscribeActorsEl.value);
+ return {
+ name: 'demo',
+ enabled: true,
+ events: parseEvents(eventsEl.value),
+ lease: 7200,
+ hub_endpoint: hubEndpointEl.value.trim(),
+ token_endpoint: tokenEndpointEl.value.trim(),
+ client_id: selectedClientId || undefined,
+ client_secret: selectedClientSecret || undefined,
+ subscriberName: subscriberNameEl.value.trim() || undefined,
+ actors: actorsList.length ? actorsList : undefined,
+ topic: topicEl.value.trim(),
+ };
+}
+
+function ensureClient(recreate = false): CastClient {
+ if (!client || recreate) {
+ client?.destroy();
+ const hub = buildHubConfig();
+ client = new CastClient({
+ hubs: [hub],
+ defaultHub: hub.name,
+ productName: productNameEl.value.trim() || 'CS3D-EXAMPLE',
+ callbackUrl: `${window.location.origin}/castCallback`,
+ messageIdPrefix: 'CAST-',
+ autoReconnect: true,
+ });
+ client.onMessage((message) => {
+ addMessage('received', 'Received', message);
+ });
+ }
+ client.setTopic(topicEl.value.trim());
+ return client;
+}
+
+byId('tokenBtn').addEventListener('click', async () => {
+ setConnection('connecting', 'Getting token');
+ try {
+ const c = ensureClient(true);
+ const hubPreset =
+ HUB_DEFINITIONS[hubSelectEl.value as keyof typeof HUB_DEFINITIONS];
+ const tokenFormData = new URLSearchParams();
+ tokenFormData.append('client_id', selectedClientId || hubPreset.client_id);
+ tokenFormData.append('grant_type', 'client_credentials');
+ tokenFormData.append(
+ 'client_secret',
+ selectedClientSecret || hubPreset.client_secret
+ );
+ const response = await fetch(tokenEndpointEl.value.trim(), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: tokenFormData,
+ });
+ let ok = false;
+ if (response.ok) {
+ const data = await response.json();
+ c.getHub().token = data.access_token ?? '';
+ if (data.subscriber_name) {
+ c.getHub().subscriberName = data.subscriber_name;
+ }
+ if (data.topic) {
+ c.setTopic(data.topic);
+ }
+ ok = Boolean(c.getHub().token);
+ }
+ hasToken = ok;
+ subscribeBtnEl.disabled = !ok;
+ if (ok) {
+ setConnection('disconnected', 'Token ready');
+ const hub = c.getHub();
+ if (hub.subscriberName) {
+ subscriberNameEl.value = hub.subscriberName;
+ getSubscriberEl.value = hub.subscriberName;
+ }
+ if (hub.topic) {
+ topicEl.value = hub.topic;
+ publishTopicEl.value = hub.topic;
+ getTopicEl.value = hub.topic;
+ topicDisplayEl.textContent = hub.topic;
+ }
+ addMessage('received', 'Token', 'Token obtained');
+ } else {
+ setConnection('disconnected', 'Token failed');
+ addMessage('err', 'Token error', 'Failed to get token');
+ }
+ } catch (error) {
+ hasToken = false;
+ subscribeBtnEl.disabled = true;
+ setConnection('disconnected', 'Token error');
+ addMessage('err', 'Token exception', String(error));
+ }
+});
+
+subscribeBtnEl.addEventListener('click', async () => {
+ setConnection('connecting', 'Subscribing');
+ const c = ensureClient();
+ const result = await c.subscribe();
+ if (result === 202) {
+ setConnection('connected', 'Websocket connected');
+ byId('unsubscribeBtn').disabled = false;
+ byId('publishBtn').disabled = false;
+ byId('getBtn').disabled = false;
+ byId('conferenceBtn').disabled = false;
+ addMessage('sent', 'Subscribe', { topic: topicEl.value.trim() });
+ } else {
+ setConnection('disconnected', `Subscribe failed (${String(result)})`);
+ addMessage(
+ 'err',
+ 'Subscribe error',
+ `Subscribe failed (${String(result)})`
+ );
+ }
+});
+
+byId('unsubscribeBtn').addEventListener(
+ 'click',
+ async () => {
+ if (!client) return;
+ await client.unsubscribe();
+ byId('unsubscribeBtn').disabled = true;
+ byId('publishBtn').disabled = true;
+ byId('getBtn').disabled = true;
+ byId('conferenceBtn').disabled = true;
+ subscribeBtnEl.disabled = false;
+ setConnection('disconnected', 'Not connected');
+ addMessage('sent', 'Unsubscribe', topicEl.value.trim());
+ }
+);
+
+byId('publishBtn').addEventListener('click', async () => {
+ if (!client) {
+ addMessage('err', 'Publish error', 'Subscribe first');
+ return;
+ }
+ const eventType =
+ eventTypeEl.value === 'custom'
+ ? eventTypeCustomEl.value.trim()
+ : eventTypeEl.value;
+ if (!eventType) {
+ addMessage('err', 'Publish error', 'Event type required');
+ return;
+ }
+ let context: unknown;
+ try {
+ context = JSON.parse(eventDataEl.value || '[]');
+ } catch {
+ addMessage('err', 'Publish error', 'Invalid Event Data JSON');
+ return;
+ }
+ const payload: Record = {
+ event: {
+ 'hub.topic': publishTopicEl.value.trim(),
+ 'hub.event': eventType,
+ context,
+ },
+ };
+ const actorValue = parseActorField(publishActorPresetEl.value.trim());
+ if (actorValue !== undefined) {
+ payload.actor = actorValue;
+ }
+ const res = await client.publish(payload, client.getHub());
+ if (res?.ok) {
+ addMessage('sent', 'Publish', payload);
+ } else {
+ addMessage(
+ 'err',
+ 'Publish error',
+ res ? `HTTP ${res.status}` : 'No response'
+ );
+ }
+});
+
+byId('getBtn').addEventListener('click', async () => {
+ const subscriber = getSubscriberEl.value.trim();
+ if (!subscriber) {
+ addMessage('err', 'Get error', 'Subscriber is required');
+ return;
+ }
+ const endpoint = getEndpointEl.value.trim();
+ const dataType = getDataTypeEl.value;
+ const url = new URL(endpoint);
+ url.searchParams.set('subscriber', subscriber);
+ const getTopic = getTopicEl.value.trim();
+ if (getTopic) url.searchParams.set('topic', getTopic);
+ if (dataType) url.searchParams.set('dataType', dataType);
+ const getActorRaw = getActorPresetEl.value.trim();
+ if (getActorRaw) {
+ url.searchParams.set('actor', getActorRaw);
+ }
+
+ const response = await fetch(url.toString(), { method: 'GET' });
+ if (response.ok) {
+ const json = await response.json();
+ byId('getResults').innerHTML =
+ `${JSON.stringify(json, null, 2)} `;
+ addMessage('received', 'Get', json);
+ } else {
+ const text = await response.text();
+ byId('getResults').innerHTML = '';
+ addMessage('err', 'Get error', `${response.status} ${text}`);
+ }
+});
+
+byId('conferenceBtn').addEventListener('click', () => {
+ const url = new URL(conferenceEndpointEl.value.trim());
+ url.searchParams.set('subscriberName', subscriberNameEl.value.trim());
+ url.searchParams.set('topic', topicEl.value.trim());
+ window.open(url.toString(), '_blank');
+});
+
+byId('clearBtn').addEventListener('click', () => {
+ messagesEl.innerHTML = '';
+ messageCount = 0;
+ messageCountEl.textContent = '(0)';
+});
+
+window.addEventListener('beforeunload', () => {
+ client?.destroy();
+});
diff --git a/packages/cast/package.json b/packages/cast/package.json
new file mode 100644
index 0000000000..3c945c4a89
--- /dev/null
+++ b/packages/cast/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@cornerstonejs/cast",
+ "version": "4.20.0",
+ "description": "Cast hub / WebSocket networking client for Cornerstone-related viewers",
+ "files": [
+ "dist"
+ ],
+ "module": "./dist/esm/index.js",
+ "types": "./dist/esm/index.d.ts",
+ "directories": {
+ "build": "dist"
+ },
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "import": "./dist/esm/index.js",
+ "types": "./dist/esm/index.d.ts"
+ },
+ "./version": {
+ "node": "./dist/esm/version.js",
+ "import": "./dist/esm/version.js",
+ "types": "./dist/esm/version.d.ts"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "prebuild": "node ../../scripts/generate-version.js ./",
+ "test": "jest --testTimeout 60000",
+ "clean": "shx rm -rf dist",
+ "clean:deep": "yarn run clean && shx rm -rf node_modules",
+ "build": "yarn run build:esm",
+ "build:esm": "tsc --project ./tsconfig.json",
+ "build:esm:watch": "tsc --project ./tsconfig.json --watch",
+ "dev": "tsc --project ./tsconfig.json --watch",
+ "build:all": "yarn run build:esm",
+ "start": "tsc --project ./tsconfig.json --watch",
+ "format": "prettier --write 'src/**/*.ts'",
+ "lint": "oxlint .",
+ "api-check": "api-extractor --debug run ",
+ "prepublishOnly": "yarn clean && yarn build"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/cornerstonejs/cornerstone3D.git"
+ },
+ "author": "@cornerstonejs",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/cornerstonejs/cornerstone3D/issues"
+ },
+ "homepage": "https://github.com/cornerstonejs/cornerstone3D/blob/main/packages/cast/README.md"
+}
diff --git a/packages/cast/src/CastClient.ts b/packages/cast/src/CastClient.ts
new file mode 100644
index 0000000000..c574ec932b
--- /dev/null
+++ b/packages/cast/src/CastClient.ts
@@ -0,0 +1,405 @@
+import type { HubConfig, CastMessage, CastClientConfig } from './types';
+import { generateMessageId } from './generateMessageId';
+import { RECONNECT_INTERVAL_MS, SUBSCRIBE_TIMEOUT_MS } from './constants';
+
+export interface CastTransport {
+ getHub(): HubConfig;
+ sendGetResponse(requestId: string, data: unknown, topic?: string): void;
+}
+
+const DEFAULT_MESSAGE_ID_PREFIX = 'OHIF-';
+
+export class CastClient implements CastTransport {
+ private _config: CastClientConfig;
+ private _hub: HubConfig;
+ private _reconnectInterval: ReturnType | null = null;
+ private _onMessageCallback: ((message: CastMessage) => void) | null = null;
+
+ constructor(config: CastClientConfig = {}) {
+ this._config = config;
+ this._hub = this._createEmptyHub();
+
+ if (config.hubs?.length && config.defaultHub) {
+ this.setHub(config.defaultHub);
+ }
+
+ if (config.autoReconnect) {
+ this._reconnectInterval = setInterval(
+ () => this._checkWebsocket(),
+ RECONNECT_INTERVAL_MS
+ );
+ }
+ }
+
+ destroy(): void {
+ if (this._reconnectInterval) {
+ clearInterval(this._reconnectInterval);
+ this._reconnectInterval = null;
+ }
+ this.unsubscribe();
+ }
+
+ onMessage(callback: (message: CastMessage) => void): void {
+ this._onMessageCallback = callback;
+ }
+
+ setHub(hubName: string): boolean {
+ if (hubName === this._hub.name) {
+ console.debug('CastClient: setHub: hub already set to', hubName);
+ return true;
+ }
+ console.debug('CastClient: setting hub to', hubName);
+ const hubs = this._config.hubs;
+ if (!hubs) {
+ console.debug('CastClient: hub not found in configuration', hubName);
+ return false;
+ }
+ for (const hubConfig of hubs) {
+ if (hubConfig.enabled && hubConfig.name === hubName) {
+ if (this._hub.subscribed) {
+ this.unsubscribe();
+ }
+ this._hub = { ...hubConfig, subscribed: false } as HubConfig;
+ return true;
+ }
+ }
+ console.debug('CastClient: hub not found in configuration', hubName);
+ return false;
+ }
+
+ getHub(): HubConfig {
+ return this._hub;
+ }
+
+ setTopic(topic: string): void {
+ console.debug('CastClient: setting topic to', topic);
+ this._hub.topic = topic;
+ }
+
+ async getToken(): Promise {
+ const hub = this._hub;
+ try {
+ const url = new URL(hub.token_endpoint);
+ console.debug(
+ 'CastClient: Getting token from:',
+ url.origin + url.pathname
+ );
+ } catch {
+ console.debug('CastClient: Getting token from hub');
+ }
+
+ const tokenFormData = new URLSearchParams();
+ tokenFormData.append('grant_type', 'client_credentials');
+ tokenFormData.append('client_id', hub.client_id ?? '');
+ tokenFormData.append('client_secret', hub.client_secret ?? '');
+ tokenFormData.append(
+ 'client_product_name',
+ this._config.productName ?? 'OHIF'
+ );
+
+ const requestOptions = {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: tokenFormData,
+ };
+
+ try {
+ const response = await fetch(hub.token_endpoint, requestOptions);
+ if (response.status === 200) {
+ const config = await response.json();
+ if (config.access_token) {
+ hub.token = config.access_token;
+ }
+ hub.subscriberName = config.subscriber_name;
+ if (config.topic) {
+ this.setTopic(config.topic);
+ if (this._config.autoStart) {
+ this.subscribe();
+ }
+ }
+ return true;
+ }
+ await response.text(); // consume body (may contain sensitive data; do not log)
+ console.error(
+ 'CastClient: Error getting token. Status:',
+ response.status
+ );
+ return false;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error('CastClient: Exception getting token:', message);
+ return false;
+ }
+ }
+
+ async subscribe(): Promise {
+ const hub = this._hub;
+ if (hub.topic === undefined) {
+ console.warn(
+ 'CastClient: Error. subscription not sent. No topic defined.'
+ );
+ return 'error: topic not defined';
+ }
+ if (!hub.token) {
+ console.warn(
+ 'CastClient: Error. subscription not sent. No token available.'
+ );
+ return 'error: no token';
+ }
+
+ const callbackUrl =
+ this._config.callbackUrl ??
+ (typeof window !== 'undefined'
+ ? `${window.location.origin}/castCallback`
+ : '');
+ const subscribeFormData = new URLSearchParams();
+ subscribeFormData.append('hub.mode', 'subscribe');
+ subscribeFormData.append('hub.channel.type', 'websocket');
+ subscribeFormData.append('hub.callback', callbackUrl);
+ subscribeFormData.append('hub.events', (hub.events ?? []).toString());
+ subscribeFormData.append('hub.topic', hub.topic);
+ subscribeFormData.append('hub.lease', String(hub.lease ?? 999));
+ subscribeFormData.append('subscriber.name', hub.subscriberName ?? '');
+ for (const a of hub.actors ?? []) {
+ const v = a.trim();
+ if (v) subscribeFormData.append('subscriber.actor', v);
+ }
+
+ const requestOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: 'Bearer ' + hub.token,
+ },
+ body: subscribeFormData,
+ signal: AbortSignal.timeout(SUBSCRIBE_TIMEOUT_MS),
+ };
+
+ try {
+ const response = await fetch(hub.hub_endpoint, requestOptions);
+ if (response.status === 202) {
+ hub.subscribed = true;
+ hub.resubscribeRequested = false;
+ const subscriptionResponse = await response.json();
+ const websocketUrl = subscriptionResponse['hub.channel.endpoint'];
+
+ let normalizedWebsocketUrl = websocketUrl;
+ try {
+ const hubEndpointUrl = new URL(hub.hub_endpoint);
+ const wsUrl = new URL(websocketUrl);
+ const wsProtocol =
+ hubEndpointUrl.protocol === 'https:' ? 'wss:' : 'ws:';
+ normalizedWebsocketUrl = websocketUrl.replace(
+ wsUrl.origin,
+ `${wsProtocol}//${hubEndpointUrl.host}`
+ );
+ } catch {
+ // use original
+ }
+
+ hub.websocket = new WebSocket(normalizedWebsocketUrl);
+ hub.websocket.onopen = function () {
+ (this as WebSocket).send(
+ '{"hub.channel.endpoint":"' + normalizedWebsocketUrl + '"}'
+ );
+ };
+ hub.websocket.addEventListener('message', (ev) =>
+ this._processEvent(ev.data)
+ );
+ hub.websocket.addEventListener('close', () => this._websocketClose());
+ hub.websocket.onerror = function () {
+ console.warn('CastClient: Error reported on websocket');
+ };
+
+ return response.status;
+ }
+ if (response.status === 401) {
+ console.warn(
+ 'CastClient: Subscription response 401 - Token refresh needed.'
+ );
+ this.getToken();
+ } else {
+ console.error(
+ 'CastClient: Subscription rejected by hub. Status:',
+ response.status
+ );
+ }
+ return response.status;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error('CastClient: Exception subscribing to the hub:', message);
+ return 0;
+ }
+ }
+
+ async unsubscribe(): Promise {
+ const hub = this._hub;
+ hub.subscribed = false;
+ hub.resubscribeRequested = false;
+
+ const callbackUrl =
+ this._config.callbackUrl ??
+ (typeof window !== 'undefined'
+ ? `${window.location.origin}/castCallback`
+ : '');
+ const subscribeFormData = new URLSearchParams();
+ subscribeFormData.append('hub.mode', 'unsubscribe');
+ subscribeFormData.append('hub.channel.type', 'websocket');
+ subscribeFormData.append('hub.callback', callbackUrl);
+ subscribeFormData.append('hub.events', (hub.events ?? []).toString());
+ subscribeFormData.append('hub.topic', hub.topic ?? '');
+ subscribeFormData.append('hub.lease', String(hub.lease ?? 999));
+ subscribeFormData.append('subscriber.name', hub.subscriberName ?? '');
+ for (const a of hub.actors ?? []) {
+ const v = a.trim();
+ if (v) subscribeFormData.append('subscriber.actor', v);
+ }
+
+ try {
+ const response = await fetch(hub.hub_endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: 'Bearer ' + hub.token,
+ },
+ body: subscribeFormData,
+ signal: AbortSignal.timeout(SUBSCRIBE_TIMEOUT_MS),
+ });
+ if (response.status === 202) {
+ console.debug(
+ 'CastClient: Unsubscribe successfully from hub',
+ hub.name
+ );
+ }
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.warn('CastClient: Error unsubscribing from the hub.', message);
+ }
+ if (hub.websocket) {
+ hub.websocket.close();
+ hub.websocket = null;
+ }
+ }
+
+ async publish(
+ castMessage: Record,
+ hub: HubConfig
+ ): Promise {
+ const timestamp = new Date();
+ const msg = { ...castMessage, timestamp: timestamp.toJSON() } as Record<
+ string,
+ unknown
+ >;
+ msg.id = generateMessageId(this._messageIdPrefix());
+ hub.lastPublishedMessageID = msg.id as string;
+
+ const event = msg.event as Record;
+ if (event) {
+ event['hub.topic'] = hub.topic;
+ }
+
+ const hubEndpoint = hub.hub_endpoint;
+
+ try {
+ const response = await fetch(hubEndpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'Bearer ' + hub.token,
+ },
+ body: JSON.stringify(msg),
+ });
+ return response;
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.debug('CastClient:', message);
+ return null;
+ }
+ }
+
+ sendGetResponse(requestId: string, data: unknown, topic?: string): void {
+ const hub = this._hub;
+ if (!hub.websocket || hub.websocket.readyState !== WebSocket.OPEN) {
+ return;
+ }
+ const response = {
+ timestamp: new Date().toJSON(),
+ id: generateMessageId(this._messageIdPrefix()),
+ event: {
+ 'hub.topic': topic ?? hub.topic,
+ 'hub.event': 'get-response',
+ context: { requestId, data },
+ },
+ };
+ hub.websocket.send(JSON.stringify(response));
+ }
+
+ private _messageIdPrefix(): string {
+ return this._config.messageIdPrefix ?? DEFAULT_MESSAGE_ID_PREFIX;
+ }
+
+ private _createEmptyHub(): HubConfig {
+ return {
+ name: '',
+ friendlyName: '',
+ productName: '',
+ enabled: false,
+ events: [],
+ lease: 999,
+ hub_endpoint: '',
+ authorization_endpoint: '',
+ token_endpoint: '',
+ token: '',
+ subscriberName: '',
+ actors: [],
+ topic: '',
+ lastPublishedMessageID: '',
+ subscribed: false,
+ resubscribeRequested: false,
+ websocket: null,
+ };
+ }
+
+ private async _checkWebsocket(): Promise {
+ const hub = this._hub;
+ if (
+ hub.resubscribeRequested &&
+ hub.subscribed &&
+ this._config.autoReconnect
+ ) {
+ console.debug('CastClient: Try to resubscribe');
+ hub.resubscribeRequested = false;
+ const response = await this.subscribe();
+ if (response !== 202) {
+ hub.resubscribeRequested = true;
+ }
+ } else if (!hub.subscribed && hub.resubscribeRequested) {
+ hub.resubscribeRequested = false;
+ }
+ }
+
+ private _processEvent(eventData: string): void {
+ try {
+ const castMessage = JSON.parse(eventData) as CastMessage;
+ if (castMessage['hub.mode' as keyof CastMessage]) {
+ return;
+ }
+ const event = castMessage.event;
+ if (!event) return;
+ if (event['hub.event'] === 'heartbeat') {
+ return;
+ }
+ if (castMessage.id === this._hub.lastPublishedMessageID) {
+ return;
+ }
+ this._onMessageCallback?.(castMessage);
+ } catch (err) {
+ console.warn('CastClient: websocket processing error:', err);
+ }
+ }
+
+ private _websocketClose(): void {
+ console.debug('CastClient: websocket is closed.');
+ this._hub.resubscribeRequested = true;
+ }
+}
diff --git a/packages/cast/src/constants.ts b/packages/cast/src/constants.ts
new file mode 100644
index 0000000000..eded101eff
--- /dev/null
+++ b/packages/cast/src/constants.ts
@@ -0,0 +1,5 @@
+/** Interval (ms) for checking WebSocket and attempting reconnect when autoReconnect is enabled. */
+export const RECONNECT_INTERVAL_MS = 10_000;
+
+/** Timeout (ms) for subscribe/unsubscribe HTTP requests. */
+export const SUBSCRIBE_TIMEOUT_MS = 5000;
diff --git a/packages/cast/src/generateMessageId.ts b/packages/cast/src/generateMessageId.ts
new file mode 100644
index 0000000000..bd6538dc0d
--- /dev/null
+++ b/packages/cast/src/generateMessageId.ts
@@ -0,0 +1,9 @@
+/**
+ * Generates a unique message ID for cast publish. Prefers crypto.randomUUID when available.
+ */
+export function generateMessageId(prefix = 'OHIF-'): string {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+ return prefix + crypto.randomUUID().replace(/-/g, '').slice(0, 16);
+ }
+ return prefix + Math.random().toString(36).substring(2, 18);
+}
diff --git a/packages/cast/src/index.ts b/packages/cast/src/index.ts
new file mode 100644
index 0000000000..1a6cd8ede6
--- /dev/null
+++ b/packages/cast/src/index.ts
@@ -0,0 +1,5 @@
+export { CastClient, type CastTransport } from './CastClient';
+export type { HubConfig, CastMessage, CastClientConfig } from './types';
+export { RECONNECT_INTERVAL_MS, SUBSCRIBE_TIMEOUT_MS } from './constants';
+export { generateMessageId } from './generateMessageId';
+export { version } from './version';
diff --git a/packages/cast/src/types.ts b/packages/cast/src/types.ts
new file mode 100644
index 0000000000..b907c059fe
--- /dev/null
+++ b/packages/cast/src/types.ts
@@ -0,0 +1,45 @@
+export interface HubConfig {
+ name: string;
+ friendlyName?: string;
+ productName?: string;
+ enabled: boolean;
+ events: string[];
+ lease: number;
+ hub_endpoint: string;
+ authorization_endpoint?: string;
+ token_endpoint: string;
+ client_id?: string;
+ client_secret?: string;
+ token?: string;
+ subscriberName?: string;
+ /** Optional; each entry is sent as a repeated `subscriber.actor` form field. */
+ actors?: string[];
+ topic?: string;
+ subscribed?: boolean;
+ resubscribeRequested?: boolean;
+ websocket?: WebSocket | null;
+ lastPublishedMessageID?: string;
+}
+
+export interface CastMessage {
+ id?: string;
+ timestamp?: string;
+ event?: {
+ 'hub.event': string;
+ 'hub.topic'?: string;
+ context?: unknown;
+ };
+}
+
+export interface CastClientConfig {
+ hubs?: HubConfig[];
+ defaultHub?: string;
+ productName?: string;
+ callbackUrl?: string;
+ autoStart?: boolean;
+ autoReconnect?: boolean;
+ /**
+ * Prefix for generated message IDs (publish and get-response). Defaults to `OHIF-` for backward compatibility.
+ */
+ messageIdPrefix?: string;
+}
diff --git a/packages/cast/src/version.ts b/packages/cast/src/version.ts
new file mode 100644
index 0000000000..7abc36b825
--- /dev/null
+++ b/packages/cast/src/version.ts
@@ -0,0 +1,5 @@
+/**
+ * Auto-generated from version.json
+ * Do not modify this file directly
+ */
+export const version = '4.20.0';
diff --git a/packages/cast/tsconfig.json b/packages/cast/tsconfig.json
new file mode 100644
index 0000000000..bc915f1e65
--- /dev/null
+++ b/packages/cast/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist/esm",
+ "rootDir": "./src"
+ },
+ "include": ["./src/**/*"]
+}
diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts
index 46d4bd3f59..c17315825f 100644
--- a/packages/tools/examples/volumeCroppingTool/index.ts
+++ b/packages/tools/examples/volumeCroppingTool/index.ts
@@ -101,8 +101,8 @@ const renderingEngineId = 'myRenderingEngine';
/////////////////////////////////////////
// ======== Set up page ======== //
setTitleAndDescription(
- 'Volume Cropping with Orientation Controller',
- 'Here we demonstrate how to crop a 3D volume with 6 clipping planes and an orientation controller. Use shift-drag to rotate the planes.'
+ 'Volume Cropping and Orientation Controller',
+ 'Demonstrates the volume cropping and the orientation controller tools in a volume3d viewport along with the volume cropping control tool in 1 to 3 orthographic viewports.'
);
const size = '400px';
@@ -169,7 +169,6 @@ instructions.innerText = `
Basic controls:
- Click/Drag the spheres in 3D or reference lines in the orthographic viewports.
- Rotate, pan or zoom the 3D viewport using the mouse.
- - Shift+Drag in the 3D viewport to rotate the clipping planes.
- Use the scroll wheel to scroll through the slices in the orthographic viewports.
- Toggle the clipping planes, handles, and rotate clipping planes on drag.
- Click on the faces/edges/corners of the beveled cube orientation widget to change the orientation.
@@ -178,6 +177,34 @@ instructions.innerText = `
content.append(instructions);
+const rotateHintOverlay = document.createElement('div');
+rotateHintOverlay.textContent = 'Use SHIFT-drag to rotate the clipping planes.';
+rotateHintOverlay.style.position = 'absolute';
+rotateHintOverlay.style.top = '10px';
+rotateHintOverlay.style.left = '50%';
+rotateHintOverlay.style.transform = 'translateX(-50%)';
+rotateHintOverlay.style.padding = '4px 8px';
+rotateHintOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.55)';
+rotateHintOverlay.style.color = 'white';
+rotateHintOverlay.style.fontSize = '12px';
+rotateHintOverlay.style.borderRadius = '4px';
+rotateHintOverlay.style.pointerEvents = 'none';
+rotateHintOverlay.style.zIndex = '2';
+rotateHintOverlay.style.display = 'none';
+element4.appendChild(rotateHintOverlay);
+
+const updateRotateHintVisibility = () => {
+ const toolGroupVRT =
+ cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupIdVRT);
+ const croppingTool = toolGroupVRT?.getToolInstance('VolumeCropping');
+ const isCroppingActive =
+ !!croppingTool &&
+ typeof croppingTool.getClippingPlanesVisible === 'function' &&
+ croppingTool.getClippingPlanesVisible();
+
+ rotateHintOverlay.style.display = isCroppingActive ? 'block' : 'none';
+};
+
const croppingLabel = document.createElement('span');
croppingLabel.textContent = 'Cropping:';
croppingLabel.style.marginRight = '4px';
@@ -198,6 +225,7 @@ addToggleButtonToToolbar({
croppingTool.setClippingPlanesVisible(
!croppingTool.getClippingPlanesVisible()
);
+ updateRotateHintVisibility();
}
},
});
@@ -402,17 +430,9 @@ async function run(numViewports = getNumViewportsFromUrl()) {
],
});
- const colorScheme: 'rgy' | 'gray' | 'marker' = 'rgy';
+ const colorScheme: 'rgy' | 'gray' | 'marker' = 'gray';
const keepOrientationUp = true;
- const letterColorScheme: 'mixed' | 'white' | 'black' = 'mixed';
-
- toolGroup.addTool(OrientationControllerTool.toolName, {
- colorScheme,
- keepOrientationUp,
- letterColorScheme,
- position: 'top-right',
- });
- toolGroup.setToolEnabled(OrientationControllerTool.toolName);
+ const letterColorScheme: 'mixed' | 'white' | 'black' = 'white';
// Tool group for 3D viewport
const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT);
@@ -558,6 +578,45 @@ async function run(numViewports = getNumViewportsFromUrl()) {
},
});
+ addSliderToToolbar({
+ title: 'Handles size',
+ range: [2, 20],
+ defaultValue: 7,
+ step: 1,
+ container: planesRow,
+ onSelectedValueChange: (value) => {
+ const sphereRadius = Number(value);
+ const toolGroupVRT =
+ cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupIdVRT);
+ const croppingTool = toolGroupVRT.getToolInstance(
+ VolumeCroppingTool.toolName
+ );
+
+ if (!croppingTool) {
+ return;
+ }
+
+ croppingTool.configuration.sphereRadius = sphereRadius;
+
+ if (Array.isArray(croppingTool.sphereStates)) {
+ croppingTool.sphereStates.forEach((state) => {
+ if (state?.sphereSource?.setRadius) {
+ state.sphereSource.setRadius(sphereRadius);
+ state.sphereSource.modified();
+ }
+ });
+ }
+
+ const viewport = croppingTool._getViewport?.();
+ if (viewport) {
+ viewport.render();
+ }
+ },
+ updateLabelOnChange: (value, label) => {
+ label.textContent = `Handles size: ${value}`;
+ },
+ });
+
const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D;
await setVolumesForViewports(
@@ -608,6 +667,7 @@ async function run(numViewports = getNumViewportsFromUrl()) {
if (croppingTool && typeof croppingTool.setHandlesVisible === 'function') {
croppingTool.setHandlesVisible(false);
}
+ updateRotateHintVisibility();
// Clipping off on load; user enables via Toggle Clipping Planes button
renderingEngine.renderViewports(activeViewportIds);
}
diff --git a/packages/tools/src/tools/OrientationControllerTool.ts b/packages/tools/src/tools/OrientationControllerTool.ts
index 8146343119..e47c83e40b 100644
--- a/packages/tools/src/tools/OrientationControllerTool.ts
+++ b/packages/tools/src/tools/OrientationControllerTool.ts
@@ -113,6 +113,8 @@ class OrientationControllerTool extends BaseTool {
private widget = new vtkOrientationControllerWidget();
private resizeObservers = new Map();
private cameraHandlers = new Map void>();
+ private animationFrameHandles = new Map();
+ private animationTokens = new Map();
constructor(
toolProps = {},
@@ -320,6 +322,11 @@ class OrientationControllerTool extends BaseTool {
this.resizeObservers.forEach((observer) => observer.disconnect());
this.resizeObservers.clear();
this.cameraHandlers.clear();
+ this.animationFrameHandles.forEach((handle) =>
+ cancelAnimationFrame(handle)
+ );
+ this.animationFrameHandles.clear();
+ this.animationTokens.clear();
}
private addMarkers = (): void => {
@@ -370,6 +377,10 @@ class OrientationControllerTool extends BaseTool {
viewportId: string,
renderingEngineId: string
): void {
+ if (this.widget.getActors(viewportId)) {
+ return;
+ }
+
const enabledElement = getEnabledElementByIds(
viewportId,
renderingEngineId
@@ -444,12 +455,12 @@ class OrientationControllerTool extends BaseTool {
}
},
onFaceHover: (result) => {
- if (result && result.actorIndex !== 0) {
+ if (result) {
this.widget.highlightFace(
result.pickedActor,
result.cellId,
volumeViewport,
- false
+ result.actorIndex === 0
);
} else {
this.widget.clearHighlight();
@@ -512,14 +523,13 @@ class OrientationControllerTool extends BaseTool {
return;
}
- // Recalculate both size and position to maintain fixed screen size
- // Size needs to be recalculated because parallel scale changes with zoom
const volumeViewport = viewport as Types.IVolumeViewport;
this.widget.positionActors(
volumeViewport,
actors,
this.getPositionConfig()
);
+ this.widget.syncOverlayViewport(viewportId, volumeViewport);
viewport.render();
};
@@ -564,6 +574,15 @@ class OrientationControllerTool extends BaseTool {
targetViewPlaneNormal: number[],
targetViewUp: number[]
): void {
+ const viewportId = viewport.id;
+ const existingHandle = this.animationFrameHandles.get(viewportId);
+ if (existingHandle !== undefined) {
+ cancelAnimationFrame(existingHandle);
+ this.animationFrameHandles.delete(viewportId);
+ }
+ const nextToken = (this.animationTokens.get(viewportId) ?? 0) + 1;
+ this.animationTokens.set(viewportId, nextToken);
+
const keepOrientationUp = this.configuration.keepOrientationUp !== false; // Default to true
// Get the VTK camera from the renderer
@@ -594,55 +613,51 @@ class OrientationControllerTool extends BaseTool {
0, 0, 0, 1
);
+ const targetForward = vec3.normalize(
+ vec3.create(),
+ targetViewPlaneNormal as vec3
+ );
+
let targetUp: vec3;
if (keepOrientationUp) {
- // Use the target viewUp as specified (original behavior)
targetUp = vec3.fromValues(
targetViewUp[0],
targetViewUp[1],
targetViewUp[2]
);
} else {
- // Keep current viewUp, but project it onto the plane perpendicular to targetViewPlaneNormal
- // to ensure orthogonality
- const currentUp = vec3.normalize(vec3.create(), startUp);
+ const currentFwd = vec3.normalize(vec3.create(), startForward);
- // Normalize targetViewPlaneNormal for projection
- const normalizedForward = vec3.create();
- vec3.normalize(normalizedForward, targetViewPlaneNormal as vec3);
+ const rotQuat = quat.create();
+ quat.rotationTo(rotQuat, currentFwd, targetForward);
- // Project currentUp onto the plane perpendicular to targetViewPlaneNormal
- // Remove the component of currentUp that's parallel to targetViewPlaneNormal
- const dot = vec3.dot(currentUp, normalizedForward);
targetUp = vec3.create();
- vec3.scaleAndAdd(targetUp, currentUp, normalizedForward, -dot);
+ vec3.transformQuat(targetUp, startUp, rotQuat);
vec3.normalize(targetUp, targetUp);
+ }
- // If the projection results in a zero vector (currentUp was parallel to targetViewPlaneNormal),
- // use a default up vector
- if (vec3.length(targetUp) < 0.001) {
- // Use a default up vector perpendicular to targetViewPlaneNormal
- if (Math.abs(normalizedForward[2]) < 0.9) {
- targetUp = vec3.fromValues(0, 0, 1);
- } else {
- targetUp = vec3.fromValues(0, 1, 0);
- }
- // Project and normalize
- const dot2 = vec3.dot(targetUp, normalizedForward);
- vec3.scaleAndAdd(targetUp, targetUp, normalizedForward, -dot2);
- vec3.normalize(targetUp, targetUp);
- }
+ // Keep an orthonormal target camera basis so quaternion interpolation
+ // matches the final camera vectors and avoids end-of-animation snapping.
+ const upDotForward = vec3.dot(targetUp, targetForward);
+ vec3.scaleAndAdd(targetUp, targetUp, targetForward, -upDotForward);
+ if (vec3.length(targetUp) < 0.0001) {
+ targetUp = vec3.clone(startUp);
+ const fallbackDot = vec3.dot(targetUp, targetForward);
+ vec3.scaleAndAdd(targetUp, targetUp, targetForward, -fallbackDot);
}
+ vec3.normalize(targetUp, targetUp);
const targetRight = vec3.create();
- vec3.cross(targetRight, targetUp, targetViewPlaneNormal as vec3);
+ vec3.cross(targetRight, targetUp, targetForward);
vec3.normalize(targetRight, targetRight);
+ vec3.cross(targetUp, targetForward, targetRight);
+ vec3.normalize(targetUp, targetUp);
// prettier-ignore
const targetMatrix = mat4.fromValues(
targetRight[0], targetRight[1], targetRight[2], 0,
targetUp[0], targetUp[1], targetUp[2], 0,
- targetViewPlaneNormal[0], targetViewPlaneNormal[1], targetViewPlaneNormal[2], 0,
+ targetForward[0], targetForward[1], targetForward[2], 0,
0, 0, 0, 1
);
@@ -660,14 +675,23 @@ class OrientationControllerTool extends BaseTool {
return;
}
- const steps = 10;
const duration = 150;
- const stepDuration = duration / steps;
- let currentStep = 0;
+ const animationStart = performance.now();
- const animate = () => {
- currentStep++;
- const t = currentStep / steps;
+ const finalNormal: Point3 = [
+ targetForward[0],
+ targetForward[1],
+ targetForward[2],
+ ];
+ const finalUp: Point3 = [targetUp[0], targetUp[1], targetUp[2]];
+
+ const animate = (now: number) => {
+ if (this.animationTokens.get(viewportId) !== nextToken) {
+ return;
+ }
+ const elapsed = now - animationStart;
+ const t = Math.min(1, elapsed / duration);
+ const isLastStep = t >= 1;
const easedT = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
const interpolatedQuat = quat.create();
@@ -680,18 +704,23 @@ class OrientationControllerTool extends BaseTool {
const interpolatedUp = interpolatedMatrix.slice(4, 7) as Point3;
viewport.setCamera({
- viewPlaneNormal: interpolatedForward,
- viewUp: interpolatedUp,
+ viewPlaneNormal: isLastStep ? finalNormal : interpolatedForward,
+ viewUp: isLastStep ? finalUp : interpolatedUp,
});
viewport.resetCamera(ANIMATE_RESET_CAMERA_OPTIONS);
+
viewport.render();
- if (currentStep < steps) {
- setTimeout(animate, stepDuration);
+ if (!isLastStep) {
+ const handle = requestAnimationFrame(animate);
+ this.animationFrameHandles.set(viewportId, handle);
+ } else {
+ this.animationFrameHandles.delete(viewportId);
}
};
- animate();
+ const handle = requestAnimationFrame(animate);
+ this.animationFrameHandles.set(viewportId, handle);
}
}
diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts
index bd3768e230..ee2e1db175 100644
--- a/packages/tools/src/tools/VolumeCroppingTool.ts
+++ b/packages/tools/src/tools/VolumeCroppingTool.ts
@@ -38,6 +38,9 @@ import {
calculateAdaptiveSphereRadius,
} from '../utilities/draw3D';
+const ORIENTATION_CONTROLLER_ACTIVE_DRAG_ATTR =
+ 'data-cs-orientation-controller-drag';
+
/**
* VolumeCroppingTool provides manipulatable spheres and real-time volume cropping capabilities.
* It renders interactive handles (spheres) at face centers and corners of a cropping box, allowing users to precisely adjust volume boundaries through direct manipulation in 3D space.
@@ -197,6 +200,7 @@ class VolumeCroppingTool extends BaseTool {
originalClippingPlanes: ClippingPlane[] = [];
draggingSphereIndex: number | null = null;
rotatePlanesOnDrag: boolean = false; // If true, dragging rotates clipping planes instead of camera
+ suppressPlaneRotationForCurrentDrag: boolean = false;
cornerDragOffset: [number, number, number] | null = null;
faceDragOffset: number | null = null;
// Store volume direction vectors for non-axis-aligned volumes
@@ -380,6 +384,9 @@ class VolumeCroppingTool extends BaseTool {
preMouseDownCallback = (evt: EventTypes.InteractionEventType) => {
const eventDetail = evt.detail;
const { element } = eventDetail;
+ this.suppressPlaneRotationForCurrentDrag = element.hasAttribute(
+ ORIENTATION_CONTROLLER_ACTIVE_DRAG_ATTR
+ );
const enabledElement = getEnabledElement(element);
const { viewport } = enabledElement;
const actorEntry = viewport.getDefaultActor();
@@ -479,6 +486,7 @@ class VolumeCroppingTool extends BaseTool {
this.draggingSphereIndex = null;
this.cornerDragOffset = null;
this.faceDragOffset = null;
+ this.suppressPlaneRotationForCurrentDrag = false;
viewport.render();
this._hasResolutionChanged = false;
@@ -679,7 +687,10 @@ class VolumeCroppingTool extends BaseTool {
this._onMouseMoveSphere(evt);
} else {
const shiftKey = (evt.detail.event as MouseEvent)?.shiftKey ?? false;
- if (this.rotatePlanesOnDrag === true || shiftKey) {
+ if (
+ (this.rotatePlanesOnDrag === true || shiftKey) &&
+ !this.suppressPlaneRotationForCurrentDrag
+ ) {
this._rotateClippingPlanes(evt);
return;
}
diff --git a/packages/tools/src/utilities/vtkjs/OrientationControllerWidget/index.ts b/packages/tools/src/utilities/vtkjs/OrientationControllerWidget/index.ts
index 1bd97f447d..58d717fc31 100644
--- a/packages/tools/src/utilities/vtkjs/OrientationControllerWidget/index.ts
+++ b/packages/tools/src/utilities/vtkjs/OrientationControllerWidget/index.ts
@@ -1,6 +1,6 @@
import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker';
import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor';
-import type vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer';
+import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer';
import { Enums } from '@cornerstonejs/core';
import type { Types } from '@cornerstonejs/core';
import vtkAnnotatedRhombicuboctahedronActor from '../AnnotatedRhombicuboctahedronActor';
@@ -41,15 +41,33 @@ export interface MouseHandlersCallbacks {
}
export class vtkOrientationControllerWidget {
+ private static readonly ACTIVE_DRAG_ATTR =
+ 'data-cs-orientation-controller-drag';
+
private actors = new Map();
private pickers = new Map();
+ private overlayRenderers = new Map<
+ string,
+ ReturnType
+ >();
+ private renderWindows = new Map<
+ string,
+ {
+ addRenderer(r: unknown): void;
+ removeRenderer(r: unknown): void;
+ setNumberOfLayers(n: number): void;
+ getNumberOfLayers(): number;
+ }
+ >();
private highlightedFace: {
actor: vtkActor;
cellId: number;
originalColor: number[];
viewport: Types.IVolumeViewport;
isMainFace: boolean;
- originalScale?: number[];
+ mainFacePointIds?: number[];
+ mainFacePositions?: number[];
+ mainFaceNormals?: number[];
} | null = null;
private mouseHandlers = new Map<
string,
@@ -146,9 +164,9 @@ export class vtkOrientationControllerWidget {
}
/**
- * Adds orientation controller actors to the viewport's main scene so they
- * are always visible without requiring any rendering engine changes. Actors
- * are positioned in the viewport corner and rendered with the rest of the scene.
+ * Adds orientation controller actors to a dedicated overlay renderer (layer 1)
+ * so they always render on top of the volume and other scene actors.
+ * This follows the same pattern as vtkOrientationMarkerWidget.
*/
addActorsToViewport(
viewportId: string,
@@ -160,25 +178,69 @@ export class vtkOrientationControllerWidget {
this.removeActorsFromViewport(viewportId, viewport);
}
- actors.forEach((actor, index) => {
- const uid = `orientation-controller-${viewportId}-${index}`;
- viewport.addActor({ actor, uid });
+ const renderWindow = (viewport as Types.IViewport)
+ .getRenderingEngine()
+ .getOffscreenMultiRenderWindow(viewport.id)
+ .getRenderWindow();
+
+ const mainRenderer =
+ (viewport as Types.IViewport)
+ .getRenderingEngine()
+ ?.getRenderer(viewportId) ?? viewport.getRenderer();
+
+ const vtkMainRenderer = mainRenderer as vtkRenderer;
+ const overlayRenderer = vtkRenderer.newInstance();
+ overlayRenderer.setLayer(1);
+ overlayRenderer.setInteractive(false);
+ overlayRenderer.setPreserveColorBuffer(true);
+
+ overlayRenderer.setActiveCamera(vtkMainRenderer.getActiveCamera());
+
+ // Match the main renderer's viewport region within the shared render window
+ // so the overlay only draws in this viewport's canvas area.
+ const vp = vtkMainRenderer.getViewport() as [
+ number,
+ number,
+ number,
+ number,
+ ];
+ overlayRenderer.setViewport(...vp);
+
+ if (renderWindow.getNumberOfLayers() < 2) {
+ renderWindow.setNumberOfLayers(2);
+ }
+ renderWindow.addRenderer(overlayRenderer);
+
+ actors.forEach((actor) => {
+ overlayRenderer.addActor(actor);
});
+
this.actors.set(viewportId, actors);
+ this.overlayRenderers.set(viewportId, overlayRenderer);
+ this.renderWindows.set(viewportId, renderWindow);
}
removeActorsFromViewport(
viewportId: string,
- viewport: Types.IVolumeViewport
+ _viewport: Types.IVolumeViewport
): void {
const actors = this.actors.get(viewportId);
- if (actors) {
- const uids = actors.map(
- (_, index) => `orientation-controller-${viewportId}-${index}`
- );
- viewport.removeActors(uids);
- this.actors.delete(viewportId);
+ const overlayRenderer = this.overlayRenderers.get(viewportId);
+ const renderWindow = this.renderWindows.get(viewportId);
+
+ if (actors && overlayRenderer) {
+ actors.forEach((actor) => {
+ overlayRenderer.removeActor(actor);
+ });
+ if (renderWindow) {
+ renderWindow.removeRenderer(overlayRenderer);
+ }
+ overlayRenderer.delete();
}
+
+ this.actors.delete(viewportId);
+ this.overlayRenderers.delete(viewportId);
+ this.renderWindows.delete(viewportId);
}
setupPicker(viewportId: string, actors: vtkActor[]): vtkCellPicker {
@@ -206,11 +268,12 @@ export class vtkOrientationControllerWidget {
return null;
}
- const mainRenderer =
+ const renderer =
+ this.overlayRenderers.get(viewportId) ??
(viewport as Types.IViewport)
.getRenderingEngine()
- ?.getRenderer(viewportId) ?? viewport.getRenderer();
- const renderer: vtkRenderer | null = mainRenderer as vtkRenderer | null;
+ ?.getRenderer(viewportId) ??
+ viewport.getRenderer();
if (!renderer) {
return null;
}
@@ -260,7 +323,8 @@ export class vtkOrientationControllerWidget {
calculateMarkerPosition(
viewport: Types.IVolumeViewport,
- position: PositionConfig['position']
+ position: PositionConfig['position'],
+ screenSizePixels: number
): [number, number, number] | null {
const canvas = viewport.canvas;
if (!canvas) {
@@ -271,28 +335,40 @@ export class vtkOrientationControllerWidget {
const canvasWidth = canvas.clientWidth || canvas.width / devicePixelRatio;
const canvasHeight =
canvas.clientHeight || canvas.height / devicePixelRatio;
- const cornerOffset =
- viewport.type === Enums.ViewportType.VOLUME_3D ? 55 : 35;
+
+ // We want the distance between the controller and the viewport border
+ // to scale with the controller size.
+ //
+ // We clamp the margin so the controller stays fully within the viewport
+ // even when the size is large.
+ const marginRatio =
+ viewport.type === Enums.ViewportType.VOLUME_3D ? 1.3 : 1.1;
+ const marginPxRaw = marginRatio * screenSizePixels;
+ const halfPx = screenSizePixels * 0.5;
+
+ const maxMarginX = Math.max(0, (canvasWidth - screenSizePixels) / 2);
+ const maxMarginY = Math.max(0, (canvasHeight - screenSizePixels) / 2);
+ const marginPx = Math.min(marginPxRaw, maxMarginX, maxMarginY);
let canvasX: number;
let canvasY: number;
switch (position) {
case 'top-left':
- canvasX = cornerOffset;
- canvasY = cornerOffset;
+ canvasX = marginPx + halfPx;
+ canvasY = marginPx + halfPx;
break;
case 'top-right':
- canvasX = canvasWidth - cornerOffset;
- canvasY = cornerOffset;
+ canvasX = canvasWidth - marginPx - halfPx;
+ canvasY = marginPx + halfPx;
break;
case 'bottom-left':
- canvasX = cornerOffset;
- canvasY = canvasHeight - cornerOffset;
+ canvasX = marginPx + halfPx;
+ canvasY = canvasHeight - marginPx - halfPx;
break;
default: // bottom-right
- canvasX = canvasWidth - cornerOffset;
- canvasY = canvasHeight - cornerOffset;
+ canvasX = canvasWidth - marginPx - halfPx;
+ canvasY = canvasHeight - marginPx - halfPx;
}
const canvasPos: Types.Point2 = [canvasX, canvasY];
@@ -347,7 +423,11 @@ export class vtkOrientationControllerWidget {
actors.forEach((actor) => {
actor.setScale(markerSize, markerSize, markerSize);
- const worldPos = this.calculateMarkerPosition(viewport, config.position);
+ const worldPos = this.calculateMarkerPosition(
+ viewport,
+ config.position,
+ screenSizePixels
+ );
if (!worldPos) {
console.warn(
'OrientationControllerWidget: Could not get world position'
@@ -362,6 +442,139 @@ export class vtkOrientationControllerWidget {
return true;
}
+ /**
+ * Main-face mesh uses disjoint vertex sets per quad (see RhombicuboctahedronSource MAIN_FACES),
+ * so scaling only that cell's points expands a single face plate.
+ */
+ private scaleMainFaceQuadLocally(
+ actor: vtkActor,
+ cellId: number,
+ scaleFactor: number
+ ): {
+ pointIds: number[];
+ positions: number[];
+ normals: number[];
+ } | null {
+ const mapper = actor.getMapper();
+ const polyData = mapper.getInputData() as {
+ getCellPoints: (id: number) => {
+ cellPointIds: Uint32Array | Uint16Array | null;
+ };
+ getPoints: () => {
+ getData: () => Float32Array | Float64Array;
+ modified: () => void;
+ };
+ getPointData: () => {
+ getNormals: () => {
+ getData: () => Float32Array | Float64Array;
+ modified: () => void;
+ } | null;
+ modified: () => void;
+ };
+ modified: () => void;
+ } | null;
+ if (!polyData?.getCellPoints) {
+ return null;
+ }
+ const { cellPointIds } = polyData.getCellPoints(cellId);
+ if (!cellPointIds || cellPointIds.length < 3) {
+ return null;
+ }
+ const pointIds = Array.from(cellPointIds);
+ const points = polyData.getPoints();
+ const ptsData = points.getData();
+ const normalScalars = polyData.getPointData().getNormals();
+ const normalsData = normalScalars?.getData();
+
+ const positions: number[] = [];
+ const normals: number[] = [];
+ let cx = 0;
+ let cy = 0;
+ let cz = 0;
+ for (const pid of pointIds) {
+ const o = pid * 3;
+ positions.push(ptsData[o], ptsData[o + 1], ptsData[o + 2]);
+ cx += ptsData[o];
+ cy += ptsData[o + 1];
+ cz += ptsData[o + 2];
+ if (normalsData) {
+ normals.push(normalsData[o], normalsData[o + 1], normalsData[o + 2]);
+ }
+ }
+ const nPts = pointIds.length;
+ cx /= nPts;
+ cy /= nPts;
+ cz /= nPts;
+
+ for (const pid of pointIds) {
+ const o = pid * 3;
+ const vx = ptsData[o] - cx;
+ const vy = ptsData[o + 1] - cy;
+ const vz = ptsData[o + 2] - cz;
+ ptsData[o] = cx + vx * scaleFactor;
+ ptsData[o + 1] = cy + vy * scaleFactor;
+ ptsData[o + 2] = cz + vz * scaleFactor;
+ if (normalsData) {
+ const nx = ptsData[o];
+ const ny = ptsData[o + 1];
+ const nz = ptsData[o + 2];
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
+ normalsData[o] = nx / len;
+ normalsData[o + 1] = ny / len;
+ normalsData[o + 2] = nz / len;
+ }
+ }
+ points.modified();
+ polyData.getPointData().modified();
+ polyData.modified();
+ return { pointIds, positions, normals };
+ }
+
+ private restoreMainFaceQuadGeometry(
+ actor: vtkActor,
+ pointIds: number[],
+ positions: number[],
+ normalsBackup: number[] | undefined
+ ): void {
+ const mapper = actor.getMapper();
+ const polyData = mapper.getInputData() as {
+ getPoints: () => {
+ getData: () => Float32Array | Float64Array;
+ modified: () => void;
+ };
+ getPointData: () => {
+ getNormals: () => {
+ getData: () => Float32Array | Float64Array;
+ modified: () => void;
+ } | null;
+ modified: () => void;
+ };
+ modified: () => void;
+ } | null;
+ if (!polyData) {
+ return;
+ }
+ const points = polyData.getPoints();
+ const ptsData = points.getData();
+ const normalsData = polyData.getPointData().getNormals()?.getData();
+ for (let i = 0; i < pointIds.length; i++) {
+ const pid = pointIds[i];
+ const o = pid * 3;
+ const j = i * 3;
+ ptsData[o] = positions[j];
+ ptsData[o + 1] = positions[j + 1];
+ ptsData[o + 2] = positions[j + 2];
+ if (normalsData && normalsBackup && normalsBackup.length >= j + 3) {
+ normalsData[o] = normalsBackup[j];
+ normalsData[o + 1] = normalsBackup[j + 1];
+ normalsData[o + 2] = normalsBackup[j + 2];
+ }
+ }
+ points.modified();
+ polyData.getPointData().modified();
+ polyData.modified();
+ }
+
highlightFace(
actor: vtkActor,
cellId: number,
@@ -381,10 +594,22 @@ export class vtkOrientationControllerWidget {
// Clear any existing highlight first
this.clearHighlight();
- // For main faces (texture-based), we can't easily highlight individual faces
- // since they're all on the same actor. Scaling the actor would affect all 6 main faces.
- // Skip highlighting for main faces on hover to avoid highlighting multiple faces.
if (isMainFace) {
+ const backup = this.scaleMainFaceQuadLocally(actor, cellId, 1.08);
+ if (!backup) {
+ return;
+ }
+ this.highlightedFace = {
+ actor,
+ cellId,
+ originalColor: [0, 0, 0, 0],
+ viewport,
+ isMainFace: true,
+ mainFacePointIds: backup.pointIds,
+ mainFacePositions: backup.positions,
+ mainFaceNormals: backup.normals,
+ };
+ viewport.render();
return;
}
@@ -442,10 +667,13 @@ export class vtkOrientationControllerWidget {
const { actor, cellId, originalColor, viewport, isMainFace } =
this.highlightedFace;
- // For main faces, reset the actor's scale
- if (isMainFace && this.highlightedFace.originalScale) {
- const scale = this.highlightedFace.originalScale;
- actor.setScale(scale[0], scale[1], scale[2]);
+ if (isMainFace) {
+ const ids = this.highlightedFace.mainFacePointIds;
+ const pos = this.highlightedFace.mainFacePositions;
+ const nrm = this.highlightedFace.mainFaceNormals;
+ if (ids && pos && pos.length === ids.length * 3) {
+ this.restoreMainFaceQuadGeometry(actor, ids, pos, nrm);
+ }
viewport.render();
this.highlightedFace = null;
return;
@@ -492,9 +720,20 @@ export class vtkOrientationControllerWidget {
callbacks: MouseHandlersCallbacks
): { cleanup: () => void } {
let isMouseDown = false;
+ let didDrag = false;
+ let pendingPickResult: PickResult | null = null;
+ let mouseDownCanvas: { x: number; y: number } | null = null;
+ const clickTolerancePx = 3;
const hoverHandler = (evt: MouseEvent) => {
if (isMouseDown) {
+ if (mouseDownCanvas) {
+ const dx = evt.clientX - mouseDownCanvas.x;
+ const dy = evt.clientY - mouseDownCanvas.y;
+ if (dx * dx + dy * dy > clickTolerancePx * clickTolerancePx) {
+ didDrag = true;
+ }
+ }
return;
}
@@ -508,16 +747,7 @@ export class vtkOrientationControllerWidget {
if (pickResult) {
const { pickedActor, cellId, actorIndex } = pickResult;
- // Only highlight edge/corner faces on hover (not main faces)
- // Main faces are all on the same actor, so highlighting would affect all of them
- if (actorIndex !== 0) {
- this.highlightFace(
- pickedActor,
- cellId,
- viewport,
- false // isMainFace = false for edge/corner faces
- );
- }
+ this.highlightFace(pickedActor, cellId, viewport, actorIndex === 0);
if (callbacks.onFaceHover) {
callbacks.onFaceHover(pickResult);
}
@@ -547,29 +777,39 @@ export class vtkOrientationControllerWidget {
}
isMouseDown = true;
+ didDrag = false;
+ pendingPickResult = pickResult;
+ mouseDownCanvas = { x: evt.clientX, y: evt.clientY };
+ element.setAttribute(
+ vtkOrientationControllerWidget.ACTIVE_DRAG_ATTR,
+ 'true'
+ );
+ };
- // Determine global cellId
- let globalCellId = pickResult.cellId;
- if (pickResult.actorIndex === 1) {
- // Edge faces: add 6 to convert local cellId to global
- globalCellId = pickResult.cellId + 6;
- } else if (pickResult.actorIndex === 2) {
- // Corner faces: add 18 to convert local cellId to global
- globalCellId = pickResult.cellId + 18;
- }
- // actorIndex === 0 (main faces): cellId stays as is
-
- callbacks.onFacePicked({
- ...pickResult,
- cellId: globalCellId,
- });
+ const mouseUpHandler = (evt: MouseEvent) => {
+ if (isMouseDown && !didDrag && pendingPickResult) {
+ // Determine global cellId for a true click (not drag).
+ let globalCellId = pendingPickResult.cellId;
+ if (pendingPickResult.actorIndex === 1) {
+ globalCellId = pendingPickResult.cellId + 6;
+ } else if (pendingPickResult.actorIndex === 2) {
+ globalCellId = pendingPickResult.cellId + 18;
+ }
- evt.preventDefault();
- evt.stopPropagation();
- };
+ callbacks.onFacePicked({
+ ...pendingPickResult,
+ cellId: globalCellId,
+ });
+ evt.preventDefault();
+ evt.stopImmediatePropagation();
+ evt.stopPropagation();
+ }
- const mouseUpHandler = () => {
isMouseDown = false;
+ didDrag = false;
+ pendingPickResult = null;
+ mouseDownCanvas = null;
+ element.removeAttribute(vtkOrientationControllerWidget.ACTIVE_DRAG_ATTR);
this.clearHighlight();
};
@@ -587,18 +827,19 @@ export class vtkOrientationControllerWidget {
}
};
- element.addEventListener('mousemove', hoverHandler);
- element.addEventListener('mousedown', clickHandler);
+ element.addEventListener('mousemove', hoverHandler, true);
+ element.addEventListener('mousedown', clickHandler, true);
element.addEventListener('mouseup', mouseUpHandler);
element.addEventListener('mouseleave', mouseUpHandler);
element.addEventListener('dblclick', dblclickHandler, true);
const cleanup = () => {
- element.removeEventListener('mousemove', hoverHandler);
- element.removeEventListener('mousedown', clickHandler);
+ element.removeEventListener('mousemove', hoverHandler, true);
+ element.removeEventListener('mousedown', clickHandler, true);
element.removeEventListener('mouseup', mouseUpHandler);
element.removeEventListener('mouseleave', mouseUpHandler);
element.removeEventListener('dblclick', dblclickHandler, true);
+ element.removeAttribute(vtkOrientationControllerWidget.ACTIVE_DRAG_ATTR);
};
this.mouseHandlers.set(viewportId, { cleanup });
@@ -611,10 +852,30 @@ export class vtkOrientationControllerWidget {
}
syncOverlayViewport(
- _viewportId: string,
- _viewport: Types.IVolumeViewport
+ viewportId: string,
+ viewport: Types.IVolumeViewport
): void {
- // No overlay when using main scene only; keep for API compatibility.
+ const overlayRenderer = this.overlayRenderers.get(viewportId);
+ if (!overlayRenderer) {
+ return;
+ }
+
+ const mainRenderer =
+ (viewport as Types.IViewport)
+ .getRenderingEngine()
+ ?.getRenderer(viewportId) ?? viewport.getRenderer();
+ if (!mainRenderer) {
+ return;
+ }
+
+ // Camera is shared via setActiveCamera, so only viewport bounds need syncing.
+ const mainVp = (mainRenderer as vtkRenderer).getViewport() as [
+ number,
+ number,
+ number,
+ number,
+ ];
+ overlayRenderer.setViewport(...mainVp);
}
getOrientationForFace(cellId: number): {
@@ -735,11 +996,34 @@ export class vtkOrientationControllerWidget {
handler.cleanup();
this.mouseHandlers.delete(viewportId);
}
+
+ const overlayRenderer = this.overlayRenderers.get(viewportId);
+ const renderWindow = this.renderWindows.get(viewportId);
+ if (overlayRenderer) {
+ if (renderWindow) {
+ renderWindow.removeRenderer(overlayRenderer);
+ }
+ overlayRenderer.delete();
+ }
+
this.actors.delete(viewportId);
this.pickers.delete(viewportId);
+ this.overlayRenderers.delete(viewportId);
+ this.renderWindows.delete(viewportId);
} else {
this.mouseHandlers.forEach((handler) => handler.cleanup());
this.mouseHandlers.clear();
+
+ this.overlayRenderers.forEach((overlayRenderer, vpId) => {
+ const renderWindow = this.renderWindows.get(vpId);
+ if (renderWindow) {
+ renderWindow.removeRenderer(overlayRenderer);
+ }
+ overlayRenderer.delete();
+ });
+ this.overlayRenderers.clear();
+ this.renderWindows.clear();
+
this.actors.clear();
this.pickers.clear();
}
diff --git a/packages/tools/src/utilities/vtkjs/RhombicuboctahedronSource/index.js b/packages/tools/src/utilities/vtkjs/RhombicuboctahedronSource/index.js
index 6d878e202f..9d2e885c5e 100644
--- a/packages/tools/src/utilities/vtkjs/RhombicuboctahedronSource/index.js
+++ b/packages/tools/src/utilities/vtkjs/RhombicuboctahedronSource/index.js
@@ -36,8 +36,7 @@ function vtkRhombicuboctahedronSource(publicAPI, model) {
}
const phi = 1.4;
- // Smaller main faces (0.75 vs 0.95) make edge and corner faces easier to click
- const faceSize = 0.88;
+ const faceSize = 0.792;
const vertices = [];
vertices.push(-faceSize, -faceSize, -phi);
diff --git a/utils/ExampleRunner/build-all-examples-cli.js b/utils/ExampleRunner/build-all-examples-cli.js
index 80e6e43783..a58ec0335a 100644
--- a/utils/ExampleRunner/build-all-examples-cli.js
+++ b/utils/ExampleRunner/build-all-examples-cli.js
@@ -73,6 +73,10 @@ if (options.fromRoot === true) {
path: 'packages/adapters/examples',
regexp: 'index.ts',
},
+ {
+ path: 'packages/cast/examples',
+ regexp: 'index.ts',
+ },
],
};
} else {
diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json
index df04dcb200..df8abcce2b 100644
--- a/utils/ExampleRunner/example-info.json
+++ b/utils/ExampleRunner/example-info.json
@@ -29,6 +29,9 @@
},
"nifti": {
"description": "Nifti Volume loader"
+ },
+ "cast": {
+ "description": "Cast hub networking (@cornerstonejs/cast)"
}
},
"examplesByCategory": {
@@ -514,6 +517,12 @@
"description": "Demonstrates how to use AI assistance tools for segmentation creation using onnx runtime on the client side"
}
},
+ "cast": {
+ "castClient": {
+ "name": "Cast Client",
+ "description": "Subscribe, receive, and publish Cast hub events using CastClient (same flow as the Slicer Cast HTML reference client)"
+ }
+ },
"tools-advanced": {
"mipJumpToClick": {
"name": "Maximum Intensity Projection (MIP) - Jump to Click",
diff --git a/utils/ExampleRunner/example-runner-cli.js b/utils/ExampleRunner/example-runner-cli.js
index 7e22654c97..653e5342d8 100755
--- a/utils/ExampleRunner/example-runner-cli.js
+++ b/utils/ExampleRunner/example-runner-cli.js
@@ -128,6 +128,10 @@ const configuration = {
path: 'packages/adapters/examples',
regexp: 'index.ts',
},
+ {
+ path: 'packages/cast/examples',
+ regexp: 'index.ts',
+ },
],
};
diff --git a/utils/ExampleRunner/template-config.js b/utils/ExampleRunner/template-config.js
index 17683f6dc2..f37700095a 100644
--- a/utils/ExampleRunner/template-config.js
+++ b/utils/ExampleRunner/template-config.js
@@ -14,6 +14,7 @@ const csDICOMImageLoaderDistPath = path.resolve(
'packages/dicomImageLoader/src/index'
);
const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index');
+const csCastBasePath = path.resolve('packages/cast/src/index');
module.exports = function buildConfig(name, destPath, root, exampleBasePath) {
return `
@@ -94,6 +95,7 @@ module.exports = {
/\\/g,
'/'
)}',
+ '@cornerstonejs/cast': '${csCastBasePath.replace(/\\/g, '/')}',
},
modules,
extensions: ['.ts', '.tsx', '.js', '.jsx'],
diff --git a/utils/ExampleRunner/template-multiexample-config.js b/utils/ExampleRunner/template-multiexample-config.js
index 2c6a09acf1..00c08f0014 100644
--- a/utils/ExampleRunner/template-multiexample-config.js
+++ b/utils/ExampleRunner/template-multiexample-config.js
@@ -14,6 +14,7 @@ const csDICOMImageLoaderDistPath = path.resolve(
'packages/dicomImageLoader/src/index'
);
const csNiftiPath = path.resolve('packages/nifti-volume-loader/src/index');
+const csCastBasePath = path.resolve('./packages/cast/src/index');
module.exports = function buildConfig(names, exampleBasePaths, destPath, root) {
let multiExampleEntryPoints = '';
@@ -129,6 +130,7 @@ module.exports = {
/\\/g,
'/'
)}',
+ '@cornerstonejs/cast': '${csCastBasePath.replace(/\\/g, '/')}',
},
modules,
extensions: ['.ts', '.tsx', '.js', '.jsx'],
From 69c4b2f13aa6be80f3acfedce56b6e1a3b7d0824 Mon Sep 17 00:00:00 2001
From: "Martin Bellehumeur, M. Eng."
<23396581+mbellehumeur@users.noreply.github.com>
Date: Sun, 29 Mar 2026 17:54:34 +0200
Subject: [PATCH 02/20] refactor(castClient): Standardize naming and improve
button event handling
- Updated the title and description in the setTitleAndDescription function for consistency.
- Changed the casing of "Cast Client" to "Cast client" for uniformity.
- Replaced direct DOM element retrieval with stored references for button elements to enhance readability and maintainability.
- Removed unused 'agfa' hub configuration and related code.
- Improved the refreshDerivedUrls function comment for clarity.
---
packages/cast/README.md | 4 +-
packages/cast/examples/castClient/index.ts | 76 ++++++++++------------
packages/cast/src/CastClient.ts | 27 ++++----
packages/cast/src/generateMessageId.ts | 2 +-
packages/cast/src/types.ts | 4 +-
5 files changed, 55 insertions(+), 58 deletions(-)
diff --git a/packages/cast/README.md b/packages/cast/README.md
index f53519ccdd..00d042af8c 100644
--- a/packages/cast/README.md
+++ b/packages/cast/README.md
@@ -1,6 +1,6 @@
# @cornerstonejs/cast
-Cast hub networking for browser-based viewers: OAuth client-credentials token, HTTP subscribe/unsubscribe, WebSocket receive, and JSON publish. This package contains transport only; application-specific message handling (e.g. OHIF `CastMessageHandler`) stays in the host app to implent FHIRcast or other messaging.
+Cast hub networking for browser-based viewers: OAuth client-credentials token, HTTP subscribe/unsubscribe, WebSocket receive, and JSON publish. This package contains transport only; application-specific message handling (e.g. OHIF `CastMessageHandler`) stays in the host app to implement FHIRcast or other messaging.
## Example
@@ -14,7 +14,7 @@ yarn workspace @cornerstonejs/cast build:esm
yarn example castClient
```
-Then open the dev server URL (default port from `CS3D_PORT` or `3000`). Set **Token endpoint** and **Hub endpoint** to your Cast hub (defaults target `127.0.0.1:2016`). If the hub runs on another origin, ensure CORS allows the example origin.
+Then open the dev server URL (default port from `CS3D_PORT` or `3000`). Set **Token endpoint** and **Hub endpoint** to your Cast hub (defaults target `127.0.0.1:2017`). If the hub runs on another origin, ensure CORS allows the example origin.
Optional query parameter: `?topic=your-topic` pre-fills the topic fields.
diff --git a/packages/cast/examples/castClient/index.ts b/packages/cast/examples/castClient/index.ts
index efd9cd5ace..f1ba9ec89b 100644
--- a/packages/cast/examples/castClient/index.ts
+++ b/packages/cast/examples/castClient/index.ts
@@ -3,8 +3,8 @@ import type { HubConfig } from '@cornerstonejs/cast';
import { setTitleAndDescription } from '../../../../utils/demo/helpers';
setTitleAndDescription(
- 'Cast Client',
- 'Demontrate connecting, messaging and conferencing with the 3D Slicer hub.'
+ 'Cast client API',
+ 'Demonstrate connecting, messaging and conferencing with the 3D Slicer hub.'
);
/** Default actor keyword for subscribe list and publish preset. */
@@ -70,14 +70,13 @@ root.innerHTML = `