diff --git a/doc/changelog.d/143.added.md b/doc/changelog.d/143.added.md new file mode 100644 index 00000000..b7247c34 --- /dev/null +++ b/doc/changelog.d/143.added.md @@ -0,0 +1 @@ +[Remote rendering 3.3d] sync camera to server on orientation widget diff --git a/src/ansys/visor/visor-client/src/jest-tests/CameraGestureTracker.test.js b/src/ansys/visor/visor-client/src/jest-tests/CameraGestureTracker.test.js index 4895803e..318512f5 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/CameraGestureTracker.test.js +++ b/src/ansys/visor/visor-client/src/jest-tests/CameraGestureTracker.test.js @@ -211,6 +211,23 @@ describe('CameraGestureTracker', () => { expect(onSettled).toHaveBeenCalledWith('programmatic'); }); + // ---- the orientation widget's mark -------------------------------------- + + test('a camera event followed by the widget mark within 300 ms reports gesture', () => { + // The widget's mark can arrive after the camera events it belongs + // to, so this exercises the retroactive branch of noteWidgetGesture. + // The mark-first order is covered by the wheel test above, via the + // same #markImpulse code path. + tracker.noteCameraEvent(); + jest.advanceTimersByTime(299); + + tracker.noteWidgetGesture(); + jest.advanceTimersByTime(300); + + expect(onSettled).toHaveBeenCalledTimes(1); + expect(onSettled).toHaveBeenCalledWith('gesture'); + }); + // ---- listener management and teardown ---------------------------------- test('the remover returned by addSettledListener stops reports', () => { diff --git a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx index 0ce6a698..89be55cf 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/WasmRendererWidgetTriggers.test.tsx @@ -103,7 +103,14 @@ function makeFakeWasmObjects() { SetOrigin: jest.fn(async () => undefined), SetNormal: jest.fn(async () => undefined), }; - return { actor, property, widget }; + // The orientation widget is its own double, not the shared `widget` + // above. Both are observed on `EndInteractionEvent`, and one double for + // both cannot tell those two registrations apart: the plane's own test + // asserts exactly one such registration, and would see two. + const orientationWidget = { + observe: jest.fn(), + }; + return { actor, property, widget, orientationWidget }; } /** @@ -137,6 +144,10 @@ async function makeRenderer( canvasDiv: document.createElement('div'), render: jest.fn(), clearObserversAndEventListeners: jest.fn(), + // The tracker itself is `#private` to VtkScene, so what the renderer + // can reach is this one passthrough, and this is what the orientation + // registration is pinned against. + noteWidgetGesture: jest.fn(), camera, getVtkObject: (wasmId: number) => { switch (wasmId) { @@ -144,6 +155,8 @@ async function makeRenderer( return objects.actor; case PROPERTY_ID: return objects.property; + case ORIENTATION_WIDGET_ID: + return objects.orientationWidget; default: return objects.widget; } @@ -156,7 +169,7 @@ async function makeRenderer( ); const sceneGraph = makeSceneGraphDouble(actorIds); renderer.attachSceneGraph(sceneGraph as unknown as VisorSceneNodeExtended); - return { renderer, camera, sceneGraph, ...objects }; + return { renderer, camera, scene, sceneGraph, ...objects }; } /** A sender that records its calls and resolves. */ @@ -336,3 +349,28 @@ describe('WasmRenderer reports the cross-section plane on the end-of-drag event' expect(sender).not.toHaveBeenCalled(); }); }); + +describe('WasmRenderer marks an orientation-widget move as a gesture', () => { + // As above, the event does not exist under jsdom, so what is pinned is + // the *registration*: which event the mark is bound to, that there is + // exactly one of it on the orientation widget, and that the callback + // marks and sends nothing. Whether the wasm widget invokes that event at + // all is MC-I6's subject and no gate reaches it. + test('the orientation widget is observed once on EndInteractionEvent and the callback marks a widget gesture', async () => { + const sender = makeSender(); + const { scene, orientationWidget } = await makeRenderer(sender); + + const endCalls = orientationWidget.observe.mock.calls.filter( + (call) => call[0] === 'EndInteractionEvent' + ); + expect(endCalls).toHaveLength(1); + expect(scene.noteWidgetGesture).not.toHaveBeenCalled(); + + await endCalls[0][1](); + + expect(scene.noteWidgetGesture).toHaveBeenCalledTimes(1); + // The mark carries no payload and triggers no send: the report stays + // the settle's, through the unchanged sync_camera path. + expect(sender).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index d61fff6d..b18f9054 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -96,6 +96,22 @@ export class WasmRenderer implements IRenderer { }); }); + /** + * Attribute an orientation-widget camera move to the user. + * + * Clicking a face of the cube moves the camera entirely inside wasm, + * with no DOM input `CameraGestureTracker` can see, so without this + * mark the move settles as `programmatic` and the server drops it. + * `EndInteractionEvent` (not `InteractionEvent`) fires once per + * interaction rather than per animation frame. The callback only + * marks the gesture; the report itself is still the settle's, + * unchanged, via `sync_camera`. + */ + const orientationWidget = vtkScene.getVtkObject(annotation.widgets.orientationWidgetId); + orientationWidget.observe('EndInteractionEvent', () => { + vtkScene.noteWidgetGesture(); + }); + // Bounding-box ids are stashed for attachSceneGraph, which is the // point at which the live sceneGraph (needed by BoundingBoxWidget) // becomes available. diff --git a/src/ansys/visor/visor-client/src/wasm/CameraGestureTracker.js b/src/ansys/visor/visor-client/src/wasm/CameraGestureTracker.js index 694c2147..b9895371 100644 --- a/src/ansys/visor/visor-client/src/wasm/CameraGestureTracker.js +++ b/src/ansys/visor/visor-client/src/wasm/CameraGestureTracker.js @@ -25,6 +25,11 @@ * the camera event can arrive before the tracker has noticed the input. * To cover that, a wheel event or a z/r keydown arriving while a report is * pending marks that report `gesture` as well. + * + * - The orientation widget is not a DOM input at all: its face click reaches + * the camera inside wasm. It is marked explicitly, through + * `noteWidgetGesture`, from the observer `WasmRenderer` registers on the + * widget's `EndInteractionEvent`. */ /** @@ -190,6 +195,22 @@ export default class CameraGestureTracker { this.#settleTimer = setTimeout(this.#reportSettled, CAMERA_SETTLE_MS); }; + /** + * The orientation widget's end-of-interaction mark, called via + * `VtkScene.noteWidgetGesture` from the `EndInteractionEvent` observer in + * `WasmRenderer`. A face click involves no button, wheel or z/r key, so + * without this mark the move settles as `programmatic`. + * + * Delegates to `#markImpulse` to reuse the same window and retroactive + * stickiness as a wheel notch or z/r press, so a mark landing before or + * after the camera events it belongs to is still caught. + * + * @return {void} + */ + noteWidgetGesture = () => { + this.#markImpulse(); + }; + /** * @return {void} */ diff --git a/src/ansys/visor/visor-client/src/wasm/VtkScene.js b/src/ansys/visor/visor-client/src/wasm/VtkScene.js index 81d71a0e..adce7015 100644 --- a/src/ansys/visor/visor-client/src/wasm/VtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/VtkScene.js @@ -159,6 +159,17 @@ export default class VtkScene { addCameraSettledListener = (handler) => { return this.#cameraGestureTracker.addSettledListener(handler); }; + /** + * Mark the settle window now open as a user gesture, on behalf of a wasm + * widget (the orientation widget) whose interaction is not a DOM input + * this scene can see. `WasmRenderer` calls this from its + * `EndInteractionEvent` observer, since `#cameraGestureTracker` is private. + * + * @return {void} + */ + noteWidgetGesture = () => { + this.#cameraGestureTracker?.noteWidgetGesture(); + }; /** * @param {(actorId:number,ctrlKey:boolean,shiftKey:boolean,normX:number,normY:number)=>void} handler * @return {()=>void}