Skip to content
Merged
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/185.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 4.2b: Choose viewer from server mode and show streamed frames
22 changes: 20 additions & 2 deletions src/ansys/visor/visor-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { CSSProperties, RefObject, useEffect, useRef } from 'react';
import { randomInt } from './utils/JsHelpers';
import { useVisorArgs } from './hooks/useVisorArgs';
import { useClientVtk } from './hooks/useClientVtk';
import { useRenderingMode } from './hooks/useRenderingMode';
import { useServerVtk } from './hooks/useServerVtk';

function App() {
const visorArgs = useVisorArgs();
Expand All @@ -22,7 +24,12 @@ function App() {
margin: '0 auto',
};
const uiScaffoldUtil: RefObject<UiScaffoldUtil> = useRef(null!);
const { visorFrontend: visorState, schemaMismatchError } = useClientVtk(visorArgs);
const probe = useRenderingMode(visorArgs.host, visorArgs.port);
const client = useClientVtk(visorArgs, probe.mode === 'client');
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 () => {
Expand All @@ -44,12 +51,23 @@ function App() {

return (
<div style={visorContainerStyle}>
{schemaMismatchError != null ? (
{probe.error != null ? (
<div>{probe.error.message}</div>
) : schemaMismatchError != null ? (
<div>
{`Scene details schema version mismatch: payload is version ` +
`${schemaMismatchError.payloadVersion}, client expects version ` +
`${schemaMismatchError.expectedVersion}.`}
</div>
) : serverCanvas != null ? (
// Server mode has no frontend to host the canvas, so its
// element is mounted here directly.
<div
style={{ width: '100%', height: '100%' }}
ref={(container) => {
container?.replaceChildren(serverCanvas.element);
}}
/>
) : (
visorState != null && (
<UiScaffold
Expand Down
12 changes: 8 additions & 4 deletions src/ansys/visor/visor-client/src/hooks/useClientVtk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WasmRenderer } from '../renderer/WasmRenderer';
import { requireWasmAnnotation } from '../renderer/RendererAnnotation';
import { attachCameraSyncReporter } from '../CameraSyncReporter';
import { buildFrontendAsync } from '../buildFrontendAsync';
import { trameWebSocketUrl } from '../trameWebSocketUrl';
import { VisorArgs } from './useVisorArgs';

/** What the client-mode hook gives App. */
Expand All @@ -15,15 +16,18 @@ export type ClientVtk = Readonly<{
schemaMismatchError: SchemaVersionMismatchError | null;
}>;

/** Connect the wasm scene, rebuild the frontend on each server update, and apply set_state. */
export function useClientVtk(visorArgs: VisorArgs): ClientVtk {
/** 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 {
const [visorFrontend, setVisorFrontend] = useState<VisorFrontend | null>(null);
const [schemaMismatchError, setSchemaMismatchError] =
useState<SchemaVersionMismatchError | null>(null);
const wasmView = useRef<RemoteVtkScene | null>(null);
const cameraSyncRemover = useRef<(() => void) | null>(null);

useEffect(() => {
if (!active) {
return;
}
(async () => {
if (wasmView.current == null) {
const { host, port } = visorArgs;
Expand All @@ -32,13 +36,13 @@ export function useClientVtk(visorArgs: VisorArgs): ClientVtk {
wasmIdsStateKey: 'wasm_ids',
refNameStateKey: 'wasm_ref_name',
wasmUrl: `http${secure ? 's' : ''}://${host}:${port}/wasm`,
webSocketUrl: `ws${secure ? 's' : ''}://${host}:${port}/ws`,
webSocketUrl: trameWebSocketUrl(host, port),
});
wasmView.current.addServerUpdatedListener(onServerUpdateAsync);
await onServerUpdateAsync();
}
})();
}, []);
}, [active]);

async function onServerUpdateAsync() {
if (wasmView.current == null) {
Expand Down
80 changes: 80 additions & 0 deletions src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react';
import { Trame } from '@kitware/trame/src/trame.js';
import { trameWebSocketUrl } from '../trameWebSocketUrl';

/** The rendering_mode wire values. */
export type RenderingMode = 'client' | 'server';

/** Raised when the server's rendering_mode is missing or not a known wire value. */
export class UnknownRenderingModeError extends Error {
constructor(value: unknown) {
super(`Unknown rendering_mode: ${JSON.stringify(value) ?? String(value)}`);
this.name = `UnknownRenderingModeError`;
this.value = value;
}

readonly value: unknown;
}

/** Map a rendering_mode state value to a mode; throws UnknownRenderingModeError for anything else. */
export function parseRenderingMode(value: unknown): RenderingMode {
if (value === 'client' || value === 'server') {
return value;
}
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. */
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. */
export function useRenderingMode(host: string, port: number): RenderingModeProbe {
const [probe, setProbe] = useState<RenderingModeProbe>({
mode: null,
trame: null,
error: null,
});

useEffect(() => {
// Under StrictMode the effect runs twice in development: each run owns
// its own connection, and a cancelled run drops its result.
let cancelled = false;
const trame = new Trame();
(async () => {
try {
await trame.connect({
application: 'trame',
sessionURL: trameWebSocketUrl(host, port),
});
if (cancelled) {
trame.disconnect();
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 });
}
} catch (err) {
trame.disconnect();
if (!cancelled) {
const error = err instanceof Error ? err : new Error(String(err));
setProbe({ mode: null, trame: null, error });
}
}
})();
return () => {
cancelled = true;
trame.disconnect();
};
}, [host, port]);

return probe;
}
57 changes: 57 additions & 0 deletions src/ansys/visor/visor-client/src/hooks/useServerVtk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 { RCA_AREA_NAME, ServerCanvas, type RcaSession } from '../rca/ServerCanvas';
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. */
export function useServerVtk(trame: Trame | null, visorArgs: VisorArgs): ServerVtk {
const [serverCanvas, setServerCanvas] = useState<ServerCanvas | null>(null);

useEffect(() => {
if (trame == null) {
return;
}
// 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);
(async () => {
if (visorArgs.darkMode != null) {
await sendDarkThemeAsync(trame, visorArgs.darkMode);
}
})();
return () => {
canvas.dispose();
setServerCanvas(null);
};
}, [trame]);

return { visorFrontend: null, schemaMismatchError: null, serverCanvas };
}

/** The wslink session of a connected trame client. */
function sessionOf(trame: Trame): RcaSession {
const session = trame.client?.getConnection()?.getSession();
if (session == null) {
throw new Error(`trame has no open session`);
}
return session;
}

/** 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<void> {
try {
await trame.trigger('set_dark_theme', [{ dark }]);
} catch (err) {
console.error('[VISOR] set_dark_theme send failed', err);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ jest.mock('../components/UiScaffold.tsx', () => ({
},
}));

jest.mock('../hooks/useRenderingMode', () => ({
useRenderingMode: () => ({ mode: 'client', trame: null, error: null }),
}));

/** A minimal v4 scene-details payload whose delivered theme is light. */
function mockSceneDetailsPayload() {
return {
Expand Down
54 changes: 54 additions & 0 deletions src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { render, waitFor } from '@testing-library/react';
import App from '../App';

/**
* When the probe reports server rendering, the page never starts the
* client-mode wasm scene: it streams frames over the probe's session instead.
*
* Expected values are hand-written literals.
*/

const mockGetInstanceAsync = jest.fn();
const mockSubscribe = jest.fn(() => ({ unsubscribe: () => {} }));
const mockTrame = {
client: {
getConnection: () => ({
getSession: () => ({ subscribe: mockSubscribe, call: async () => undefined }),
}),
},
trigger: async () => undefined,
};

jest.mock('../wasm/RemoteVtkScene', () => ({
__esModule: true,
default: { getInstanceAsync: (...args: unknown[]) => mockGetInstanceAsync(...args) },
}));

jest.mock('../hooks/useRenderingMode', () => ({
useRenderingMode: () => ({ mode: 'server', trame: mockTrame, error: null }),
}));

const globals = globalThis as unknown as { ResizeObserver: unknown };
let originalResizeObserver: unknown;

beforeEach(() => {
originalResizeObserver = globals.ResizeObserver;
globals.ResizeObserver = class {
observe() {}
disconnect() {}
};
});

afterEach(() => {
globals.ResizeObserver = originalResizeObserver;
});

describe('App mode branch', () => {
test('server mode never loads the wasm scene', async () => {
render(<App />);

await waitFor(() => expect(mockSubscribe).toHaveBeenCalled());

expect(mockGetInstanceAsync).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jest.mock('../components/UiScaffold.tsx', () => ({
UiScaffold: () => null,
}));

jest.mock('../hooks/useRenderingMode', () => ({
useRenderingMode: () => ({ mode: 'client', trame: null, error: null }),
}));

/** The client-mode scene connection; its scene details carry schema version 3. */
function mockMakeWasmView() {
return {
Expand Down
Loading
Loading