diff --git a/doc/changelog.d/186.added.md b/doc/changelog.d/186.added.md new file mode 100644 index 00000000..079ac2d0 --- /dev/null +++ b/doc/changelog.d/186.added.md @@ -0,0 +1 @@ +Remote rendering 4.3a: forward mouse and wheel input to server diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index da7a2d13..7ca9e46b 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -63,6 +63,7 @@ def __init__( self._camera_publisher = camera_publisher self._bounding_box_visible = False super().__init__() + self._orientation_widget.animate_off() # ------------------------------------------------------------------ # Server-only surface diff --git a/src/ansys/visor/viewer/vtk/widgets/visor_orientation.py b/src/ansys/visor/viewer/vtk/widgets/visor_orientation.py index 206b864e..2b692204 100644 --- a/src/ansys/visor/viewer/vtk/widgets/visor_orientation.py +++ b/src/ansys/visor/viewer/vtk/widgets/visor_orientation.py @@ -47,6 +47,10 @@ def is_on(self): """Return whether the widget is on or not.""" return self.__widget.GetEnabled() == 1 + def animate_off(self) -> None: + """Snap the camera on an axis click instead of animating it over interactor timer events.""" + self.__widget.AnimateOff() + def register_with_local_view(self, local_view: LocalView): """Register the widget with the local view.""" self.__widget_wasm_id = local_view.register_vtk_object(self.__widget) diff --git a/src/ansys/visor/visor-client/src/input/ClickTracker.ts b/src/ansys/visor/visor-client/src/input/ClickTracker.ts new file mode 100644 index 00000000..4da80833 --- /dev/null +++ b/src/ansys/visor/visor-client/src/input/ClickTracker.ts @@ -0,0 +1,24 @@ +/** Recognises a click: a press and a release at the same client position with no leave or blur between them. */ +export class ClickTracker { + /** Record a press at (clientX, clientY). */ + press(clientX: number, clientY: number): void { + this.#pressX = clientX; + this.#pressY = clientY; + } + + /** Forget any press; called on mouseout and blur. */ + reset(): void { + this.#pressX = null; + this.#pressY = null; + } + + /** True if a release at (clientX, clientY) completes a click; always forgets the press. */ + release(clientX: number, clientY: number): boolean { + const isClick = this.#pressX === clientX && this.#pressY === clientY; + this.reset(); + return isClick; + } + + #pressX: number | null = null; + #pressY: number | null = null; +} diff --git a/src/ansys/visor/visor-client/src/input/MouseButtonState.ts b/src/ansys/visor/visor-client/src/input/MouseButtonState.ts new file mode 100644 index 00000000..2adf9ef3 --- /dev/null +++ b/src/ansys/visor/visor-client/src/input/MouseButtonState.ts @@ -0,0 +1,46 @@ +/** VTK button a gesture drives: 0 left (rotate), 1 middle (pan), 2 right (zoom). */ +export type VtkButton = 0 | 1 | 2; + +/** Which physical buttons are held, and the one VTK button they drive together. */ +export class MouseButtonState { + /** Record that DOM button (0 left, 1 middle, 2 right) went down or up; other values are ignored. */ + setButton(button: number, down: boolean): void { + if (button === 0) { + this.#left = down; + } else if (button === 1) { + this.#middle = down; + } else if (button === 2) { + this.#right = down; + } + } + + /** Whether DOM button (0 left, 1 middle, 2 right) is held; false for any other value. */ + isDown(button: number): boolean { + if (button === 0) { + return this.#left; + } else if (button === 1) { + return this.#middle; + } else if (button === 2) { + return this.#right; + } + return false; + } + + /** The VTK button the held buttons drive: left+right 1, right 2, left 0, middle 0, none null. */ + effectiveButton(): VtkButton | null { + if (this.#left && this.#right) { + return 1; + } else if (this.#right) { + return 2; + } else if (this.#left) { + return 0; + } else if (this.#middle) { + return 0; + } + return null; + } + + #left = false; + #middle = false; + #right = false; +} diff --git a/src/ansys/visor/visor-client/src/jest-tests/MouseButtonState.test.ts b/src/ansys/visor/visor-client/src/jest-tests/MouseButtonState.test.ts new file mode 100644 index 00000000..9cb4bd2e --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/MouseButtonState.test.ts @@ -0,0 +1,59 @@ +import { MouseButtonState } from '../input/MouseButtonState'; + +/** + * The held DOM buttons map to the one VTK button they drive together: a + * middle press drives rotate (0), and pan (1) is the left and right chord. + * + * Expected values are hand-written literals. + */ + +/** A state with each DOM button in `pressed` held, in order. */ +function holding(...pressed: number[]): MouseButtonState { + const state = new MouseButtonState(); + for (const button of pressed) { + state.setButton(button, true); + } + return state; +} + +describe('MouseButtonState', () => { + test('left alone drives 0', () => { + expect(holding(0).effectiveButton()).toBe(0); + }); + + test('right alone drives 2', () => { + expect(holding(2).effectiveButton()).toBe(2); + }); + + test('left and right drive 1', () => { + expect(holding(0, 2).effectiveButton()).toBe(1); + }); + + test('middle alone drives 0', () => { + expect(holding(1).effectiveButton()).toBe(0); + }); + + test('left and middle drive 0', () => { + expect(holding(0, 1).effectiveButton()).toBe(0); + }); + + test('nothing held drives null', () => { + expect(holding().effectiveButton()).toBeNull(); + }); + + test('releasing every held button drives null', () => { + const state = holding(0, 1, 2); + + state.setButton(0, false); + state.setButton(1, false); + state.setButton(2, false); + + expect(state.effectiveButton()).toBeNull(); + }); + + test('isDown reports only the button held', () => { + const state = holding(1); + + expect([state.isDown(0), state.isDown(1), state.isDown(2)]).toEqual([false, true, false]); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/ServerCanvasEvents.test.ts b/src/ansys/visor/visor-client/src/jest-tests/ServerCanvasEvents.test.ts new file mode 100644 index 00000000..2999e2b5 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/ServerCanvasEvents.test.ts @@ -0,0 +1,267 @@ +import { ServerCanvas, type CanvasClick } from '../rca/ServerCanvas'; + +/** + * The server canvas forwards mouse and wheel input to its area as vtk-js + * interaction events in element pixels with y measured up from the bottom, + * drives the same VTK button local mode does for each held combination, and + * reports a left click at one point as a click. + * + * The element sits at (5, 10) and is 200 x 100, so a position that left out + * the element's offset differs from one that kept it. jsdom has no layout and + * no `ResizeObserver`; both are replaced by doubles. + * + * Expected values are hand-written literals. + */ + +const globals = globalThis as unknown as { ResizeObserver: unknown }; +let originalResizeObserver: unknown; + +beforeEach(() => { + originalResizeObserver = globals.ResizeObserver; + globals.ResizeObserver = class { + observe() {} + disconnect() {} + }; +}); + +afterEach(() => { + globals.ResizeObserver = originalResizeObserver; +}); + +/** A session double that records every call. */ +function makeSession() { + return { + call: jest.fn(async (_method: string, _args: unknown[]) => undefined), + subscribe: () => ({ unsubscribe: () => undefined }), + }; +} + +/** A canvas over a recording session whose element is 200 x 100 at (5, 10). */ +function makeCanvas() { + const session = makeSession(); + const canvas = new ServerCanvas(session, 'view'); + canvas.element.getBoundingClientRect = () => + ({ + left: 5, + top: 10, + width: 200, + height: 100, + right: 205, + bottom: 110, + x: 5, + y: 10, + }) as DOMRect; + return { session, canvas }; +} + +/** Dispatch a mouse event of type with DOM button at client (clientX, clientY). */ +function mouse( + canvas: ServerCanvas, + type: string, + button: number, + clientX: number, + clientY: number +) { + const event = new MouseEvent(type, { + button, + clientX, + clientY, + bubbles: true, + cancelable: true, + }); + canvas.element.dispatchEvent(event); + return event; +} + +/** The interaction events sent so far, in order. */ +function sentEvents(session: ReturnType): Record[] { + return session.call.mock.calls.map(([, args]) => args[2] as Record); +} + +/** The interaction event types sent so far, in order. */ +function sentTypes(session: ReturnType): unknown[] { + return sentEvents(session).map((event) => event.type); +} + +describe('ServerCanvas events', () => { + test('a left press with no button held sends a MouseMove, three releases, then the press with y flipped', () => { + const { session, canvas } = makeCanvas(); + + mouse(canvas, 'mousedown', 0, 15, 30); + + const modifiers = { ctrlKey: false, altKey: false, shiftKey: false }; + expect(session.call.mock.calls).toEqual([ + [ + 'trame.rca.event', + [ + 'view', + 'ServerCanvas', + { type: 'MouseMove', x: 10, y: 80, w: 200, h: 100, ...modifiers }, + ], + ], + [ + 'trame.rca.event', + [ + 'view', + 'ServerCanvas', + { type: 'LeftButtonRelease', x: 10, y: 80, w: 200, h: 100, ...modifiers }, + ], + ], + [ + 'trame.rca.event', + [ + 'view', + 'ServerCanvas', + { type: 'MiddleButtonRelease', x: 10, y: 80, w: 200, h: 100, ...modifiers }, + ], + ], + [ + 'trame.rca.event', + [ + 'view', + 'ServerCanvas', + { type: 'RightButtonRelease', x: 10, y: 80, w: 200, h: 100, ...modifiers }, + ], + ], + [ + 'trame.rca.event', + [ + 'view', + 'ServerCanvas', + { type: 'LeftButtonPress', x: 10, y: 80, w: 200, h: 100, ...modifiers }, + ], + ], + ]); + }); + + test('a right press while left is held sends a middle press', () => { + const { session, canvas } = makeCanvas(); + mouse(canvas, 'mousedown', 0, 15, 30); + session.call.mockClear(); + + mouse(canvas, 'mousedown', 2, 15, 30); + + expect(sentTypes(session)).toEqual([ + 'LeftButtonRelease', + 'MiddleButtonRelease', + 'RightButtonRelease', + 'MiddleButtonPress', + ]); + }); + + test('mousemove is sent only while a button is held', () => { + const { session, canvas } = makeCanvas(); + + mouse(canvas, 'mousemove', 0, 25, 40); + expect(session.call).not.toHaveBeenCalled(); + + mouse(canvas, 'mousedown', 0, 15, 30); + session.call.mockClear(); + mouse(canvas, 'mousemove', 0, 25, 40); + + expect(sentEvents(session)).toEqual([ + { + type: 'MouseMove', + x: 20, + y: 70, + w: 200, + h: 100, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + ]); + }); + + test('a wheel sends one MouseWheel step and keeps the page from scrolling', () => { + const { session, canvas } = makeCanvas(); + const wheel = new WheelEvent('wheel', { + deltaY: 120, + clientX: 15, + clientY: 30, + bubbles: true, + cancelable: true, + }); + + canvas.element.dispatchEvent(wheel); + + expect(sentEvents(session)).toEqual([ + { + type: 'MouseWheel', + x: 10, + y: 80, + w: 200, + h: 100, + ctrlKey: false, + altKey: false, + shiftKey: false, + spinY: 1, + }, + ]); + expect(wheel.defaultPrevented).toBe(true); + }); + + test('mouseout releases the button the server holds and presses none', () => { + const { session, canvas } = makeCanvas(); + mouse(canvas, 'mousedown', 0, 15, 30); + mouse(canvas, 'mousedown', 2, 15, 30); + session.call.mockClear(); + + mouse(canvas, 'mouseout', 0, 15, 30); + expect(sentTypes(session)).toEqual(['MiddleButtonRelease']); + + session.call.mockClear(); + mouse(canvas, 'mousemove', 0, 25, 40); + expect(session.call).not.toHaveBeenCalled(); + }); + + test('contextmenu is prevented', () => { + const { canvas } = makeCanvas(); + + const event = mouse(canvas, 'contextmenu', 2, 15, 30); + + expect(event.defaultPrevented).toBe(true); + }); + + test('a left press and release at one point fires one click', () => { + const { canvas } = makeCanvas(); + const clicks: CanvasClick[] = []; + canvas.addClickListener((click) => clicks.push(click)); + + mouse(canvas, 'mousedown', 0, 15, 30); + mouse(canvas, 'mouseup', 0, 15, 30); + + expect(clicks).toEqual([{ normX: 0.05, normY: 0.8, ctrlKey: false, shiftKey: false }]); + }); + + test('a press and release at different points fires no click', () => { + const { canvas } = makeCanvas(); + const listener = jest.fn(); + canvas.addClickListener(listener); + + mouse(canvas, 'mousedown', 0, 15, 30); + mouse(canvas, 'mouseup', 0, 16, 30); + + expect(listener).not.toHaveBeenCalled(); + }); + + test('a right press and release at one point fires no click', () => { + const { canvas } = makeCanvas(); + const listener = jest.fn(); + canvas.addClickListener(listener); + + mouse(canvas, 'mousedown', 2, 15, 30); + mouse(canvas, 'mouseup', 2, 15, 30); + + expect(listener).not.toHaveBeenCalled(); + }); + + test('dispose stops event forwarding', () => { + const { session, canvas } = makeCanvas(); + + canvas.dispose(); + mouse(canvas, 'mousedown', 0, 15, 30); + + expect(session.call).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/VtkSceneMouse.test.js b/src/ansys/visor/visor-client/src/jest-tests/VtkSceneMouse.test.js new file mode 100644 index 00000000..4a04cac3 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/VtkSceneMouse.test.js @@ -0,0 +1,108 @@ +import VtkScene from '../wasm/VtkScene.js'; + +/** + * Local mode remaps mouse buttons on their way to the VTK canvas, and stops + * every press it remaps. The picker listens on the same element in the bubble + * phase, so it sees only the events the remap lets through: a left click + * selects, a right click does not, and a release with nothing held must + * always reach it. + * + * The scene is built over a fake remote scene: its canvas sits inside its + * canvas div, and its renderer, camera, interactor and picker are doubles. + * The picker reports actor 7 for any pick. `requestAnimationFrame` does + * nothing, so the frame-rate monitor never runs. + * + * Expected values are hand-written literals. + */ + +let originalRequestAnimationFrame; +/**@type{VtkScene|null}*/ +let scene = null; + +beforeEach(() => { + originalRequestAnimationFrame = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = () => 0; +}); + +afterEach(() => { + scene?.clearObserversAndEventListeners(); + scene = null; + globalThis.requestAnimationFrame = originalRequestAnimationFrame; +}); + +/** A scene over a fake remote scene whose canvas div is 200 x 100 at (0, 0). */ +async function makeScene() { + const canvasDiv = document.createElement('div'); + const canvas = document.createElement('canvas'); + canvasDiv.appendChild(canvas); + canvasDiv.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 200, + height: 100, + right: 200, + bottom: 100, + x: 0, + y: 0, + }); + const camera = { observe: () => undefined }; + const picker = { Pick: async () => true, getActor: async () => ({ id: 7 }) }; + const interactor = { GetPicker: async () => picker, GetEventPosition: async () => [20, 70] }; + const remoteVtkScene = { + renderer: { GetActiveCamera: async () => camera }, + renderWindow: { GetInteractor: async () => interactor }, + canvasDiv, + canvas, + }; + scene = await VtkScene.getInstanceAsync(null, remoteVtkScene); + return { scene, canvas }; +} + +/** Dispatch a bubbling mouse event of type with DOM button on target at client (clientX, clientY). */ +function mouse(target, type, button, clientX, clientY) { + target.dispatchEvent( + new MouseEvent(type, { button, clientX, clientY, bubbles: true, cancelable: true }) + ); +} + +/** Let the picker's asynchronous release handler finish. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('VtkScene mouse', () => { + test('a left press and release at one point reaches the picker once', async () => { + const { scene, canvas } = await makeScene(); + const listener = jest.fn(); + scene.addViewerClickedListener(listener); + + mouse(canvas, 'mousedown', 0, 20, 30); + mouse(canvas, 'mouseup', 0, 20, 30); + await settle(); + + expect(listener.mock.calls).toEqual([[7, false, false, 0.1, 0.7]]); + }); + + test('a right press and release at one point does not reach the picker', async () => { + const { scene, canvas } = await makeScene(); + const listener = jest.fn(); + scene.addViewerClickedListener(listener); + + mouse(canvas, 'mousedown', 2, 20, 30); + mouse(canvas, 'mouseup', 2, 20, 30); + await settle(); + + expect(listener).not.toHaveBeenCalled(); + }); + + test('a left and right press drives button 1 on the VTK canvas', async () => { + const { canvas } = await makeScene(); + const pressed = []; + canvas.addEventListener('mousedown', (e) => pressed.push(e.button)); + + mouse(canvas, 'mousedown', 0, 20, 30); + mouse(canvas, 'mousedown', 2, 20, 30); + + expect(pressed[pressed.length - 1]).toBe(1); + }); +}); diff --git a/src/ansys/visor/visor-client/src/rca/ServerCanvas.ts b/src/ansys/visor/visor-client/src/rca/ServerCanvas.ts index 7f043766..810f3180 100644 --- a/src/ansys/visor/visor-client/src/rca/ServerCanvas.ts +++ b/src/ansys/visor/visor-client/src/rca/ServerCanvas.ts @@ -1,11 +1,17 @@ +import { ClickTracker } from '../input/ClickTracker'; +import { MouseButtonState } from '../input/MouseButtonState'; + /** The RCA area name; must equal RCA_AREA_NAME in app/trame/server_app.py. */ export const RCA_AREA_NAME = 'view'; /** The trame-rca topic every area's frames are published on. */ const STREAM_TOPIC = 'trame.rca.topic.stream'; -/** The origin this canvas reports in its size messages. */ -const SIZE_ORIGIN = 'ServerCanvas'; +/** The origin this canvas reports in its size and interaction messages. */ +const MESSAGE_ORIGIN = 'ServerCanvas'; + +/** The vtk-js event name prefix for each VTK button. */ +const BUTTON_EVENT_PREFIXES = ['Left', 'Middle', 'Right'] as const; /** The part of a wslink session the canvas uses. */ export type RcaSession = { @@ -19,17 +25,39 @@ export type RcaSession = { /** Pixel size of the last frame drawn. */ export type FrameSize = Readonly<{ width: number; height: number }>; +/** A click on the canvas in VTK-normalised coordinates (origin bottom-left). */ +export type CanvasClick = Readonly<{ + normX: number; + normY: number; + ctrlKey: boolean; + shiftKey: boolean; +}>; + /** What ServerRenderer needs from the canvas. */ export interface IServerCanvas { readonly element: HTMLDivElement; readonly frameSize: FrameSize | null; addFrameListener(callback: (fps: number) => void): () => void; + addClickListener(callback: (click: CanvasClick) => void): () => void; } /** One message on the stream topic: an encoded frame for one area. */ type StreamMessage = Readonly<{ name?: unknown; content?: unknown }>; -/** Shows the frames the server streams for one RCA area, and reports the area's size to the server. */ +/** One vtk-js interaction event: element pixels, origin bottom-left. */ +type InteractionEvent = { + type: string; + x: number; + y: number; + w: number; + h: number; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + spinY?: number; +}; + +/** Shows the frames the server streams for one RCA area, reports the area's size, and forwards mouse and wheel input to it. */ export class ServerCanvas implements IServerCanvas { /** Subscribe to the area's frame stream on session, draw each frame into element, and report element's size. */ constructor(session: RcaSession, areaName: string) { @@ -47,6 +75,14 @@ export class ServerCanvas implements IServerCanvas { this.element = element; this.#image = image; + element.addEventListener('mousedown', this.#onMouseDown); + element.addEventListener('mouseup', this.#onMouseUp); + element.addEventListener('mousemove', this.#onMouseMove); + element.addEventListener('mouseout', this.#onMouseOut); + element.addEventListener('blur', this.#onBlur); + element.addEventListener('wheel', this.#onWheel, { passive: false }); + element.addEventListener('contextmenu', this.#onContextMenu); + this.#subscription = session.subscribe(STREAM_TOPIC, async (args) => this.#onStream(args)); // The first non-zero observation is what starts the stream: the server // renders a frame for each size it is sent. @@ -71,13 +107,31 @@ export class ServerCanvas implements IServerCanvas { return remover; } + /** Call callback once per left click (press and release at one point); returns its remover. */ + addClickListener(callback: (click: CanvasClick) => void): () => void { + const remover = () => { + this.#clickListeners.delete(remover); + }; + this.#clickListeners.set(remover, callback); + return remover; + } + /** Unsubscribe, stop observing the size, remove DOM listeners and revoke the current frame URL. */ dispose(): void { this.#subscription.unsubscribe(); this.#resizeObserver.disconnect(); this.#image.removeEventListener('load', this.#onImageLoad); this.#image.removeEventListener('error', this.#onImageError); + const element = this.element; + element.removeEventListener('mousedown', this.#onMouseDown); + element.removeEventListener('mouseup', this.#onMouseUp); + element.removeEventListener('mousemove', this.#onMouseMove); + element.removeEventListener('mouseout', this.#onMouseOut); + element.removeEventListener('blur', this.#onBlur); + element.removeEventListener('wheel', this.#onWheel); + element.removeEventListener('contextmenu', this.#onContextMenu); this.#frameListeners.clear(); + this.#clickListeners.clear(); this.#revokeFrameUrl(); } @@ -88,6 +142,9 @@ export class ServerCanvas implements IServerCanvas { readonly #resizeObserver: ResizeObserver; readonly #frameListeners = new Map<() => void, (fps: number) => void>(); readonly #frameTimes: number[] = []; + readonly #clickListeners = new Map<() => void, (click: CanvasClick) => void>(); + readonly #buttons = new MouseButtonState(); + readonly #clickTracker = new ClickTracker(); #frameSize: FrameSize | null = null; #frameUrl: string | null = null; @@ -154,8 +211,116 @@ export class ServerCanvas implements IServerCanvas { } const p = window.devicePixelRatio ?? 1; this.#session - .call('trame.rca.size', [this.#areaName, SIZE_ORIGIN, { w, h, p }]) + .call('trame.rca.size', [this.#areaName, MESSAGE_ORIGIN, { w, h, p }]) .catch((err) => console.error('[VISOR] trame.rca.size send failed', err)); } }; + + /** Forward the new button state, first moving to the press point when no button was held, so a widget under the pointer is hovered; a left press may start a click. */ + #onMouseDown = (e: MouseEvent): void => { + if (this.#buttons.effectiveButton() == null) { + this.#sendInteraction(this.#interactionEvent('MouseMove', e)); + } + this.#buttons.setButton(e.button, true); + this.#sendButtons(e); + if (e.button === 0) { + this.#clickTracker.press(e.clientX, e.clientY); + } + }; + + /** Forward the new button state; a release where the press was completes a click. */ + #onMouseUp = (e: MouseEvent): void => { + this.#buttons.setButton(e.button, false); + this.#sendButtons(e); + if (this.#clickTracker.release(e.clientX, e.clientY)) { + this.#fireClick(e); + } + }; + + /** Forward pointer motion while a button drives a gesture. */ + #onMouseMove = (e: MouseEvent): void => { + if (this.#buttons.effectiveButton() == null) { + return; + } + this.#sendInteraction(this.#interactionEvent('MouseMove', e)); + }; + + /** Release the button the server holds and forget every held button, so no drag outlives the pointer. */ + #onMouseOut = (e: MouseEvent): void => { + this.#clickTracker.reset(); + const effective = this.#buttons.effectiveButton(); + if (effective != null) { + this.#sendInteraction( + this.#interactionEvent(`${BUTTON_EVENT_PREFIXES[effective]}ButtonRelease`, e) + ); + } + for (const button of [0, 1, 2]) { + this.#buttons.setButton(button, false); + } + }; + + #onBlur = (): void => { + this.#clickTracker.reset(); + }; + + /** Send one wheel step per DOM wheel event and keep the page from scrolling under the view. */ + #onWheel = (e: WheelEvent): void => { + e.preventDefault(); + this.#sendInteraction({ + ...this.#interactionEvent('MouseWheel', e), + spinY: Math.sign(e.deltaY), + }); + }; + + #onContextMenu = (e: MouseEvent): void => { + e.preventDefault(); + }; + + /** Release all three VTK buttons, then press the one the held buttons drive, if any. */ + #sendButtons(e: MouseEvent): void { + for (const prefix of BUTTON_EVENT_PREFIXES) { + this.#sendInteraction(this.#interactionEvent(`${prefix}ButtonRelease`, e)); + } + const effective = this.#buttons.effectiveButton(); + if (effective != null) { + this.#sendInteraction( + this.#interactionEvent(`${BUTTON_EVENT_PREFIXES[effective]}ButtonPress`, e) + ); + } + } + + /** An event of type at e's position in element pixels, y measured up from the element's bottom. */ + #interactionEvent(type: string, e: MouseEvent): InteractionEvent { + const rect = this.element.getBoundingClientRect(); + return { + type, + x: Math.round(e.clientX - rect.left), + y: Math.round(rect.height - (e.clientY - rect.top)), + w: Math.round(rect.width), + h: Math.round(rect.height), + ctrlKey: e.ctrlKey, + altKey: e.altKey, + shiftKey: e.shiftKey, + }; + } + + #sendInteraction(event: InteractionEvent): void { + this.#session + .call('trame.rca.event', [this.#areaName, MESSAGE_ORIGIN, event]) + .catch((err) => console.error('[VISOR] trame.rca.event send failed', err)); + } + + /** Tell every click listener where the click landed, normalised with the origin bottom-left. */ + #fireClick(e: MouseEvent): void { + const rect = this.element.getBoundingClientRect(); + const click: CanvasClick = { + normX: (e.clientX - rect.left) / rect.width, + normY: 1.0 - (e.clientY - rect.top) / rect.height, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + }; + for (const callback of this.#clickListeners.values()) { + callback(click); + } + } } diff --git a/src/ansys/visor/visor-client/src/wasm/VtkScene.js b/src/ansys/visor/visor-client/src/wasm/VtkScene.js index adce7015..f679c455 100644 --- a/src/ansys/visor/visor-client/src/wasm/VtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/VtkScene.js @@ -6,6 +6,7 @@ * remote one. */ import CameraGestureTracker from './CameraGestureTracker.js'; +import { MouseButtonState } from '../input/MouseButtonState'; export default class VtkScene { /** @@ -421,12 +422,7 @@ export default class VtkScene { /**@type{boolean}*/ let eventFiring = false; - /**@type{boolean}*/ - let mouseLeftDown = false; - /**@type{boolean}*/ - let mouseMiddleDown = false; - /**@type{boolean}*/ - let mouseRightDown = false; + const buttons = new MouseButtonState(); /** * @param {MouseEvent} e @@ -453,29 +449,18 @@ export default class VtkScene { } try { eventFiring = true; - if (e.button === 0) { - mouseLeftDown = down; - } else if (e.button === 1) { - mouseMiddleDown = down; - } else if (e.button === 2) { - mouseRightDown = down; - } + buttons.setButton(e.button, down); canvas.dispatchEvent(new MouseEvent('mouseup', getEventDict(e, 0))); canvas.dispatchEvent(new MouseEvent('mouseup', getEventDict(e, 1))); canvas.dispatchEvent(new MouseEvent('mouseup', getEventDict(e, 2))); - if (mouseLeftDown && mouseRightDown) { - canvas.dispatchEvent(new MouseEvent('mousedown', getEventDict(e, 1))); - e.stopPropagation(); - } else if (mouseRightDown) { - canvas.dispatchEvent(new MouseEvent('mousedown', getEventDict(e, 2))); - e.stopPropagation(); - } else if (mouseLeftDown) { - canvas.dispatchEvent(new MouseEvent('mousedown', getEventDict(e, 0))); - // don't call e.stopPropagation() here because we need to - // send the left-mouse-button event to the VTK canvas. - } else if (mouseMiddleDown) { - canvas.dispatchEvent(new MouseEvent('mousedown', getEventDict(e, 0))); - e.stopPropagation(); + const effective = buttons.effectiveButton(); + if (effective != null) { + canvas.dispatchEvent(new MouseEvent('mousedown', getEventDict(e, effective))); + // While left drives button 0 the event goes on to the VTK + // canvas (and to the picker); every other combination stops it. + if (effective !== 0 || !buttons.isDown(0)) { + e.stopPropagation(); + } } } finally { eventFiring = false;