diff --git a/doc/changelog.d/187.added.md b/doc/changelog.d/187.added.md
new file mode 100644
index 00000000..c0c77f86
--- /dev/null
+++ b/doc/changelog.d/187.added.md
@@ -0,0 +1 @@
+Remote rendering 4.3b: Display the panels in server mode
diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx
index f5c72934..faa42632 100644
--- a/src/ansys/visor/visor-client/src/App.tsx
+++ b/src/ansys/visor/visor-client/src/App.tsx
@@ -29,7 +29,6 @@ function App() {
const server = useServerVtk(probe.mode === 'server' ? probe.trame : null, visorArgs);
const active = probe.mode === 'server' ? server : client;
const { visorFrontend: visorState, schemaMismatchError } = active;
- const { serverCanvas } = server;
useEffect(() => {
(async () => {
@@ -59,15 +58,6 @@ function App() {
`${schemaMismatchError.payloadVersion}, client expects version ` +
`${schemaMismatchError.expectedVersion}.`}
- ) : serverCanvas != null ? (
- // Server mode has no frontend to host the canvas, so its
- // element is mounted here directly.
-
{
- container?.replaceChildren(serverCanvas.element);
- }}
- />
) : (
visorState != null && (
;
/**
- * The part of a renderer this module uses. Narrower than `IRenderer` so the
+ * The part of a renderer this module uses. Narrower than `WasmRenderer` so the
* unit test can supply exactly these two members without a cast that would
- * defeat the type check it is here to get.
+ * defeat the type check it is here to get. Only the client-rendering renderer
+ * reports settled camera windows, so the source is typed from it.
*/
-export type CameraSyncSource = Pick;
+export type CameraSyncSource = Pick<
+ WasmRenderer,
+ 'addCameraSettledListener' | 'getCameraStateAsync'
+>;
/**
* Build the trigger payload from a settled camera snapshot.
diff --git a/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts b/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts
index 9532da27..dccfe836 100644
--- a/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts
+++ b/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts
@@ -1,41 +1,87 @@
import { useEffect, useState } from 'react';
import type { Trame } from '@kitware/trame/src/trame.js';
import type { VisorFrontend } from '../VisorFrontend.tsx';
-import type { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx';
+import { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx';
import { RCA_AREA_NAME, ServerCanvas, type RcaSession } from '../rca/ServerCanvas';
+import { ServerRenderer, type ServerStateSource } from '../renderer/ServerRenderer';
+import type { TrameTriggerSender } from '../renderer/IRenderer';
+import { buildFrontendAsync } from '../buildFrontendAsync';
import type { VisorArgs } from './useVisorArgs';
/** What the server-mode hook gives App. */
export type ServerVtk = Readonly<{
visorFrontend: VisorFrontend | null;
schemaMismatchError: SchemaVersionMismatchError | null;
- serverCanvas: ServerCanvas | null;
}>;
-/** Over the probe's session: stream frames and send the page theme; idle when trame is null. */
+/** Over the probe's session: send the page theme, stream frames and build the frontend; idle when trame is null. */
export function useServerVtk(trame: Trame | null, visorArgs: VisorArgs): ServerVtk {
- const [serverCanvas, setServerCanvas] = useState(null);
+ const [visorFrontend, setVisorFrontend] = useState(null);
+ const [schemaMismatchError, setSchemaMismatchError] =
+ useState(null);
useEffect(() => {
if (trame == null) {
return;
}
+ // Under StrictMode the effect runs twice in development: each run
+ // disposes only the canvas and renderer it created, and a cancelled
+ // run drops its frontend.
+ let cancelled = false;
+ let renderer: ServerRenderer | null = null;
+ const disposeRenderer = () => {
+ renderer?.dispose();
+ renderer = null;
+ };
// Subscribed before anything can make the server render, so the frame
// that render produces reaches the canvas.
const canvas = new ServerCanvas(sessionOf(trame), RCA_AREA_NAME);
- setServerCanvas(canvas);
+ const sender: TrameTriggerSender = (triggerName, payload) =>
+ trame.trigger(triggerName, [payload]);
(async () => {
if (visorArgs.darkMode != null) {
await sendDarkThemeAsync(trame, visorArgs.darkMode);
}
+ // No arguments: the trigger takes none, and a sender would pass one.
+ const sceneDetailsJson = await trame.trigger('get_visor_scene_details_json');
+ let newFrontend: VisorFrontend;
+ try {
+ newFrontend = await buildFrontendAsync(
+ sceneDetailsJson,
+ async () => {
+ renderer = new ServerRenderer(canvas, stateOf(trame), sender);
+ return renderer;
+ },
+ sender,
+ visorArgs.darkMode
+ );
+ } catch (err) {
+ if (err instanceof SchemaVersionMismatchError) {
+ if (!cancelled) {
+ setSchemaMismatchError(err);
+ }
+ return;
+ }
+ throw err;
+ }
+ if (cancelled) {
+ // Built after cleanup ran, so cleanup could not dispose it.
+ disposeRenderer();
+ return;
+ }
+ setSchemaMismatchError(null);
+ setVisorFrontend(newFrontend);
})();
return () => {
+ cancelled = true;
+ disposeRenderer();
canvas.dispose();
- setServerCanvas(null);
+ setVisorFrontend(null);
+ setSchemaMismatchError(null);
};
}, [trame]);
- return { visorFrontend: null, schemaMismatchError: null, serverCanvas };
+ return { visorFrontend, schemaMismatchError };
}
/** The wslink session of a connected trame client. */
@@ -47,6 +93,15 @@ function sessionOf(trame: Trame): RcaSession {
return session;
}
+/** The state of a connected trame client. */
+function stateOf(trame: Trame): ServerStateSource {
+ const state = trame.state;
+ if (state == null) {
+ throw new Error(`trame has no state`);
+ }
+ return state;
+}
+
/** Tell the server the theme the page shows, so its rendered background matches; a failed send is logged. */
async function sendDarkThemeAsync(trame: Trame, dark: boolean): Promise {
try {
diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx
index d98202df..b49fc400 100644
--- a/src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx
+++ b/src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx
@@ -16,7 +16,9 @@ const mockTrame = {
getSession: () => ({ subscribe: mockSubscribe, call: async () => undefined }),
}),
},
- trigger: async () => undefined,
+ // The scene-details fetch never answers, so the server frontend is never
+ // built: this test is about which scene loads, not what the server sends.
+ trigger: () => new Promise(() => {}),
};
jest.mock('../wasm/RemoteVtkScene', () => ({
diff --git a/src/ansys/visor/visor-client/src/jest-tests/ServerRendererCache.test.ts b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererCache.test.ts
new file mode 100644
index 00000000..daace525
--- /dev/null
+++ b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererCache.test.ts
@@ -0,0 +1,121 @@
+import { ServerRenderer, type ServerStateSource } from '../renderer/ServerRenderer';
+import type { IServerCanvas } from '../rca/ServerCanvas';
+
+/**
+ * The server-rendering renderer keeps a cache the server feeds: the camera
+ * from the camera state key, the widget flags from its own writes. A
+ * no-argument toggle sends the value it settled on; the camera key is the
+ * echo of the client's own camera writes, so receiving it sends nothing.
+ *
+ * Expected values are hand-written literals.
+ */
+
+/** A camera record as the server publishes it, at the given position and focal point. */
+function cameraRecord(position: number[], focalPoint: number[]) {
+ return {
+ position,
+ focalPoint,
+ viewUp: [0, 1, 0],
+ clippingRange: [0.1, 100],
+ parallelProjection: false,
+ viewAngle: 30,
+ parallelScale: 1,
+ };
+}
+
+/** A trame state holding one camera key value whose watchers can be told of a new one. */
+class FakeState {
+ constructor(value: unknown) {
+ this.#value = value;
+ }
+
+ watch(keys: string[], fn: (...values: unknown[]) => void): () => void {
+ this.#watchers.push(fn);
+ fn(this.#value);
+ return () => {};
+ }
+
+ /** Write the camera key and notify its watchers, as a server push does. */
+ push(value: unknown): void {
+ this.#value = value;
+ for (const fn of this.#watchers) {
+ fn(value);
+ }
+ }
+
+ #value: unknown;
+ readonly #watchers: ((...values: unknown[]) => void)[] = [];
+}
+
+function stubCanvas(): IServerCanvas {
+ return {
+ element: document.createElement('div'),
+ frameSize: null,
+ addFrameListener: () => () => {},
+ addClickListener: () => () => {},
+ };
+}
+
+function makeRenderer(initial: unknown) {
+ const state = new FakeState(initial);
+ const sender = jest.fn(async (_name: string, _payload: unknown) => undefined);
+ const renderer = new ServerRenderer(stubCanvas(), state as ServerStateSource, sender);
+ return { renderer, state, sender };
+}
+
+describe('ServerRenderer toggles', () => {
+ test('a no-argument bounding-box toggle sends the value it settled on', async () => {
+ const { renderer, sender } = makeRenderer(undefined);
+
+ await renderer.setBoundingBoxVisibilityAsync();
+
+ expect(sender.mock.calls).toStrictEqual([
+ ['set_bounding_box_visibility', { visible: true }],
+ ]);
+ });
+
+ test('a no-argument bounding-box toggle flips the cached flag', async () => {
+ const { renderer } = makeRenderer(undefined);
+
+ await renderer.setBoundingBoxVisibilityAsync();
+
+ expect(renderer.isBoundingBoxVisible()).toBe(true);
+ });
+});
+
+describe('ServerRenderer camera cache', () => {
+ test('a camera record replaces the cached camera', async () => {
+ const { renderer, state } = makeRenderer(cameraRecord([0, 0, 9], [0, 0, 0]));
+
+ state.push(cameraRecord([1, 2, 3], [4, 6, 3]));
+
+ const camera = await renderer.getCameraStateAsync();
+ expect(camera.position).toEqual([1, 2, 3]);
+ expect(camera.distance).toBe(5);
+ });
+
+ test('a camera record sends nothing', async () => {
+ const { state, sender } = makeRenderer(cameraRecord([0, 0, 9], [0, 0, 0]));
+
+ state.push(cameraRecord([1, 2, 3], [4, 6, 3]));
+
+ expect(sender).not.toHaveBeenCalled();
+ });
+
+ test('None after a record leaves the cached camera', async () => {
+ const { renderer, state } = makeRenderer(cameraRecord([1, 2, 3], [4, 6, 3]));
+
+ state.push(null);
+
+ const camera = await renderer.getCameraStateAsync();
+ expect(camera.position).toEqual([1, 2, 3]);
+ });
+
+ test('the camera read rejects before any record arrives', async () => {
+ const { renderer } = makeRenderer(undefined);
+
+ await expect(renderer.getCameraStateAsync()).rejects.toMatchObject({
+ name: 'CameraStateUnavailableError',
+ });
+ });
+});
diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkBuild.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkBuild.test.tsx
new file mode 100644
index 00000000..e7b4d877
--- /dev/null
+++ b/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkBuild.test.tsx
@@ -0,0 +1,84 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import type { Trame } from '@kitware/trame/src/trame.js';
+import { useServerVtk } from '../hooks/useServerVtk';
+import type { VisorArgs } from '../hooks/useVisorArgs';
+
+/**
+ * The server-mode frontend sends each mutation through the probe's session
+ * as one positional payload, the shape every server trigger takes.
+ *
+ * Expected values are hand-written literals.
+ */
+
+/** A minimal v4 scene-details payload as a server-rendering server delivers it: no renderer annotation. */
+function sceneDetailsPayload() {
+ return {
+ schemaVersion: 4,
+ ui: {},
+ scene: { unit: '', orthographicEnabled: null },
+ vtkInfo: {
+ sceneGraph: {
+ id: 0,
+ dataArrays: [],
+ name: '',
+ isGroupNode: true,
+ isActorNode: false,
+ nodeType: 'root',
+ diffuseColor: [1, 1, 1],
+ bounds: [],
+ children: [],
+ },
+ rendererAnnotation: null,
+ },
+ };
+}
+
+const trigger = jest.fn(async (name: string, ..._args: unknown[]) =>
+ name === 'get_visor_scene_details_json' ? sceneDetailsPayload() : undefined
+);
+const session = {
+ subscribe: () => ({ unsubscribe: () => {} }),
+ call: async () => undefined,
+};
+const trame = {
+ client: { getConnection: () => ({ getSession: () => session }) },
+ state: { watch: () => () => {} },
+ trigger,
+} as unknown as Trame;
+
+const args = {
+ host: 'localhost',
+ port: 8080,
+ aspectRatio: 1,
+ pixelDensity: 1000,
+ basePath: '',
+} as VisorArgs;
+
+const globals = globalThis as unknown as { ResizeObserver: unknown };
+let originalResizeObserver: unknown;
+
+beforeEach(() => {
+ trigger.mockClear();
+ originalResizeObserver = globals.ResizeObserver;
+ globals.ResizeObserver = class {
+ observe() {}
+ disconnect() {}
+ };
+});
+
+afterEach(() => {
+ globals.ResizeObserver = originalResizeObserver;
+});
+
+describe('useServerVtk build', () => {
+ test('a toolbar toggle reaches the server as one positional payload', async () => {
+ const { result } = renderHook(() => useServerVtk(trame, args));
+ await waitFor(() => expect(result.current.visorFrontend).not.toBeNull());
+
+ await result.current.visorFrontend!.setBoundingBoxVisibilityAsync(true);
+
+ expect(
+ trigger.mock.calls.filter(([name]) => name === 'set_bounding_box_visibility')
+ ).toStrictEqual([['set_bounding_box_visibility', [{ visible: true }]]]);
+ });
+});
diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkTheme.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkTheme.test.tsx
index f5b33063..1e5474b7 100644
--- a/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkTheme.test.tsx
+++ b/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkTheme.test.tsx
@@ -6,14 +6,17 @@ import type { VisorArgs } from '../hooks/useVisorArgs';
/**
* In server mode the page's theme reaches the server once, after the canvas
* has subscribed to the frame stream, so the frame the theme change renders
- * is not lost. With no theme from the page, nothing is sent.
+ * is not lost, and before the scene details are fetched, so they carry it.
+ * With no theme from the page, nothing is sent. The fetch is answered with
+ * an outdated schema, which ends the build before any renderer exists.
*
* Expected values are hand-written literals.
*/
const events: string[] = [];
-const trigger = jest.fn(async (name: string) => {
+const trigger = jest.fn(async (name: string, ..._args: unknown[]) => {
events.push(name);
+ return name === 'get_visor_scene_details_json' ? { schemaVersion: 3 } : undefined;
});
const session = {
subscribe: (topic: string) => {
@@ -58,17 +61,25 @@ afterEach(() => {
describe('useServerVtk theme', () => {
test('a darkMode prop is sent once, after the canvas subscribes', async () => {
renderHook(() => useServerVtk(trame, argsWith(true)));
- await waitFor(() => expect(trigger).toHaveBeenCalled());
+ await waitFor(() => expect(events).toContain('get_visor_scene_details_json'));
- expect(trigger).toHaveBeenCalledTimes(1);
- expect(trigger).toHaveBeenCalledWith('set_dark_theme', [{ dark: true }]);
- expect(events).toEqual(['subscribe trame.rca.topic.stream', 'set_dark_theme']);
+ expect(trigger.mock.calls.filter(([name]) => name === 'set_dark_theme')).toStrictEqual([
+ ['set_dark_theme', [{ dark: true }]],
+ ]);
+ expect(events).toEqual([
+ 'subscribe trame.rca.topic.stream',
+ 'set_dark_theme',
+ 'get_visor_scene_details_json',
+ ]);
+ expect(
+ trigger.mock.calls.filter(([name]) => name === 'get_visor_scene_details_json')
+ ).toStrictEqual([['get_visor_scene_details_json']]);
});
test('no darkMode prop sends no set_dark_theme', async () => {
- const { result } = renderHook(() => useServerVtk(trame, argsWith(undefined)));
- await waitFor(() => expect(result.current.serverCanvas).not.toBeNull());
+ renderHook(() => useServerVtk(trame, argsWith(undefined)));
+ await waitFor(() => expect(events).toContain('get_visor_scene_details_json'));
- expect(trigger).not.toHaveBeenCalled();
+ expect(events).not.toContain('set_dark_theme');
});
});
diff --git a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts
index e810acec..66ee6ce3 100644
--- a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts
+++ b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts
@@ -4,7 +4,7 @@ export type NodeId = number;
/**
* Renderer-agnostic camera snapshot. Superset of what UI panels read and what
* getAppStateAsync/setAppStateAsync round-trip. Every field is a plain value
- * so a RemoteRenderer can cache and serve it without querying a wasm object.
+ * so a ServerRenderer can cache and serve it without querying a wasm object.
*/
export type VisorCameraState = Readonly<{
// Derived display properties (UI panels, camera-changed listeners)
@@ -77,7 +77,7 @@ export interface IRenderer {
// ---- Canvas / lifecycle -------------------------------------------------
/** DOM element hosting the render output (canvas + overlay). */
readonly domElement: HTMLDivElement;
- /** Kick a frame flush. Wasm: wasm.update() + Render(). Remote: no-op / RPC. */
+ /** Kick a frame flush. Wasm: wasm.update() + Render(). Server: no-op. */
renderAsync(): Promise;
/** Resize output to its container. Call after container size changes. */
resizeAsync(): Promise;
@@ -97,14 +97,6 @@ export interface IRenderer {
// ---- Subscriptions (return unsubscribe closures) ------------------------
/** Fires whenever the camera changes. Callback receives a full snapshot. */
addCameraChangedListener(callback: (state: VisorCameraState) => void): () => void;
- /**
- * Fires once per gesture, after the last camera change has stopped changing for
- * CAMERA_SETTLE_MS, with the origin of that change. The callback receives
- * the origin only; read the camera with getCameraStateAsync if needed.
- * A `gesture` report is the user's own camera; a `programmatic` one is a
- * camera the application or the server applied.
- */
- addCameraSettledListener(callback: (origin: CameraOrigin) => void): () => void;
/** Fires each frame with the current FPS. */
addFrameRenderedListener(callback: (fps: number) => void): () => void;
/**
diff --git a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts
index 87de2be6..e82b56ea 100644
--- a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts
+++ b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts
@@ -1,5 +1,4 @@
import {
- CameraOrigin,
ColorVariableDescriptor,
GeometryPickMode,
IRenderer,
@@ -79,12 +78,6 @@ export class NullRenderer implements IRenderer {
};
}
- addCameraSettledListener(_callback: (origin: CameraOrigin) => void): () => void {
- return () => {
- // no-op unsubscribe
- };
- }
-
addFrameRenderedListener(_callback: (fps: number) => void): () => void {
return () => {
// no-op unsubscribe
diff --git a/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts b/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts
new file mode 100644
index 00000000..95302e9b
--- /dev/null
+++ b/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts
@@ -0,0 +1,155 @@
+import type { ColorVariableDescriptor, NodeId, TrameTriggerSender } from './IRenderer';
+
+/** The VISOR scene-mutation triggers: each method builds one payload and sends it; a failed send is logged, never thrown; a null sender sends nothing. */
+export class SceneTriggerSends {
+ constructor(triggerSender: TrameTriggerSender | null) {
+ this.#triggerSender = triggerSender;
+ }
+
+ readonly #triggerSender: TrameTriggerSender | null;
+
+ /**
+ * Send one per-part payload to its server trigger.
+ *
+ * No sender injected -> no-op. This is the state every non-browser context
+ * is in, and it is deliberate rather than defensive: the renderer is
+ * constructible without a transport.
+ *
+ * A rejection is logged and swallowed, never rethrown. These sends run
+ * inside UI handlers that behaved a certain way before this call existed,
+ * and both stacks currently apply the same mutation independently, so a
+ * transport failure must not change what the user sees or break an
+ * interaction. The catch is deliberately unnarrowed: any rejection, of any
+ * shape, is a failed send. The log line below is therefore the *only*
+ * signal that a send failed -- the render will look correct either way --
+ * so its prefix is fixed and greppable, and it names both the trigger and
+ * the node so a failure can be attributed without a debugger.
+ */
+ async #sendTriggerAsync(
+ triggerName: string,
+ nodeId: NodeId,
+ payload: Record
+ ): Promise {
+ if (this.#triggerSender == null) {
+ return;
+ }
+ try {
+ await this.#triggerSender(triggerName, payload);
+ } catch (err) {
+ console.error(
+ `[VISOR] per-part trigger send failed: trigger='${triggerName}' nodeId=${nodeId}`,
+ err
+ );
+ }
+ }
+
+ /**
+ * Send one view-level widget payload to its server trigger.
+ *
+ * Sibling of `#sendTriggerAsync` above; same no-sender/no-op and
+ * logged-and-swallowed-rejection rationale, not repeated here.
+ *
+ * Separate only because a widget trigger has no `nodeId` to name in the
+ * error line, so the log prefix here is distinct and greppable on its own.
+ */
+ async #sendWidgetTriggerAsync(
+ triggerName: string,
+ payload: Record
+ ): Promise {
+ if (this.#triggerSender == null) {
+ return;
+ }
+ try {
+ await this.#triggerSender(triggerName, payload);
+ } catch (err) {
+ console.error(`[VISOR] widget trigger send failed: trigger='${triggerName}'`, err);
+ }
+ }
+
+ // ---- Per-part ---------------------------------------------------------------
+ async sendPartVisibilityAsync(nodeId: NodeId, visible: boolean): Promise {
+ await this.#sendTriggerAsync('set_part_visibility', nodeId, {
+ nodeId,
+ visible,
+ });
+ }
+
+ async sendPartOpacityAsync(nodeId: NodeId, opacity: number): Promise {
+ await this.#sendTriggerAsync('set_part_opacity', nodeId, {
+ nodeId,
+ opacity,
+ });
+ }
+
+ async sendPartDiffuseColorAsync(
+ nodeId: NodeId,
+ diffuseRgb: readonly number[] | null
+ ): Promise {
+ // The key is always present: `diffuseRgb: null` is how a reset is
+ // expressed. Omitting it is a different message, and not a valid one.
+ await this.#sendTriggerAsync('set_part_diffuse_color', nodeId, {
+ nodeId,
+ diffuseRgb,
+ });
+ }
+
+ async sendPartSelectedAsync(nodeId: NodeId, selected: boolean): Promise {
+ // No colour crosses this trigger by design; the server reads the
+ // part's stored colour from its own record.
+ await this.#sendTriggerAsync('set_part_selected', nodeId, {
+ nodeId,
+ selected,
+ });
+ }
+
+ async sendPartColorVariableAsync(
+ nodeId: NodeId,
+ descriptor: ColorVariableDescriptor
+ ): Promise {
+ // `variableId` is the client-built opaque token, forwarded verbatim;
+ // nothing on either side parses it. The association travels as its own
+ // typed 'POINT'|'CELL' field, and the array name as its own field,
+ // precisely so that no one has to. The range is not sent: the server
+ // applies its own record's range for the referenced slot.
+ await this.#sendTriggerAsync('set_part_color_variable', nodeId, {
+ nodeId,
+ variableId: descriptor.variableId,
+ association: descriptor.variableType,
+ arrayName: descriptor.variableName,
+ component: descriptor.component,
+ });
+ }
+
+ async sendClearPartColorVariableAsync(nodeId: NodeId): Promise {
+ await this.#sendTriggerAsync('clear_part_color_variable', nodeId, {
+ nodeId,
+ });
+ }
+
+ // ---- View-level widgets -------------------------------------------------
+ // Every payload is absolute, never a toggle or delta, so a send that is
+ // suppressed, duplicated, or reordered is harmless.
+ async sendCrossSectionVisibilityAsync(visible: boolean): Promise {
+ await this.#sendWidgetTriggerAsync('set_cross_section_visibility', { visible });
+ }
+
+ async sendBoundingBoxVisibilityAsync(visible: boolean): Promise {
+ await this.#sendWidgetTriggerAsync('set_bounding_box_visibility', { visible });
+ }
+
+ async sendProjectionAsync(parallel: boolean): Promise {
+ await this.#sendWidgetTriggerAsync('set_projection', { parallel });
+ }
+
+ async sendEdgesVisibleAsync(visible: boolean): Promise {
+ await this.#sendWidgetTriggerAsync('set_edges_visible', { visible });
+ }
+
+ /** The keys are `origin` and `normal`, three floats each, as the server's payload model requires. */
+ async sendCrossSectionPlaneAsync(
+ origin: readonly number[],
+ normal: readonly number[]
+ ): Promise {
+ await this.#sendWidgetTriggerAsync('sync_cross_section_plane', { origin, normal });
+ }
+}
diff --git a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts
new file mode 100644
index 00000000..aeaecd2b
--- /dev/null
+++ b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts
@@ -0,0 +1,495 @@
+import type {
+ ColorVariableDescriptor,
+ GeometryPickMode,
+ IRenderer,
+ NodeId,
+ PickGeometryResult,
+ TrameTriggerSender,
+ VisorCameraState,
+} from './IRenderer';
+import type { IServerCanvas } from '../rca/ServerCanvas';
+import { SceneTriggerSends } from './SceneTriggerSends';
+
+/** The trame state key the server writes the camera record to. */
+export const SERVER_CAMERA_STATE_KEY = 'server_camera_state';
+
+/** The part of trame's state the renderer reads. */
+export type ServerStateSource = Readonly<{
+ watch(keys: string[], fn: (...values: unknown[]) => void): () => void;
+}>;
+
+/** Raised by getCameraStateAsync while any of the seven camera fields has never been delivered. */
+export class CameraStateUnavailableError extends Error {
+ constructor() {
+ super(`The server has not delivered the camera yet`);
+ this.name = `CameraStateUnavailableError`;
+ }
+}
+
+/** Raised by the cross-section plane reads while the plane has never been delivered. */
+export class CrossSectionStateUnavailableError extends Error {
+ constructor() {
+ super(`The server has not delivered the cross-section plane yet`);
+ this.name = `CrossSectionStateUnavailableError`;
+ }
+}
+
+/** Raised by the geometry-pick members: geometry picking is not available when the server renders. */
+export class GeometryPickingUnavailableError extends Error {
+ constructor() {
+ super(`Geometry picking is not available when the server renders`);
+ this.name = `GeometryPickingUnavailableError`;
+ }
+}
+
+/** The seven camera fields the server publishes. */
+type CameraRecord = Readonly<{
+ position: readonly number[];
+ focalPoint: readonly number[];
+ viewUp: readonly number[];
+ clippingRange: readonly number[];
+ parallelProjection: boolean;
+ viewAngle: number;
+ parallelScale: number;
+}>;
+
+/** The seven camera fields the server publishes, cached as they arrive. */
+class CameraRecordCache {
+ /** Read a server_camera_state value: the record, or null for None/absent; throws TypeError if malformed. */
+ static parse(value: unknown): CameraRecord | null {
+ if (value == null) {
+ return null;
+ }
+ if (typeof value !== 'object') {
+ throw new TypeError(`camera record is not an object`);
+ }
+ const obj = value as Record;
+ return {
+ position: CameraRecordCache.#vector(obj.position, 3, 'position'),
+ focalPoint: CameraRecordCache.#vector(obj.focalPoint, 3, 'focalPoint'),
+ viewUp: CameraRecordCache.#vector(obj.viewUp, 3, 'viewUp'),
+ clippingRange: CameraRecordCache.#vector(obj.clippingRange, 2, 'clippingRange'),
+ parallelProjection: CameraRecordCache.#boolean(
+ obj.parallelProjection,
+ 'parallelProjection'
+ ),
+ viewAngle: CameraRecordCache.#number(obj.viewAngle, 'viewAngle'),
+ parallelScale: CameraRecordCache.#number(obj.parallelScale, 'parallelScale'),
+ };
+ }
+
+ static #vector(value: unknown, length: number, name: string): readonly number[] {
+ if (
+ !Array.isArray(value) ||
+ value.length !== length ||
+ !value.every((v) => typeof v === 'number')
+ ) {
+ throw new TypeError(`camera record ${name} is not ${length} numbers`);
+ }
+ return [...value];
+ }
+
+ static #boolean(value: unknown, name: string): boolean {
+ if (typeof value !== 'boolean') {
+ throw new TypeError(`camera record ${name} is not a boolean`);
+ }
+ return value;
+ }
+
+ static #number(value: unknown, name: string): number {
+ if (typeof value !== 'number') {
+ throw new TypeError(`camera record ${name} is not a number`);
+ }
+ return value;
+ }
+
+ /** Overwrite all seven fields. */
+ setRecord(record: CameraRecord): void {
+ this.#fields = { ...record };
+ }
+
+ /** Overwrite one field. */
+ set(field: K, value: CameraRecord[K]): void {
+ this.#fields = { ...this.#fields, [field]: value };
+ }
+
+ /** One field, or undefined if never written. */
+ field(field: K): CameraRecord[K] | undefined {
+ return this.#fields[field];
+ }
+
+ /** All seven fields, or null while any is unknown. */
+ record(): CameraRecord | null {
+ const f = this.#fields;
+ if (
+ f.position === undefined ||
+ f.focalPoint === undefined ||
+ f.viewUp === undefined ||
+ f.clippingRange === undefined ||
+ f.parallelProjection === undefined ||
+ f.viewAngle === undefined ||
+ f.parallelScale === undefined
+ ) {
+ return null;
+ }
+ return f as CameraRecord;
+ }
+
+ #fields: Partial = {};
+}
+
+/** IRenderer for server rendering: frames come from the canvas, writes go to the server, reads come from a cache. */
+export class ServerRenderer implements IRenderer {
+ /** Watch server_camera_state on state, show canvas, and send every mutation through triggerSender. */
+ constructor(
+ canvas: IServerCanvas,
+ state: ServerStateSource,
+ triggerSender: TrameTriggerSender
+ ) {
+ this.#canvas = canvas;
+ this.#sends = new SceneTriggerSends(triggerSender);
+ this.domElement = canvas.element;
+ // `watch` also calls back at once with the key's current value.
+ this.#unwatch = state.watch([SERVER_CAMERA_STATE_KEY], this.#onCameraState);
+ }
+
+ readonly #canvas: IServerCanvas;
+ readonly #sends: SceneTriggerSends;
+ readonly #unwatch: () => void;
+ readonly #camera = new CameraRecordCache();
+ readonly #cameraChangedListeners = new Map<() => void, (state: VisorCameraState) => void>();
+ readonly #viewerClickedListeners = new Map<
+ () => void,
+ (
+ nodeId: NodeId | null,
+ ctrlKey: boolean,
+ shiftKey: boolean,
+ normX: number,
+ normY: number
+ ) => void
+ >();
+ #crossSectionVisible = false;
+ #boundingBoxVisible = false;
+ #edgesVisible = false;
+ #crossSectionOrigin: readonly number[] | null = null;
+ #crossSectionNormal: readonly number[] | null = null;
+
+ /**
+ * Take the server's camera record into the cache. Nothing is sent from
+ * here: the record is also the echo of this client's own camera writes,
+ * and answering it would loop.
+ */
+ #onCameraState = (value: unknown): void => {
+ let record: CameraRecord | null;
+ try {
+ record = CameraRecordCache.parse(value);
+ } catch (err) {
+ console.error('[VISOR] server_camera_state malformed', err);
+ return;
+ }
+ if (record == null) {
+ return;
+ }
+ this.#camera.setRecord(record);
+ this.#fireCameraChanged();
+ };
+
+ /** Write one camera field and tell the camera listeners. */
+ #writeCamera(field: K, value: CameraRecord[K]): void {
+ this.#camera.set(field, value);
+ this.#fireCameraChanged();
+ }
+
+ /** Give every camera listener the full snapshot, once all seven fields are known. */
+ #fireCameraChanged(): void {
+ const record = this.#camera.record();
+ if (record == null) {
+ return;
+ }
+ const snapshot = ServerRenderer.#snapshot(record, this.#viewPortHeight());
+ for (const callback of this.#cameraChangedListeners.values()) {
+ callback(snapshot);
+ }
+ }
+
+ /** The view's height in device pixels. */
+ #viewPortHeight(): number {
+ return this.domElement.clientHeight * (window.devicePixelRatio ?? 1);
+ }
+
+ /** The full snapshot: record plus distance, orthographic, orthographicScale, unitsPerPixel, viewPortHeight. */
+ static #snapshot(record: CameraRecord, viewPortHeight: number): VisorCameraState {
+ const [px, py, pz] = record.position;
+ const [fx, fy, fz] = record.focalPoint;
+ return {
+ distance: Math.hypot(px - fx, py - fy, pz - fz),
+ orthographic: record.parallelProjection,
+ orthographicScale: record.parallelScale,
+ // unitsPerPixel is only accurate in orthographic mode
+ unitsPerPixel: record.parallelScale / (0.5 * viewPortHeight),
+ viewPortHeight,
+ position: record.position,
+ focalPoint: record.focalPoint,
+ viewUp: record.viewUp,
+ clippingRange: record.clippingRange,
+ parallelProjection: record.parallelProjection,
+ viewAngle: record.viewAngle,
+ parallelScale: record.parallelScale,
+ };
+ }
+
+ // ---- Canvas / lifecycle -------------------------------------------------
+ readonly domElement: HTMLDivElement;
+
+ /** The server renders after every write it receives. */
+ async renderAsync(): Promise {}
+
+ /** The canvas reports its own size to the server. */
+ async resizeAsync(): Promise {}
+
+ dispose(): void {
+ this.#unwatch();
+ this.#cameraChangedListeners.clear();
+ this.#viewerClickedListeners.clear();
+ }
+
+ // ---- Camera ---------------------------------------------------------------
+ async getCameraStateAsync(): Promise {
+ const record = this.#camera.record();
+ if (record == null) {
+ throw new CameraStateUnavailableError();
+ }
+ return ServerRenderer.#snapshot(record, this.#viewPortHeight());
+ }
+
+ // The camera setters only seed the cache from delivered state; the
+ // server already holds the camera they carry, so they send nothing.
+
+ async setCameraPositionAsync(position: readonly number[]): Promise {
+ this.#writeCamera('position', [...position]);
+ }
+
+ async setCameraFocalPointAsync(focalPoint: readonly number[]): Promise {
+ this.#writeCamera('focalPoint', [...focalPoint]);
+ }
+
+ async setCameraViewUpAsync(viewUp: readonly number[]): Promise {
+ this.#writeCamera('viewUp', [...viewUp]);
+ }
+
+ async setCameraClippingRangeAsync(clippingRange: readonly number[]): Promise {
+ this.#writeCamera('clippingRange', [...clippingRange]);
+ }
+
+ async setCameraParallelProjectionAsync(enabled: boolean): Promise {
+ this.#writeCamera('parallelProjection', enabled);
+ }
+
+ async setCameraViewAngleAsync(angle: number): Promise {
+ this.#writeCamera('viewAngle', angle);
+ }
+
+ async setCameraParallelScaleAsync(scale: number): Promise {
+ this.#writeCamera('parallelScale', scale);
+ }
+
+ // ---- Subscriptions ----------------------------------------------------
+ addCameraChangedListener(callback: (state: VisorCameraState) => void): () => void {
+ const remover = () => {
+ this.#cameraChangedListeners.delete(remover);
+ };
+ this.#cameraChangedListeners.set(remover, callback);
+ return remover;
+ }
+
+ addFrameRenderedListener(callback: (fps: number) => void): () => void {
+ return this.#canvas.addFrameListener(callback);
+ }
+
+ /** Held, but never called: no click picks a part in server rendering yet. */
+ addViewerClickedListener(
+ callback: (
+ nodeId: NodeId | null,
+ ctrlKey: boolean,
+ shiftKey: boolean,
+ normX: number,
+ normY: number
+ ) => void
+ ): () => void {
+ const remover = () => {
+ this.#viewerClickedListeners.delete(remover);
+ };
+ this.#viewerClickedListeners.set(remover, callback);
+ return remover;
+ }
+
+ // ---- Per-part visual state -----------------------------------------------
+ // The server applies each of these through the paired send below; there
+ // is no client-side object to write.
+
+ async setDiffuseColorRgbAsync(
+ _nodeId: NodeId,
+ _r: number,
+ _g: number,
+ _b: number,
+ _selected: boolean
+ ): Promise {}
+
+ async resetDiffuseColorAsync(
+ _nodeId: NodeId,
+ _defaultRgb: readonly number[],
+ _selected: boolean
+ ): Promise {}
+
+ async setVisibilityAsync(_nodeId: NodeId, _visible: boolean): Promise {}
+
+ async setSelectedAsync(
+ _nodeId: NodeId,
+ _selected: boolean,
+ _currentDiffuseRgb: readonly number[]
+ ): Promise {}
+
+ async setOpacityAsync(_nodeId: NodeId, _opacity: number): Promise {}
+
+ async setColorVariableAsync(
+ _nodeId: NodeId,
+ _descriptor: ColorVariableDescriptor
+ ): Promise {}
+
+ async clearColorVariableAsync(_nodeId: NodeId): Promise {}
+
+ async setScalarRangeAsync(_nodeId: NodeId, _min: number, _max: number): Promise {}
+
+ // ---- Per-part mutations routed to the server -----------------------------
+ async sendPartVisibilityAsync(nodeId: NodeId, visible: boolean): Promise {
+ await this.#sends.sendPartVisibilityAsync(nodeId, visible);
+ }
+
+ async sendPartOpacityAsync(nodeId: NodeId, opacity: number): Promise {
+ await this.#sends.sendPartOpacityAsync(nodeId, opacity);
+ }
+
+ async sendPartDiffuseColorAsync(
+ nodeId: NodeId,
+ diffuseRgb: readonly number[] | null
+ ): Promise {
+ await this.#sends.sendPartDiffuseColorAsync(nodeId, diffuseRgb);
+ }
+
+ async sendPartSelectedAsync(nodeId: NodeId, selected: boolean): Promise {
+ await this.#sends.sendPartSelectedAsync(nodeId, selected);
+ }
+
+ async sendPartColorVariableAsync(
+ nodeId: NodeId,
+ descriptor: ColorVariableDescriptor
+ ): Promise {
+ await this.#sends.sendPartColorVariableAsync(nodeId, descriptor);
+ }
+
+ async sendClearPartColorVariableAsync(nodeId: NodeId): Promise {
+ await this.#sends.sendClearPartColorVariableAsync(nodeId);
+ }
+
+ // ---- View-level widgets ---------------------------------------------------
+ // A setter called with no argument toggles the cached flag and sends the
+ // value it settled on: the payload is always absolute.
+
+ async setCrossSectionVisibilityAsync(visible?: boolean): Promise {
+ const resolved = visible ?? !this.#crossSectionVisible;
+ this.#crossSectionVisible = resolved;
+ await this.#sends.sendCrossSectionVisibilityAsync(resolved);
+ }
+
+ async applyCrossSectionVisibilityAsync(visible: boolean): Promise {
+ this.#crossSectionVisible = visible;
+ }
+
+ isCrossSectionVisible(): boolean {
+ return this.#crossSectionVisible;
+ }
+
+ async getCrossSectionOriginAsync(): Promise {
+ if (this.#crossSectionOrigin == null) {
+ throw new CrossSectionStateUnavailableError();
+ }
+ return this.#crossSectionOrigin;
+ }
+
+ async getCrossSectionNormalAsync(): Promise {
+ if (this.#crossSectionNormal == null) {
+ throw new CrossSectionStateUnavailableError();
+ }
+ return this.#crossSectionNormal;
+ }
+
+ /** Seeds the cache only; the server already holds the plane. */
+ async setCrossSectionOriginAsync(origin: readonly number[]): Promise {
+ this.#crossSectionOrigin = [...origin];
+ }
+
+ /** Seeds the cache only; the server already holds the plane. */
+ async setCrossSectionNormalAsync(normal: readonly number[]): Promise {
+ this.#crossSectionNormal = [...normal];
+ }
+
+ async setBoundingBoxVisibilityAsync(visible?: boolean): Promise {
+ const resolved = visible ?? !this.#boundingBoxVisible;
+ this.#boundingBoxVisible = resolved;
+ await this.#sends.sendBoundingBoxVisibilityAsync(resolved);
+ }
+
+ async applyBoundingBoxVisibilityAsync(visible: boolean): Promise {
+ this.#boundingBoxVisible = visible;
+ }
+
+ isBoundingBoxVisible(): boolean {
+ return this.#boundingBoxVisible;
+ }
+
+ async setOrthographicModeAsync(enable?: boolean): Promise {
+ const resolved = enable ?? !this.isOrthographicEnabled();
+ this.#writeCamera('parallelProjection', resolved);
+ await this.#sends.sendProjectionAsync(resolved);
+ }
+
+ async applyOrthographicModeAsync(enable: boolean): Promise {
+ this.#writeCamera('parallelProjection', enable);
+ }
+
+ /** The cached camera's parallelProjection; false until delivered. */
+ isOrthographicEnabled(): boolean {
+ return this.#camera.field('parallelProjection') ?? false;
+ }
+
+ async setEdgeVisibilityGlobalAsync(visible?: boolean): Promise {
+ const resolved = visible ?? !this.#edgesVisible;
+ this.#edgesVisible = resolved;
+ await this.#sends.sendEdgesVisibleAsync(resolved);
+ }
+
+ async applyEdgeVisibilityGlobalAsync(visible: boolean): Promise {
+ this.#edgesVisible = visible;
+ }
+
+ areEdgesVisibleGlobally(): boolean {
+ return this.#edgesVisible;
+ }
+
+ // ---- Picking ------------------------------------------------------------
+ async pickGeometryAsync(
+ _normX: number,
+ _normY: number,
+ _mode: GeometryPickMode
+ ): Promise {
+ throw new GeometryPickingUnavailableError();
+ }
+
+ /** Rejects rather than doing nothing, so a caller that skips the pick fails loudly. */
+ async setGeometryHighlightAsync(_result: PickGeometryResult): Promise {
+ throw new GeometryPickingUnavailableError();
+ }
+
+ /** There is never a highlight to clear. */
+ clearGeometryHighlight(): void {}
+}
diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts
index 8e9f5981..3f744b6d 100644
--- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts
+++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts
@@ -14,6 +14,7 @@ import { CrossSectionWidget } from '../widgets/crossSectionWidget.ts';
import { BoundingBoxWidget } from '../widgets/boundingBoxWidget.ts';
import { OrthographicWidget } from '../widgets/orthographicWidget.ts';
import { EdgesWidget } from '../widgets/edgesWidget.ts';
+import { SceneTriggerSends } from './SceneTriggerSends';
const SELECTION_TINT_RGB: Readonly = [0 / 255, 62 / 255, 111 / 255];
@@ -30,7 +31,7 @@ export class WasmRenderer implements IRenderer {
triggerSender: TrameTriggerSender | null
) {
this.#vtkScene = vtkScene;
- this.#triggerSender = triggerSender;
+ this.#sends = new SceneTriggerSends(triggerSender);
const wasmPlaneId = annotation.widgets.crossSectionPlaneId;
const wasmPlaneRepId = annotation.widgets.crossSectionPlaneRepresentationId;
@@ -83,17 +84,16 @@ export class WasmRenderer implements IRenderer {
* keys are `origin` and `normal`, snake_case and unaliased, three
* floats each, which is what the server's payload model requires.
*
- * Deliberately not defensive: `#sendWidgetTriggerAsync` already
+ * Deliberately not defensive: `SceneTriggerSends` already
* swallows and logs a send rejection, and a `catch` around the two
* reads would turn a broken representation read into a silent
* no-report, which is the one outcome that reads as "the observer was
* never wired".
*/
planeWidget.observe('EndInteractionEvent', async () => {
- await this.#sendWidgetTriggerAsync('sync_cross_section_plane', {
- origin: await this.#crossSectionWidget.getOriginAsync(),
- normal: await this.#crossSectionWidget.getNormalAsync(),
- });
+ const origin = await this.#crossSectionWidget.getOriginAsync();
+ const normal = await this.#crossSectionWidget.getNormalAsync();
+ await this.#sends.sendCrossSectionPlaneAsync(origin, normal);
});
/**
@@ -143,7 +143,7 @@ export class WasmRenderer implements IRenderer {
}
readonly #vtkScene: VtkScene;
- readonly #triggerSender: TrameTriggerSender | null;
+ readonly #sends: SceneTriggerSends;
readonly #actorIdToNodeId = new Map();
readonly #nodeIdToHandles = new Map();
readonly #crossSectionWidget: CrossSectionWidget;
@@ -225,6 +225,13 @@ export class WasmRenderer implements IRenderer {
});
}
+ /**
+ * Fires once per gesture, after the last camera change has stopped changing for
+ * CAMERA_SETTLE_MS, with the origin of that change. The callback receives
+ * the origin only; read the camera with getCameraStateAsync if needed.
+ * A `gesture` report is the user's own camera; a `programmatic` one is a
+ * camera the application or the server applied.
+ */
addCameraSettledListener(callback: (origin: CameraOrigin) => void): () => void {
return this.#vtkScene.addCameraSettledListener(callback);
}
@@ -379,121 +386,34 @@ export class WasmRenderer implements IRenderer {
}
// ---- Per-part mutations routed to the server -----------------------------
- /**
- * Send one per-part payload to its server trigger.
- *
- * No sender injected -> no-op. This is the state every non-browser context
- * is in, and it is deliberate rather than defensive: the renderer is
- * constructible without a transport.
- *
- * A rejection is logged and swallowed, never rethrown. These sends run
- * inside UI handlers that behaved a certain way before this call existed,
- * and both stacks currently apply the same mutation independently, so a
- * transport failure must not change what the user sees or break an
- * interaction. The catch is deliberately unnarrowed: any rejection, of any
- * shape, is a failed send. The log line below is therefore the *only*
- * signal that a send failed -- the render will look correct either way --
- * so its prefix is fixed and greppable, and it names both the trigger and
- * the node so a failure can be attributed without a debugger.
- */
- async #sendTriggerAsync(
- triggerName: string,
- nodeId: NodeId,
- payload: Record
- ): Promise {
- if (this.#triggerSender == null) {
- return;
- }
- try {
- await this.#triggerSender(triggerName, payload);
- } catch (err) {
- console.error(
- `[VISOR] per-part trigger send failed: trigger='${triggerName}' nodeId=${nodeId}`,
- err
- );
- }
- }
-
- /**
- * Send one view-level widget payload to its server trigger.
- *
- * Sibling of `#sendTriggerAsync` above; same no-sender/no-op and
- * logged-and-swallowed-rejection rationale, not repeated here.
- *
- * Separate only because a widget trigger has no `nodeId` to name in the
- * error line, so the log prefix here is distinct and greppable on its own.
- */
- async #sendWidgetTriggerAsync(
- triggerName: string,
- payload: Record
- ): Promise {
- if (this.#triggerSender == null) {
- return;
- }
- try {
- await this.#triggerSender(triggerName, payload);
- } catch (err) {
- console.error(`[VISOR] widget trigger send failed: trigger='${triggerName}'`, err);
- }
- }
-
async sendPartVisibilityAsync(nodeId: NodeId, visible: boolean): Promise {
- await this.#sendTriggerAsync('set_part_visibility', nodeId, {
- nodeId,
- visible,
- });
+ await this.#sends.sendPartVisibilityAsync(nodeId, visible);
}
async sendPartOpacityAsync(nodeId: NodeId, opacity: number): Promise {
- await this.#sendTriggerAsync('set_part_opacity', nodeId, {
- nodeId,
- opacity,
- });
+ await this.#sends.sendPartOpacityAsync(nodeId, opacity);
}
async sendPartDiffuseColorAsync(
nodeId: NodeId,
diffuseRgb: readonly number[] | null
): Promise {
- // The key is always present: `diffuseRgb: null` is how a reset is
- // expressed. Omitting it is a different message, and not a valid one.
- await this.#sendTriggerAsync('set_part_diffuse_color', nodeId, {
- nodeId,
- diffuseRgb,
- });
+ await this.#sends.sendPartDiffuseColorAsync(nodeId, diffuseRgb);
}
async sendPartSelectedAsync(nodeId: NodeId, selected: boolean): Promise {
- // No colour crosses this trigger by design; the server reads the
- // part's stored colour from its own record.
- await this.#sendTriggerAsync('set_part_selected', nodeId, {
- nodeId,
- selected,
- });
+ await this.#sends.sendPartSelectedAsync(nodeId, selected);
}
async sendPartColorVariableAsync(
nodeId: NodeId,
descriptor: ColorVariableDescriptor
): Promise {
- // `variableId` is the client-built opaque token, forwarded verbatim;
- // nothing on either side parses it. The association travels as its own
- // typed 'POINT'|'CELL' field, and the array name as its own field,
- // precisely so that no one has to. The range is not sent: the server
- // applies its own record's range for the referenced slot.
- await this.#sendTriggerAsync('set_part_color_variable', nodeId, {
- nodeId,
- variableId: descriptor.variableId,
- association: descriptor.variableType,
- arrayName: descriptor.variableName,
- component: descriptor.component,
- });
+ await this.#sends.sendPartColorVariableAsync(nodeId, descriptor);
}
async sendClearPartColorVariableAsync(nodeId: NodeId): Promise {
- await this.#sendTriggerAsync('clear_part_color_variable', nodeId, {
- nodeId,
- });
+ await this.#sends.sendClearPartColorVariableAsync(nodeId);
}
// ---- View-level widgets -------------------------------------------------
@@ -511,9 +431,7 @@ export class WasmRenderer implements IRenderer {
*/
async setCrossSectionVisibilityAsync(visible?: boolean): Promise {
await this.#crossSectionWidget.setVisibilityAsync(visible);
- await this.#sendWidgetTriggerAsync('set_cross_section_visibility', {
- visible: this.isCrossSectionVisible(),
- });
+ await this.#sends.sendCrossSectionVisibilityAsync(this.isCrossSectionVisible());
}
isCrossSectionVisible(): boolean {
@@ -542,9 +460,7 @@ export class WasmRenderer implements IRenderer {
async setBoundingBoxVisibilityAsync(visible?: boolean): Promise {
await this.#boundingBoxWidget.setVisibilityAsync(visible);
- await this.#sendWidgetTriggerAsync('set_bounding_box_visibility', {
- visible: this.isBoundingBoxVisible(),
- });
+ await this.#sends.sendBoundingBoxVisibilityAsync(this.isBoundingBoxVisible());
}
isBoundingBoxVisible(): boolean {
@@ -557,9 +473,7 @@ export class WasmRenderer implements IRenderer {
async setOrthographicModeAsync(enable?: boolean): Promise {
await this.#orthographicWidget.setOrthographicModeAsync(enable);
- await this.#sendWidgetTriggerAsync('set_projection', {
- parallel: this.isOrthographicEnabled(),
- });
+ await this.#sends.sendProjectionAsync(this.isOrthographicEnabled());
}
isOrthographicEnabled(): boolean {
@@ -572,9 +486,7 @@ export class WasmRenderer implements IRenderer {
async setEdgeVisibilityGlobalAsync(visible?: boolean): Promise {
await this.#edgesWidget.setEdgesVisibleAsync(visible);
- await this.#sendWidgetTriggerAsync('set_edges_visible', {
- visible: this.#edgesWidget.enabled,
- });
+ await this.#sends.sendEdgesVisibleAsync(this.#edgesWidget.enabled);
}
areEdgesVisibleGlobally(): boolean {