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/184.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 4.2a] Move CLIENT mode viewer wiring out of app
168 changes: 9 additions & 159 deletions src/ansys/visor/visor-client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,16 @@
import { UiScaffold, UiScaffoldUtil } from './components/UiScaffold.tsx';
import { CSSProperties, RefObject, useEffect, useRef, useState } from 'react';
import { VisorFrontend } from './VisorFrontend.tsx';
import VisorSceneDetails, {
SchemaVersionMismatchError,
} from './state/appstate/VisorSceneDetails.tsx';
import VisorAppState from './state/appstate/VisorAppState.tsx';
import RemoteVtkScene from './wasm/RemoteVtkScene';
import { WasmRenderer } from './renderer/WasmRenderer';
import { requireWasmAnnotation } from './renderer/RendererAnnotation';
import { CSSProperties, RefObject, useEffect, useRef } from 'react';
import { randomInt } from './utils/JsHelpers';

type VisorArgs = {
host: string;
port: number;
aspectRatio: number;
pixelDensity: number;
darkMode: boolean;
basePath: string;
};
import { useVisorArgs } from './hooks/useVisorArgs';
import { useClientVtk } from './hooks/useClientVtk';

function App() {
const visorArgs: RefObject<VisorArgs> = useRef(null!);
if (visorArgs.current == null) {
// Get current args object from window.
// If the args object was not found on
// the window, create a new object.
const args = (visorArgs.current = (window as any).__visorArgs ?? {});
// Delete args object from window if it existed there.
// BHB update 2025-11-21: don't delete the args object here,
// as they might actually need to be preserved across App.tsx remounts.
// Need to investigate.
// delete (window as any).__visorArgs;
// Set default values for args if any were not set.
args.host ??= location.hostname;
if (args.port == null) {
if (location.port === '') {
args.port = location.protocol === 'https:' ? 443 : 80;
} else {
args.port = parseInt(location.port);
}
}
args.aspectRatio ??= 800 / 600;
args.pixelDensity ??= 1000;
args.basePath ??= '';
// Sanity-check each argument's value.
if ((typeof args.host as any) !== 'string' || args.host === '') {
throw new Error(`host must be a non-empty string`);
} else if ((typeof args.port as any) !== 'number' || args.port <= 0) {
throw new Error(`port must be a number greater than 0`);
} else if ((typeof args.aspectRatio as any) !== 'number' || args.aspectRatio <= 0) {
throw new Error(`aspectRatio must be a number greater than 0`);
} else if ((typeof args.pixelDensity as any) !== 'number' || args.pixelDensity <= 0) {
throw new Error(`pixelDensity must be a number greater than 0`);
}
}
const visorArgs = useVisorArgs();
const visorContainerStyle: CSSProperties =
visorArgs.current.aspectRatio > 0
visorArgs.aspectRatio > 0
? {
position: 'relative',
aspectRatio: `${visorArgs.current.aspectRatio}`,
aspectRatio: `${visorArgs.aspectRatio}`,
maxHeight: '100%',
margin: '0 auto',
}
Expand All @@ -70,128 +22,26 @@ function App() {
margin: '0 auto',
};
const uiScaffoldUtil: RefObject<UiScaffoldUtil> = useRef(null!);
const [visorState, setVisorFrontend] = useState<VisorFrontend>(null!);
const [schemaMismatchError, setSchemaMismatchError] =
useState<SchemaVersionMismatchError | null>(null);
const wasmView = useRef<RemoteVtkScene | null>(null);
const { visorFrontend: visorState, schemaMismatchError } = useClientVtk(visorArgs);

useEffect(() => {
(async () => {
if (wasmView.current == null) {
const { host, port } = visorArgs.current;
const secure = window.location.protocol === 'https:';
wasmView.current = await RemoteVtkScene.getInstanceAsync({
wasmIdsStateKey: 'wasm_ids',
refNameStateKey: 'wasm_ref_name',
wasmUrl: `http${secure ? 's' : ''}://${host}:${port}/wasm`,
webSocketUrl: `ws${secure ? 's' : ''}://${host}:${port}/ws`,
});
wasmView.current.addServerUpdatedListener(onServerUpdateAsync);
await onServerUpdateAsync();
if (visorState == null) {
return;
} else if (uiScaffoldUtil.current == null) {
throw new Error(`uiScaffoldUtil.current should not be null here`);
}
(window as any).__visorState = visorState;
//////////////////////////////////////////////////////////////////////////
// Set UiScaffold params.
uiScaffoldUtil.current.pixelDensity = visorArgs.current.pixelDensity;
uiScaffoldUtil.current.pixelDensity = visorArgs.pixelDensity;
})();
return () => {
// Cleanup
delete (window as any).__visorState;
};
}, [visorState]);

async function onServerUpdateAsync() {
if (wasmView.current == null) {
throw new Error(`wasmView should not be null here`);
}
const perfLogging = wasmView.current.trameGetState('perf_logging') === true;
const t0 = perfLogging ? performance.now() : 0;
if (perfLogging) console.log(`[PERF] onServerUpdateAsync start`);

wasmView.current.clearObserversAndEventListeners();
const oldFrontend: VisorFrontend = (window as any).__visorState;
let newFrontend: VisorFrontend;
// The listeners cleared above are registered again however this
// block ends, so a failed rebuild still receives the next server
// update, and `set_state` keeps reaching the frontend named by
// `__visorState`, which is switched only after the first pass.
try {
const sceneDetailsJson = await wasmView.current.trameTriggerAsync(
'get_visor_scene_details_json'
);
let sceneDetails: VisorSceneDetails;
try {
sceneDetails = new VisorSceneDetails(sceneDetailsJson);
} catch (err) {
if (err instanceof SchemaVersionMismatchError) {
setSchemaMismatchError(err);
return;
}
throw err;
}
const renderer = await WasmRenderer.createAsync(
wasmView.current.vtkScene,
requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation),
wasmView.current.trameTriggerAsync
);
newFrontend = new VisorFrontend(
renderer,
sceneDetails.vtkInfo.sceneGraph,
sceneDetails.appState.scene,
wasmView.current.trameTriggerAsync
);
// Release the frontend being replaced, here and not later: the
// `VtkScene` behind both renderers is the same object across a
// rebuild, so an unreleased subscription stays live and the next
// gesture is reported once per surviving frontend. There is
// deliberately no `await` between the new frontend subscribing (the
// constructor above) and the old one releasing, so the window in
// which two subscriptions coexist contains no suspension point.
if (oldFrontend != null) {
oldFrontend.releaseCameraSettledListener();
}

if (visorArgs.current.darkMode != null) {
// Explicit Dash prop takes precedence over the server's dark_mode value.
sceneDetails.appState.ui.setDarkTheme(visorArgs.current.darkMode);
}
await newFrontend.setAppStateAsync(sceneDetails.appState, false);
(window as any).__visorState = newFrontend;
setSchemaMismatchError(null);
setVisorFrontend(newFrontend); // rebuild/remount React UI
} finally {
wasmView.current.addServerUpdatedListener(onServerUpdateAsync);
wasmView.current.addSetStateListener(onSetStateAsync);
}
const view = wasmView.current;
setTimeout(() => {
view.vtkScene.resizeAsync();
}, 100);
if (perfLogging) {
const handlerMs = performance.now() - t0;
console.log(`[PERF] onServerUpdateAsync total=${handlerMs.toFixed(2)}ms`);
wasmView.current
.trameTriggerAsync('perf_report_server_update', { handlerMs })
.catch(() => {});
}
}

async function onSetStateAsync(payload: any) {
if (wasmView.current == null) {
throw new Error(`wasmView should not be null here`);
}
const newAppState = new VisorAppState(payload?.appState);
if (visorArgs.current.darkMode != null) {
// Explicit Dash prop takes precedence over the theme stored in saved state.
newAppState.ui.setDarkTheme(visorArgs.current.darkMode);
}
const oldFrontend: VisorFrontend = (window as any).__visorState;
await oldFrontend.setAppStateAsync(newAppState, true);
}

return (
<div style={visorContainerStyle}>
{schemaMismatchError != null ? (
Expand Down
19 changes: 8 additions & 11 deletions src/ansys/visor/visor-client/src/CameraSyncReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@ import type {
/**
* The `sync_camera` trigger: the client's half of the server-tracked camera.
*
* This module exists apart from `VisorFrontend` so that it can be tested. A
* `VisorFrontend` cannot be constructed under jest -- it needs a real scene
* graph node, the module-global variable manager and a renderer, and it ends
* in `Object.freeze` -- so a listener body
* written inline there would be pinned by nothing, and the payload shape is
* precisely the part no server-side gate can check. `CameraGestureTracker`
* was split out for the same reason in the previous increment.
* This module is the client-mode reporter, attached by the client-mode hook
* to each renderer it builds, and kept apart so that the listener body and
* the payload shape -- precisely the part no server-side gate can check --
* are tested directly.
*
* What reaches here is already debounced: `addCameraSettledListener` fires
* once per settle window, never once per camera event, and it carries that
Expand Down Expand Up @@ -87,10 +84,10 @@ export function buildSyncCameraPayload(
* Subscribe to settled camera windows and report each one to the server.
*
* Returns the remover from `addCameraSettledListener`, **verbatim**. The
* caller owns it and must call it when the frontend holding it is replaced:
* the `VtkScene` survives a client rebuild, so a frontend that is discarded
* without releasing leaves its subscription live and every later gesture is
* reported once per rebuild that has ever happened. Each of those reports is
* caller owns it and must call it when the renderer it was attached to is
* replaced: the `VtkScene` survives a client rebuild, so a renderer that is
* discarded without releasing leaves its subscription live and every later
* gesture is reported once per rebuild that has ever happened. Each of those reports is
* individually valid, which is why no gate can see the fault.
*
* The sender is required. A frontend with no transport is not a state this
Expand Down
54 changes: 21 additions & 33 deletions src/ansys/visor/visor-client/src/VisorFrontend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { StateInput } from './state/appstate/VisorStateCommon.tsx';
import VisorVtkSceneNode from './state/appstate/vtkInfo/VisorVtkSceneNode.tsx';
import { IRenderer, VisorCameraState } from './renderer/IRenderer';
import type { TrameTriggerSender } from './renderer/IRenderer';
import { attachCameraSyncReporter } from './CameraSyncReporter';
import { Panel_TopRight_Util } from './components/ui-panels/Panel_TopRight_Util.tsx';
import { Panel_TopLeft_Util } from './components/ui-panels/Panel_TopLeft_Util.tsx';
import { OrientationWidget } from './widgets/orientationWidget.ts';
Expand Down Expand Up @@ -84,26 +83,6 @@ export class VisorFrontend {
};
this.domElement = renderer.domElement;
this.getCameraStateAsync = () => renderer.getCameraStateAsync();
// Report each settled camera window to the server. The remover is
// held rather than discarded because the `VtkScene` behind the
// renderer survives a client rebuild: a frontend replaced without
// releasing leaves its subscription live, and every later gesture is
// then reported once per rebuild that has ever happened. Every one of
// those reports is individually valid, so nothing fails -- the record
// is simply written several times and no gate can tell.
//
// Assigned to a `#private` field and exposed through a field-assigned
// method: `Object.freeze(this)` below does not reach `#private` state,
// but it would make a public field assigned after construction throw
// in the browser and in no gate.
this.#cameraSettledRemover = attachCameraSyncReporter(renderer, triggerSender);
this.releaseCameraSettledListener = () => {
const remover = this.#cameraSettledRemover;
// Cleared first, so a second call is a no-op rather than a second
// removal against a map the next frontend now owns.
this.#cameraSettledRemover = null;
remover?.();
};
this.defaultActorColor = [];
// Reports one variable slot's range; errors are logged, not rethrown,
// since the client already applied the range locally.
Expand Down Expand Up @@ -186,12 +165,25 @@ export class VisorFrontend {
return result;
};

// Extended viewer click listener that also triggers geometry picking
renderer.addViewerClickedListener((_nodeId, _ctrlKey, _shiftKey, normX, normY) => {
if (selectionMode !== 'part') {
this.pickGeometryAsync(normX, normY).catch(() => {});
// Extended viewer click listener that also triggers geometry picking.
// The remover is held because the `VtkScene` behind the renderer
// survives a client rebuild: a listener left registered keeps picking
// for a frontend that has been replaced. `#private` because
// `Object.freeze(this)` below makes a public field assigned after
// construction throw.
this.#viewerClickedRemover = renderer.addViewerClickedListener(
(_nodeId, _ctrlKey, _shiftKey, normX, normY) => {
if (selectionMode !== 'part') {
this.pickGeometryAsync(normX, normY).catch(() => {});
}
}
});
);
this.release = () => {
const remover = this.#viewerClickedRemover;
// Cleared first, so a second call is a no-op.
this.#viewerClickedRemover = null;
remover?.();
};
this.sceneGraph = sceneGraph;
this.treeViewUtilPromise = treeViewUtilPromise;
this.panelTopLeftUtilPromise = panelTopLeftUtilPromise;
Expand Down Expand Up @@ -532,14 +524,10 @@ export class VisorFrontend {
}

#unit: string;
#cameraSettledRemover: (() => void) | null;
#viewerClickedRemover: (() => void) | null;
darkMode: boolean;
/**
* Release this frontend's settled-camera subscription. Called by the
* rebuild on the frontend it is replacing, before the replacement is
* handed out. Idempotent.
*/
releaseCameraSettledListener: () => void;
/** Remove every listener this frontend registered on its renderer; a second call does nothing. */
release: () => void;
render: () => Promise<void>;
resizeAsync: () => Promise<void>;
sceneGraph: VisorSceneNodeExtended;
Expand Down
28 changes: 28 additions & 0 deletions src/ansys/visor/visor-client/src/buildFrontendAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { VisorFrontend } from './VisorFrontend.tsx';
import VisorSceneDetails from './state/appstate/VisorSceneDetails.tsx';
import type { StateInput } from './state/appstate/VisorStateCommon.tsx';
import type { IRenderer, TrameTriggerSender } from './renderer/IRenderer';

/** Build and seed a frontend from delivered scene details; throws SchemaVersionMismatchError before any renderer exists. */
export async function buildFrontendAsync(
sceneDetailsJson: StateInput<VisorSceneDetails>,
createRenderer: (sceneDetails: VisorSceneDetails) => Promise<IRenderer>,
triggerSender: TrameTriggerSender,
darkMode: boolean | undefined
): Promise<VisorFrontend> {
const sceneDetails = new VisorSceneDetails(sceneDetailsJson);
const renderer = await createRenderer(sceneDetails);
const frontend = new VisorFrontend(
renderer,
sceneDetails.vtkInfo.sceneGraph,
sceneDetails.appState.scene,
triggerSender
);

if (darkMode != null) {
// Explicit Dash prop takes precedence over the server's dark_mode value.
sceneDetails.appState.ui.setDarkTheme(darkMode);
}
await frontend.setAppStateAsync(sceneDetails.appState, false);
return frontend;
}
Loading
Loading