diff --git a/doc/changelog.d/188.added.md b/doc/changelog.d/188.added.md new file mode 100644 index 00000000..6bfa7901 --- /dev/null +++ b/doc/changelog.d/188.added.md @@ -0,0 +1 @@ +Remote rendering 4.3c: Select parts by clicking a streamed frame diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index 57f7240f..bc90f6cb 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -174,7 +174,9 @@ export class VisorFrontend { this.#viewerClickedRemover = renderer.addViewerClickedListener( (_nodeId, _ctrlKey, _shiftKey, normX, normY) => { if (selectionMode !== 'part') { - this.pickGeometryAsync(normX, normY).catch(() => {}); + this.pickGeometryAsync(normX, normY).catch((err) => + console.error('[VISOR] geometry pick failed', err) + ); } } ); diff --git a/src/ansys/visor/visor-client/src/input/ViewerClickDispatcher.ts b/src/ansys/visor/visor-client/src/input/ViewerClickDispatcher.ts new file mode 100644 index 00000000..079bebf3 --- /dev/null +++ b/src/ansys/visor/visor-client/src/input/ViewerClickDispatcher.ts @@ -0,0 +1,51 @@ +import type { NodeId } from '../renderer/IRenderer'; +import type { CanvasClick } from '../rca/ServerCanvas'; + +/** A viewer-clicked listener: the picked node or null, the click's modifier keys, and its VTK-normalised position. */ +export type ViewerClickedListener = ( + nodeId: NodeId | null, + ctrlKey: boolean, + shiftKey: boolean, + normX: number, + normY: number +) => void; + +/** Runs at most one part pick at a time and gives each pick's result to every viewer-clicked listener once. */ +export class ViewerClickDispatcher { + /** Add listener; returns its remover. */ + addListener(listener: ViewerClickedListener): () => void { + const remover = () => { + this.#listeners.delete(remover); + }; + this.#listeners.set(remover, listener); + return remover; + } + + /** Remove every listener. */ + clear(): void { + this.#listeners.clear(); + } + + /** + * Unless a pick is running, run pick and call every listener once with its + * node and click's keys and position; a click during a pick is dropped. + * A pick that rejects calls no listener, and the rejection propagates. + */ + async dispatchAsync(click: CanvasClick, pick: () => Promise): Promise { + if (this.#pickPending) { + return; + } + this.#pickPending = true; + try { + const nodeId = await pick(); + for (const listener of this.#listeners.values()) { + listener(nodeId, click.ctrlKey, click.shiftKey, click.normX, click.normY); + } + } finally { + this.#pickPending = false; + } + } + + readonly #listeners = new Map<() => void, ViewerClickedListener>(); + #pickPending = false; +} diff --git a/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts new file mode 100644 index 00000000..9f5ab8f7 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts @@ -0,0 +1,115 @@ +import { ServerRenderer, type ServerStateSource } from '../renderer/ServerRenderer'; +import type { CanvasClick, IServerCanvas } from '../rca/ServerCanvas'; + +/** + * A click on the server-drawn view asks the server which part lies under it, + * in render-window device pixels measured on the last frame, origin bottom + * left. One click makes one pick, however many listeners there are, and each + * listener hears its result once. A click while a pick is running is dropped. + * + * The page is 300 x 200 CSS px at device pixel ratio 2, so the frame is + * 600 x 400. Expected values are hand-written literals. + */ + +let originalDevicePixelRatio: number; + +beforeEach(() => { + originalDevicePixelRatio = window.devicePixelRatio; + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }); +}); + +afterEach(() => { + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value: originalDevicePixelRatio, + }); +}); + +/** A state that never holds a camera record. */ +const emptyState: ServerStateSource = { + watch: (_keys, fn) => { + fn(undefined); + return () => {}; + }, +}; + +/** A canvas of 300 x 200 CSS px whose last frame is 600 x 400, and the click callback the renderer registered. */ +function stubCanvas() { + const element = document.createElement('div'); + Object.defineProperty(element, 'clientWidth', { value: 300 }); + Object.defineProperty(element, 'clientHeight', { value: 200 }); + element.getBoundingClientRect = () => + ({ + left: 0, + top: 0, + width: 300, + height: 200, + right: 300, + bottom: 200, + x: 0, + y: 0, + }) as DOMRect; + const clicks: ((click: CanvasClick) => void)[] = []; + const canvas: IServerCanvas = { + element, + frameSize: { width: 600, height: 400 }, + addFrameListener: () => () => {}, + addClickListener: (callback) => { + clicks.push(callback); + return () => {}; + }, + }; + return { canvas, click: (c: CanvasClick) => clicks.forEach((callback) => callback(c)) }; +} + +const CLICK: CanvasClick = { normX: 0.5, normY: 0.25, ctrlKey: false, shiftKey: false }; + +/** Let the pick's promise chain finish. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('ServerRenderer pick', () => { + test('a click sends one pick_part_at in render-window pixels from the frame', async () => { + const { canvas, click } = stubCanvas(); + const sender = jest.fn(async (_name: string, _payload: unknown) => 7 as unknown); + const renderer = new ServerRenderer(canvas, emptyState, sender); + renderer.addViewerClickedListener(jest.fn()); + renderer.addViewerClickedListener(jest.fn()); + + click(CLICK); + await settle(); + + expect(sender.mock.calls).toStrictEqual([['pick_part_at', { x: 300, y: 100 }]]); + }); + + test('every viewer-clicked listener gets the picked node once', async () => { + const { canvas, click } = stubCanvas(); + const sender = jest.fn(async (_name: string, _payload: unknown) => 7 as unknown); + const renderer = new ServerRenderer(canvas, emptyState, sender); + const first = jest.fn(); + const second = jest.fn(); + renderer.addViewerClickedListener(first); + renderer.addViewerClickedListener(second); + + click(CLICK); + await settle(); + + expect(first.mock.calls).toEqual([[7, false, false, 0.5, 0.25]]); + expect(second.mock.calls).toEqual([[7, false, false, 0.5, 0.25]]); + }); + + test('a click while a pick is pending sends nothing', async () => { + const { canvas, click } = stubCanvas(); + const sender = jest.fn( + (_name: string, _payload: unknown) => new Promise(() => {}) + ); + new ServerRenderer(canvas, emptyState, sender); + + click(CLICK); + click(CLICK); + await settle(); + + expect(sender).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts b/src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts index c96ec94c..432b7907 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorFrontendRelease.test.ts @@ -8,6 +8,9 @@ import { deliveredScene } from './deliveredSceneFixture.ts'; * renderer. The `VtkScene` behind a client renderer survives a rebuild, so a * replaced frontend that is never released keeps picking geometry on every * click. A second release removes nothing further. + * + * The same listener runs the geometry pick outside part mode; a pick that + * fails is logged. */ function makeSceneGraphNode() { @@ -63,3 +66,37 @@ describe('VisorFrontend.release', () => { expect(remover).toHaveBeenCalledTimes(1); }); }); + +describe('VisorFrontend geometry pick', () => { + test('a failed geometry pick is logged', async () => { + const error = new Error('x'); + const renderer = { + domElement: document.createElement('div'), + addViewerClickedListener: jest.fn(() => jest.fn()), + clearGeometryHighlight: jest.fn(), + pickGeometryAsync: jest.fn(async () => { + throw error; + }), + }; + const frontend = new VisorFrontend( + renderer as unknown as IRenderer, + makeSceneGraphNode() as unknown as VisorVtkSceneNode, + deliveredScene(), + jest.fn(async () => undefined) + ); + const geometryListener = ( + renderer.addViewerClickedListener.mock.calls[0] as unknown[] + )[0] as (...args: unknown[]) => void; + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + frontend.setSelectionMode('vertex'); + + geometryListener(null, false, false, 0.5, 0.5); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consoleError).toHaveBeenCalledWith('[VISOR] geometry pick failed', error); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts index aeaecd2b..79012e66 100644 --- a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts @@ -7,7 +7,8 @@ import type { TrameTriggerSender, VisorCameraState, } from './IRenderer'; -import type { IServerCanvas } from '../rca/ServerCanvas'; +import type { CanvasClick, IServerCanvas } from '../rca/ServerCanvas'; +import { ViewerClickDispatcher } from '../input/ViewerClickDispatcher'; import { SceneTriggerSends } from './SceneTriggerSends'; /** The trame state key the server writes the camera record to. */ @@ -147,27 +148,22 @@ export class ServerRenderer implements IRenderer { triggerSender: TrameTriggerSender ) { this.#canvas = canvas; + this.#triggerSender = triggerSender; 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); + this.#removeClickListener = canvas.addClickListener(this.#onCanvasClick); } readonly #canvas: IServerCanvas; + readonly #triggerSender: TrameTriggerSender; readonly #sends: SceneTriggerSends; readonly #unwatch: () => void; + readonly #removeClickListener: () => 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 - >(); + readonly #viewerClicks = new ViewerClickDispatcher(); #crossSectionVisible = false; #boundingBoxVisible = false; #edgesVisible = false; @@ -194,6 +190,36 @@ export class ServerRenderer implements IRenderer { this.#fireCameraChanged(); }; + /** Pick the part under a click on the last frame, then tell every viewer-clicked listener once. */ + #onCanvasClick = (click: CanvasClick): void => { + const frameSize = this.#canvas.frameSize; + if (frameSize == null) { + console.error('[VISOR] click dropped: no frame drawn yet'); + return; + } + // The frame is the render window as last drawn, in device pixels. + const x = ServerRenderer.#framePixel(click.normX, frameSize.width); + const y = ServerRenderer.#framePixel(click.normY, frameSize.height); + this.#viewerClicks + .dispatchAsync(click, async () => + ServerRenderer.#parsePickResult(await this.#triggerSender('pick_part_at', { x, y })) + ) + .catch((err) => console.error('[VISOR] pick_part_at failed', err)); + }; + + /** The render-window pixel under a normalised coordinate on an axis of the given size, clamped to the window. */ + static #framePixel(norm: number, size: number): number { + return Math.min(Math.max(Math.floor(norm * size), 0), size - 1); + } + + /** Read a pick_part_at result: the node id, or null for no part; throws TypeError otherwise. */ + static #parsePickResult(value: unknown): NodeId | null { + if (value === null || Number.isInteger(value)) { + return value as NodeId | null; + } + throw new TypeError(`pick_part_at result is not a node id or null: ${String(value)}`); + } + /** Write one camera field and tell the camera listeners. */ #writeCamera(field: K, value: CameraRecord[K]): void { this.#camera.set(field, value); @@ -249,8 +275,9 @@ export class ServerRenderer implements IRenderer { dispose(): void { this.#unwatch(); + this.#removeClickListener(); this.#cameraChangedListeners.clear(); - this.#viewerClickedListeners.clear(); + this.#viewerClicks.clear(); } // ---- Camera --------------------------------------------------------------- @@ -306,7 +333,7 @@ export class ServerRenderer implements IRenderer { return this.#canvas.addFrameListener(callback); } - /** Held, but never called: no click picks a part in server rendering yet. */ + /** Add a listener called once per click with the part pick_part_at found under it, or null; returns its remover. */ addViewerClickedListener( callback: ( nodeId: NodeId | null, @@ -316,11 +343,7 @@ export class ServerRenderer implements IRenderer { normY: number ) => void ): () => void { - const remover = () => { - this.#viewerClickedListeners.delete(remover); - }; - this.#viewerClickedListeners.set(remover, callback); - return remover; + return this.#viewerClicks.addListener(callback); } // ---- Per-part visual state ----------------------------------------------- diff --git a/src/ansys/visor/visor-client/src/wasm/VtkScene.js b/src/ansys/visor/visor-client/src/wasm/VtkScene.js index f679c455..6adf453c 100644 --- a/src/ansys/visor/visor-client/src/wasm/VtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/VtkScene.js @@ -7,6 +7,7 @@ */ import CameraGestureTracker from './CameraGestureTracker.js'; import { MouseButtonState } from '../input/MouseButtonState'; +import { ViewerClickDispatcher } from '../input/ViewerClickDispatcher'; export default class VtkScene { /** @@ -126,7 +127,7 @@ export default class VtkScene { remover(); } this.#cameraChangedListeners.clear(); - this.#viewerClickedListeners.clear(); + this.#viewerClicks.clear(); this.#frameRenderedListeners.clear(); this.#cameraGestureTracker?.dispose(); }; @@ -134,8 +135,7 @@ export default class VtkScene { #cameraGestureTracker = null; /**@type{Mapvoid>}*/ #userObserverRemovers = new Map(); - /**@type{Mapvoid>}*/ - #viewerClickedListeners = new Map(); + #viewerClicks = new ViewerClickDispatcher(); /**@type{Mapvoid>}*/ #cameraChangedListeners = new Map(); /**@type{Mapvoid>}*/ @@ -176,9 +176,7 @@ export default class VtkScene { * @return {()=>void} */ addViewerClickedListener = (handler) => { - const remover = () => this.#viewerClickedListeners.delete(remover); - this.#viewerClickedListeners.set(remover, handler); - return remover; + return this.#viewerClicks.addListener(handler); }; /** * Pick geometry at the last clicked position. @@ -359,10 +357,8 @@ export default class VtkScene { */ #setupPicker = () => { const { renderer, interactor, picker, canvasDiv } = this; - const viewerClickedListeners = this.#viewerClickedListeners; /////////////////////////////////////////// /////////////////////////////////////////// - let pickingPending = false; /**@type{number|null}*/ let lastMouseX = null; /**@type{number|null}*/ @@ -377,7 +373,7 @@ export default class VtkScene { }); canvasDiv.addEventListener('mouseup', async (e) => { try { - if (lastMouseX == null || lastMouseY == null || pickingPending) { + if (lastMouseX == null || lastMouseY == null) { return; } else { const margin = 0; @@ -387,20 +383,22 @@ export default class VtkScene { return; } } - pickingPending = true; const rect = canvasDiv.getBoundingClientRect(); - const normX = (e.clientX - rect.left) / rect.width; - const normY_vtk = 1.0 - (e.clientY - rect.top) / rect.height; - const [x, y] = await interactor.GetEventPosition(); - this.#lastPickDisplayPos = [x, y]; - const found = await picker.Pick([x, y, 0], renderer); - const actor = found ? await picker.getActor() : null; - for (const callback of viewerClickedListeners.values()) { - callback(actor?.id ?? null, e.ctrlKey, e.shiftKey, normX, normY_vtk); - } + const click = { + normX: (e.clientX - rect.left) / rect.width, + normY: 1.0 - (e.clientY - rect.top) / rect.height, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + }; + await this.#viewerClicks.dispatchAsync(click, async () => { + const [x, y] = await interactor.GetEventPosition(); + this.#lastPickDisplayPos = [x, y]; + const found = await picker.Pick([x, y, 0], renderer); + const actor = found ? await picker.getActor() : null; + return actor?.id ?? null; + }); } finally { lastMouseX = lastMouseY = null; - pickingPending = false; } }); };