Skip to content
Open
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/187.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 4.3b: Display the panels in server mode
10 changes: 0 additions & 10 deletions src/ansys/visor/visor-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -59,15 +58,6 @@ function App() {
`${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
18 changes: 9 additions & 9 deletions src/ansys/visor/visor-client/src/CameraSyncReporter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import type {
CameraOrigin,
IRenderer,
TrameTriggerSender,
VisorCameraState,
} from './renderer/IRenderer';
import type { CameraOrigin, TrameTriggerSender, VisorCameraState } from './renderer/IRenderer';
import type { WasmRenderer } from './renderer/WasmRenderer';

/**
* The `sync_camera` trigger: the client's half of the server-tracked camera.
Expand Down Expand Up @@ -35,11 +31,15 @@ export type SyncCameraPayload = Readonly<{
}>;

/**
* 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<IRenderer, 'addCameraSettledListener' | 'getCameraStateAsync'>;
export type CameraSyncSource = Pick<
WasmRenderer,
'addCameraSettledListener' | 'getCameraStateAsync'
>;

/**
* Build the trigger payload from a settled camera snapshot.
Expand Down
69 changes: 62 additions & 7 deletions src/ansys/visor/visor-client/src/hooks/useServerVtk.ts
Original file line number Diff line number Diff line change
@@ -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<ServerCanvas | null>(null);
const [visorFrontend, setVisorFrontend] = useState<VisorFrontend | null>(null);
const [schemaMismatchError, setSchemaMismatchError] =
useState<SchemaVersionMismatchError | null>(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. */
Expand All @@ -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<void> {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Original file line number Diff line number Diff line change
@@ -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 }]]]);
});
});
Loading
Loading