From 9243430d7828e44ec4cd0b64989df176cc70cce1 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 22 Oct 2026 09:36:22 -0700 Subject: [PATCH 1/4] feat: select parts by clicking a streamed frame --- .../visor/visor-client/src/VisorFrontend.tsx | 4 +- .../src/input/ViewerClickDispatcher.ts | 51 +++++++++ .../src/jest-tests/ServerRendererPick.test.ts | 106 ++++++++++++++++++ .../jest-tests/VisorFrontendRelease.test.ts | 37 ++++++ .../src/renderer/ServerRenderer.ts | 61 +++++++--- .../visor/visor-client/src/wasm/VtkScene.js | 38 +++---- 6 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 src/ansys/visor/visor-client/src/input/ViewerClickDispatcher.ts create mode 100644 src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts 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..85ee00d4 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts @@ -0,0 +1,106 @@ +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..dce44184 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..9b4d418f 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,38 @@ 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 +277,9 @@ export class ServerRenderer implements IRenderer { dispose(): void { this.#unwatch(); + this.#removeClickListener(); this.#cameraChangedListeners.clear(); - this.#viewerClickedListeners.clear(); + this.#viewerClicks.clear(); } // ---- Camera --------------------------------------------------------------- @@ -306,7 +335,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 +345,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; } }); }; From d3305d604495826d635cd39aa6f498ce45ddebb0 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:44:32 +0000 Subject: [PATCH 2/4] chore: adding changelog file 188.added.md [dependabot-skip] --- doc/changelog.d/188.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/188.added.md diff --git a/doc/changelog.d/188.added.md b/doc/changelog.d/188.added.md new file mode 100644 index 00000000..4b31adce --- /dev/null +++ b/doc/changelog.d/188.added.md @@ -0,0 +1 @@ +[Remote rendering 4.3c] Select parts by clicking a streamed frame From 018c069f43f02cd71e452d3c39266b5ece1436ca Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:46:07 +0000 Subject: [PATCH 3/4] chore: adding changelog file 188.added.md [dependabot-skip] --- doc/changelog.d/188.added.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changelog.d/188.added.md b/doc/changelog.d/188.added.md index 4b31adce..6bfa7901 100644 --- a/doc/changelog.d/188.added.md +++ b/doc/changelog.d/188.added.md @@ -1 +1 @@ -[Remote rendering 4.3c] Select parts by clicking a streamed frame +Remote rendering 4.3c: Select parts by clicking a streamed frame From 7f2b24a4a98e9230a0a60daba1cfe1338fc92fe0 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Thu, 29 Oct 2026 14:50:00 -0700 Subject: [PATCH 4/4] fix: prettier for 4.3c --- .../src/jest-tests/ServerRendererPick.test.ts | 11 ++++++++++- .../src/jest-tests/VisorFrontendRelease.test.ts | 6 +++--- .../visor/visor-client/src/renderer/ServerRenderer.ts | 4 +--- 3 files changed, 14 insertions(+), 7 deletions(-) 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 index 85ee00d4..9f5ab8f7 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts @@ -39,7 +39,16 @@ function stubCanvas() { 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; + ({ + 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, 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 dce44184..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 @@ -84,9 +84,9 @@ describe('VisorFrontend geometry pick', () => { deliveredScene(), jest.fn(async () => undefined) ); - const geometryListener = (renderer.addViewerClickedListener.mock.calls[0] as unknown[])[0] as ( - ...args: unknown[] - ) => void; + 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'); diff --git a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts index 9b4d418f..79012e66 100644 --- a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts @@ -202,9 +202,7 @@ export class ServerRenderer implements IRenderer { const y = ServerRenderer.#framePixel(click.normY, frameSize.height); this.#viewerClicks .dispatchAsync(click, async () => - ServerRenderer.#parsePickResult( - await this.#triggerSender('pick_part_at', { x, y }) - ) + ServerRenderer.#parsePickResult(await this.#triggerSender('pick_part_at', { x, y })) ) .catch((err) => console.error('[VISOR] pick_part_at failed', err)); };