Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/203.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Reuse the rendering-mode probe connection in client mode
2 changes: 1 addition & 1 deletion src/ansys/visor/visor-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function App() {
};
const uiScaffoldUtil: RefObject<UiScaffoldUtil> = useRef(null!);
const probe = useRenderingMode(visorArgs.host, visorArgs.port);
const client = useClientVtk(visorArgs, probe.mode === 'client');
const client = useClientVtk(visorArgs, probe.mode === 'client' ? probe.trame : null);
const server = useServerVtk(probe.mode === 'server' ? probe.trame : null, visorArgs);
const active = probe.mode === 'server' ? server : client;
const { visorFrontend: visorState, schemaMismatchError } = active;
Expand Down
11 changes: 7 additions & 4 deletions src/ansys/visor/visor-client/src/frontend/ClientViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { WasmRenderer } from '../renderer/WasmRenderer';
import { requireWasmAnnotation } from '../renderer/RendererAnnotation';
import { attachCameraSyncReporter } from '../CameraSyncReporter';
import { buildFrontendAsync } from '../buildFrontendAsync';
import { trameWebSocketUrl } from '../trameWebSocketUrl';
import type { Trame } from '@kitware/trame/src/trame.js';
import type { VisorArgs } from '../hooks/useVisorArgs';
import {
CurrentFrontend,
Expand All @@ -32,18 +32,21 @@ class ClientFrontendBuild implements FrontendBuild {

/** A client-mode view over the wasm scene: rebuilds on each server update and applies set_state to the current frontend. */
export class ClientViewer {
/** Connect the wasm scene, listen for server updates and run the first build; both parameters required. */
/** Connect the wasm scene over the given session, listen for server updates and run the first build; all parameters required. */
static async connectAsync(
visorArgs: VisorArgs,
trame: Trame,
onChange: FrontendChangeListener
): Promise<ClientViewer> {
const { host, port } = visorArgs;
const secure = window.location.protocol === 'https:';
const serverOrigin = `http${secure ? 's' : ''}://${host}:${port}`;
const wasm = await RemoteVtkScene.getInstanceAsync({
trame,
wasmIdsStateKey: 'wasm_ids',
refNameStateKey: 'wasm_ref_name',
wasmUrl: `http${secure ? 's' : ''}://${host}:${port}/wasm`,
webSocketUrl: trameWebSocketUrl(host, port),
wasmUrl: `${serverOrigin}/wasm`,
serverOrigin,
});
const viewer = new ClientViewer(wasm, visorArgs.darkMode, onChange);
wasm.addServerUpdatedListener(viewer.#onServerUpdated);
Expand Down
10 changes: 6 additions & 4 deletions src/ansys/visor/visor-client/src/hooks/useClientVtk.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, 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 { ClientViewer } from '../frontend/ClientViewer';
Expand All @@ -10,29 +11,30 @@ export type ClientVtk = Readonly<{
schemaMismatchError: SchemaVersionMismatchError | null;
}>;

/** Connect the wasm scene, rebuild the frontend on each server update, and apply set_state; idle until active. */
export function useClientVtk(visorArgs: VisorArgs, active: boolean): ClientVtk {
/** Connect the wasm scene over the given session, rebuild on each server update, and apply set_state; idle while trame is null. */
export function useClientVtk(visorArgs: VisorArgs, trame: Trame | null): ClientVtk {
const [visorFrontend, setVisorFrontend] = useState<VisorFrontend | null>(null);
const [schemaMismatchError, setSchemaMismatchError] =
useState<SchemaVersionMismatchError | null>(null);
const viewer = useRef<ClientViewer | null>(null);

useEffect(() => {
if (!active) {
if (trame == null) {
return;
}
(async () => {
if (viewer.current == null) {
viewer.current = await ClientViewer.connectAsync(
visorArgs,
trame,
(frontend, mismatch) => {
setSchemaMismatchError(mismatch);
setVisorFrontend(frontend);
}
);
}
})();
}, [active]);
}, [trame]);

return { visorFrontend, schemaMismatchError };
}
12 changes: 3 additions & 9 deletions src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ export function parseRenderingMode(value: unknown): RenderingMode {
throw new UnknownRenderingModeError(value);
}

/** The probe's outcome: exactly one of mode and error is set once it settles; trame is set in server mode only. */
/** The probe's outcome: exactly one of mode and error is set once it settles; trame is set together with mode. */
export type RenderingModeProbe = Readonly<{
mode: RenderingMode | null;
trame: Trame | null;
error: Error | null;
}>;

/** Connect once on the page's ws/wss scheme, read rendering_mode, and keep the session only in server mode. */
/** Connect once on the page's ws/wss scheme, read rendering_mode, and keep the session for that mode's scene. */
export function useRenderingMode(host: string, port: number): RenderingModeProbe {
const [probe, setProbe] = useState<RenderingModeProbe>({
mode: null,
Expand All @@ -55,13 +55,7 @@ export function useRenderingMode(host: string, port: number): RenderingModeProbe
return;
}
const mode = parseRenderingMode(trame.state?.get('rendering_mode'));
if (mode === 'client') {
// The client-mode scene opens its own connection.
trame.disconnect();
setProbe({ mode, trame: null, error: null });
} else {
setProbe({ mode, trame, error: null });
}
setProbe({ mode, trame, error: null });
} catch (err) {
trame.disconnect();
if (!cancelled) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ jest.mock('../components/UiScaffold.tsx', () => ({
}));

jest.mock('../hooks/useRenderingMode', () => ({
useRenderingMode: () => ({ mode: 'client', trame: null, error: null }),
// The scene connection is mocked, so the session is only handed on.
useRenderingMode: () => ({ mode: 'client', trame: {}, error: null }),
}));

/** A minimal v4 scene-details payload whose delivered theme is light. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ jest.mock('../components/UiScaffold.tsx', () => ({
}));

jest.mock('../hooks/useRenderingMode', () => ({
useRenderingMode: () => ({ mode: 'client', trame: null, error: null }),
// The scene connection is mocked, so the session is only handed on.
useRenderingMode: () => ({ mode: 'client', trame: {}, error: null }),
}));

/** The client-mode scene connection; its scene details carry schema version 3. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {

/**
* The probe reads `rendering_mode` from one trame connection, keeps that
* connection only when the server renders, and reports anything other than
* the two wire values as a named error.
* connection in both modes for the mode's scene, and reports anything other
* than the two wire values as a named error.
*
* Expected values are hand-written literals.
*/
Expand Down Expand Up @@ -63,14 +63,14 @@ describe('parseRenderingMode', () => {
});

describe('useRenderingMode', () => {
test('client mode disconnects the probe and hands on no session', async () => {
test('client mode keeps the probe connection', async () => {
mockProbe.renderingMode = 'client';

const { result } = renderHook(() => useRenderingMode('localhost', 8080));
await waitFor(() => expect(result.current.mode).toBe('client'));

expect(mockProbe.trame!.disconnect).toHaveBeenCalledTimes(1);
expect(result.current.trame).toBeNull();
expect(mockProbe.trame!.disconnect).not.toHaveBeenCalled();
expect(result.current.trame).toBe(mockProbe.trame);
});

test('server mode keeps the probe connection', async () => {
Expand Down
36 changes: 36 additions & 0 deletions src/ansys/visor/visor-client/src/jest-tests/TrameInterface.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Trame } from '@kitware/trame/src/trame.js';
import TrameInterface from '../wasm/TrameInterface.js';

/**
* `TrameInterface` wraps the session it is given: the server's calls on the
* ref name reach the callbacks added to the wrapper through that session's
* `refs`, and the wrapper opens no connection of its own.
*
* Expected values are hand-written literals.
*/

jest.mock('@kitware/trame/src/trame.js', () => ({
Trame: jest.fn(),
}));

/** A connected session whose state names the ref the server calls. */
function makeSession() {
return {
refs: {},
client: { getConnection: () => ({ getSession: () => ({}) }) },
state: { get: (key) => (key === 'wasm_ref_name' ? 'visor_wasm' : undefined) },
};
}

describe('TrameInterface', () => {
test('wraps the given connection: its refs reach the callbacks and no Trame is constructed', async () => {
const session = makeSession();
const update = () => {};

const wrapper = await TrameInterface.getInstanceAsync(session, 'wasm_ref_name');
wrapper.addSingleCallback('update', update);

expect(session.refs['visor_wasm'].update).toBe(update);
expect(Trame).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { useClientVtk } from '../hooks/useClientVtk';
import type { VisorArgs } from '../hooks/useVisorArgs';
import type { Trame } from '@kitware/trame/src/trame.js';
import type { CameraOrigin, VisorCameraState } from '../renderer/IRenderer';

/**
Expand Down Expand Up @@ -141,6 +142,9 @@ const ARGS = {
basePath: '',
} as VisorArgs;

/** The probe's session; the scene connection is mocked, so it is only handed on. */
const TRAME = {} as Trame;

function syncCameraSendCount(): number {
return mockScene.trameTriggerAsync.mock.calls.filter((call) => call[0] === 'sync_camera')
.length;
Expand Down Expand Up @@ -168,7 +172,7 @@ afterEach(() => {

describe('useClientVtk camera sync', () => {
test('one gesture settle after two rebuilds sends one sync_camera', async () => {
const { result } = renderHook(() => useClientVtk(ARGS, true));
const { result } = renderHook(() => useClientVtk(ARGS, TRAME));
await waitFor(() => expect(result.current.visorFrontend).not.toBeNull());
await act(async () => {
await mockScene.serverUpdated!();
Expand All @@ -188,7 +192,7 @@ describe('useClientVtk camera sync', () => {
test('a settle during the first build state seed is reported once', async () => {
mockScene.settleDuringFirstSeed = true;

const { result } = renderHook(() => useClientVtk(ARGS, true));
const { result } = renderHook(() => useClientVtk(ARGS, TRAME));
await waitFor(() => expect(result.current.visorFrontend).not.toBeNull());
await act(async () => {
await drain();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { useClientVtk } from '../hooks/useClientVtk';
import type { VisorArgs } from '../hooks/useVisorArgs';
import type { Trame } from '@kitware/trame/src/trame.js';

/**
* In client mode every server update rebuilds the frontend over the same wasm
* scene. A successful rebuild releases the replaced frontend and names the new
* one on `window.__visorState`; a schema mismatch keeps the current frontend
* and reports the error.
* and reports the error. The scene connects over the session the hook is given.
*
* Expected values are hand-written literals.
*/
Expand All @@ -16,11 +17,17 @@ const mockScene = {
payloads: [] as unknown[],
fetches: 0,
renderers: [] as { liveViewerClicks: Set<() => void> }[],
configs: [] as { trame?: unknown }[],
};

jest.mock('../wasm/RemoteVtkScene', () => ({
__esModule: true,
default: { getInstanceAsync: async () => mockMakeWasmView() },
default: {
getInstanceAsync: async (config: { trame?: unknown }) => {
mockScene.configs.push(config);
return mockMakeWasmView();
},
},
}));

jest.mock('../renderer/WasmRenderer', () => ({
Expand Down Expand Up @@ -108,9 +115,12 @@ const ARGS = {
basePath: '',
} as VisorArgs;

/** The probe's session; the scene connection is mocked, so it is only handed on. */
const TRAME = {} as Trame;

/** Mount the hook and wait for its first frontend. */
async function mountBuiltAsync() {
const hook = renderHook(() => useClientVtk(ARGS, true));
const hook = renderHook(() => useClientVtk(ARGS, TRAME));
await waitFor(() => expect(hook.result.current.visorFrontend).not.toBeNull());
return hook;
}
Expand All @@ -127,13 +137,21 @@ beforeEach(() => {
mockScene.payloads = [];
mockScene.fetches = 0;
mockScene.renderers = [];
mockScene.configs = [];
});

afterEach(() => {
delete (window as any).__visorState;
});

describe('useClientVtk rebuild', () => {
test('the scene connects over the session the hook is given', async () => {
mockScene.payloads = [mockSceneDetailsPayload()];
await mountBuiltAsync();

expect(mockScene.configs).toHaveLength(1);
expect(mockScene.configs[0].trame).toBe(TRAME);
});
test('a client rebuild releases the old frontend', async () => {
mockScene.payloads = [mockSceneDetailsPayload(), mockSceneDetailsPayload()];
await mountBuiltAsync();
Expand Down
19 changes: 11 additions & 8 deletions src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,37 +105,40 @@ export default class RemoteVtkScene {
};

/**
* Build the wasm scene over a connected trame session; every config field is required.
* @param {{
* trame:import('@kitware/trame/src/trame.js').Trame,
* wasmIdsStateKey:string,
* refNameStateKey:string,
* webSocketUrl:string,
* wasmUrl:string,
* serverOrigin:string,
* }} config
* @returns {Promise<RemoteVtkScene>}
*/
static getInstanceAsync = async (config) => {
if (config == null) {
throw new Error(`config cannot be null`);
} else if (config.trame == null) {
throw new Error(`config.trame cannot be null`);
} else if (typeof config.wasmIdsStateKey !== 'string') {
throw new Error(`config.wasmIdsStateKey must be a string`);
} else if (typeof config.refNameStateKey !== 'string') {
throw new Error(`config.refNameStateKey must be a string`);
} else if (typeof config.webSocketUrl !== 'string') {
throw new Error(`config.webSocketUrl must be a string`);
} else if (typeof config.wasmUrl !== 'string') {
throw new Error(`config.wasmUrl must be a string`);
} else if (typeof config.serverOrigin !== 'string') {
throw new Error(`config.serverOrigin must be a string`);
}
const { refNameStateKey, wasmIdsStateKey, webSocketUrl } = config;
const { trame, refNameStateKey, wasmIdsStateKey, serverOrigin } = config;
let wasmUrl = config.wasmUrl;
const trameInterface = await TrameInterface.getInstanceAsync(webSocketUrl, refNameStateKey);
const trameInterface = await TrameInterface.getInstanceAsync(trame, refNameStateKey);

// trame-vtklocal registers wasm files under a versioned path.
// Older versions exposed a plain string under
// `__trame_vtklocal_wasm_url`. Newer versions expose an object under
// keys like `__trame_vtklocal_wasm32` / `__trame_vtklocal_wasm64` with
// a `url` property. Try multiple possible state keys and use the
// first one we find.
const wsOrigin = webSocketUrl.replace(/^ws(s?):\/\//, 'http$1://').replace(/\/ws$/, '');
const wasmStateCandidates = [
'__trame_vtklocal_wasm_url',
'__trame_vtklocal_wasm',
Expand All @@ -145,11 +148,11 @@ export default class RemoteVtkScene {
for (const key of wasmStateCandidates) {
const val = trameInterface.getState(key);
if (typeof val === 'string' && val.length > 0) {
wasmUrl = `${wsOrigin}/${val}`;
wasmUrl = `${serverOrigin}/${val}`;
break;
}
if (val && typeof val === 'object' && typeof val.url === 'string') {
wasmUrl = `${wsOrigin}/${val.url}`;
wasmUrl = `${serverOrigin}/${val.url}`;
break;
}
}
Expand Down
Loading
Loading