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/188.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 4.3c: Select parts by clicking a streamed frame
4 changes: 3 additions & 1 deletion src/ansys/visor/visor-client/src/VisorFrontend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
}
);
Expand Down
51 changes: 51 additions & 0 deletions src/ansys/visor/visor-client/src/input/ViewerClickDispatcher.ts
Original file line number Diff line number Diff line change
@@ -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<NodeId | null>): Promise<void> {
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;
}
115 changes: 115 additions & 0 deletions src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>(() => {})
);
new ServerRenderer(canvas, emptyState, sender);

click(CLICK);
click(CLICK);
await settle();

expect(sender).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}
});
});
59 changes: 41 additions & 18 deletions src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand All @@ -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<K extends keyof CameraRecord>(field: K, value: CameraRecord[K]): void {
this.#camera.set(field, value);
Expand Down Expand Up @@ -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 ---------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand All @@ -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 -----------------------------------------------
Expand Down
Loading
Loading