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 {