Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6f0e670
add camera record and pipeline projection to IRenderer and write it o…
LKasianAnsys Sep 4, 2026
2083d23
Re-serialize the camera after sync_camera
LKasianAnsys Sep 9, 2026
8247928
chore: adding changelog file 110.added.md [dependabot-skip]
pyansys-ci-bot Sep 14, 2026
0248dd4
serialize camera in reset_camera
LKasianAnsys Sep 11, 2026
d5b9247
serialize only camera in serialize_camera_state and tidy up docstrings
LKasianAnsys Sep 16, 2026
a4b1c6b
xfail test_load_state_into_empty_scene
LKasianAnsys Sep 16, 2026
9189f66
Merge branch 'main' into feat/3.2a-server-tracked-camera
LKasianAnsys Sep 16, 2026
c5627ba
Merge branch 'main' into feat/3.2a-server-tracked-camera
LKasianAnsys Sep 21, 2026
3d570f0
clean up apply_state method content
LKasianAnsys Sep 21, 2026
5778ff9
fix typo
LKasianAnsys Sep 21, 2026
f5d3b6a
read camera from server on save_state
LKasianAnsys Sep 9, 2026
c7cddb8
clean up duplicate camera field
LKasianAnsys Sep 10, 2026
ab8185e
create type for applied camera
LKasianAnsys Sep 10, 2026
ebb70a9
chore: adding changelog file 111.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
7cb6b8a
camera settle binding, input-attributed origin
LKasianAnsys Sep 11, 2026
17ce79c
chore: adding changelog file 124.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
2adf984
clean up comments and docstrings
LKasianAnsys Sep 16, 2026
f8fecf2
clean up comments and docstrings
LKasianAnsys Sep 16, 2026
28f4a84
clean up comments
LKasianAnsys Sep 16, 2026
001d001
chore: adding changelog file 124.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
b149c50
chore: adding changelog file 124.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
82f9fb6
Merge branch 'main' into feat/3.2c-add-camera-gesture-tracker
LKasianAnsys Sep 22, 2026
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/124.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.2c] attribute camera changes to the input that caused them
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
/**
* Camera settle debounce and its origin capture.
*
* The tracker is tested directly rather than through VtkScene: VtkScene's
* construction awaits four wasm proxies and starts a requestAnimationFrame
* loop, so a fake large enough to build one would be what the tests measured.
*
* The debounce is 300 ms. That literal is written out in every assertion here
* and the production constant is deliberately not imported, so that changing
* it fails these tests instead of silently redefining what they assert.
*/
import CameraGestureTracker from '../wasm/CameraGestureTracker.js';

describe('CameraGestureTracker', () => {
/**@type{HTMLElement}*/
let canvasDiv;
/**@type{HTMLElement}*/
let canvas;
/**@type{CameraGestureTracker}*/
let tracker;
/**@type{jest.Mock}*/
let onSettled;

beforeEach(() => {
jest.useFakeTimers();
canvasDiv = document.createElement('div');
canvas = document.createElement('canvas');
canvasDiv.appendChild(canvas);
document.body.appendChild(canvasDiv);
tracker = new CameraGestureTracker(canvasDiv, canvas);
onSettled = jest.fn();
tracker.addSettledListener(onSettled);
});

afterEach(() => {
tracker.dispose();
document.body.removeChild(canvasDiv);
jest.useRealTimers();
});

const mouseDown = (button) =>
canvasDiv.dispatchEvent(new MouseEvent('mousedown', { button, bubbles: true }));
const mouseUp = (button) =>
canvasDiv.dispatchEvent(new MouseEvent('mouseup', { button, bubbles: true }));
const mouseOut = () => canvasDiv.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
const mouseMove = () => canvasDiv.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
const wheel = () => canvas.dispatchEvent(new Event('wheel'));
const keyUp = (key) => window.dispatchEvent(new KeyboardEvent('keyup', { key }));
const blur = () => window.dispatchEvent(new Event('blur'));

// ---- the debounce ------------------------------------------------------

test('no report at 299 ms and exactly one report at 300 ms', () => {
tracker.noteCameraEvent();

jest.advanceTimersByTime(299);
expect(onSettled).not.toHaveBeenCalled();

jest.advanceTimersByTime(1);
expect(onSettled).toHaveBeenCalledTimes(1);
});

test('a second event before the deadline restarts the debounce', () => {
tracker.noteCameraEvent();
jest.advanceTimersByTime(200);
tracker.noteCameraEvent();

jest.advanceTimersByTime(299);
expect(onSettled).not.toHaveBeenCalled();

jest.advanceTimersByTime(1);
expect(onSettled).toHaveBeenCalledTimes(1);
});

// ---- origin, captured at event time ------------------------------------

test('an event with no input reports programmatic', () => {
tracker.noteCameraEvent();
jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledTimes(1);
expect(onSettled).toHaveBeenCalledWith('programmatic');
});

test('an event while a button is held reports gesture even though the button is released before the deadline', () => {
mouseDown(0);
tracker.noteCameraEvent();
mouseUp(0);

jest.advanceTimersByTime(300);

// An origin computed when the timer fires would say 'programmatic'
// here, because by then the button is up.
expect(onSettled).toHaveBeenCalledTimes(1);
expect(onSettled).toHaveBeenCalledWith('gesture');
});

test('one gesture event makes the whole window gesture', () => {
mouseDown(0);
tracker.noteCameraEvent();
mouseUp(0);
jest.advanceTimersByTime(100);
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledTimes(1);
expect(onSettled).toHaveBeenCalledWith('gesture');
});

// ---- what counts as input ----------------------------------------------

test('pointer movement with no button held is not input', () => {
mouseMove();
tracker.noteCameraEvent();
mouseMove();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('programmatic');
});

test('a mouseout ends the hold', () => {
mouseDown(2);
mouseOut();
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('programmatic');
});

test('a window blur ends the hold', () => {
mouseDown(1);
blur();
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('programmatic');
});

test('an event within 300 ms of a wheel reports gesture', () => {
wheel();
jest.advanceTimersByTime(299);
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('gesture');
});

test('an event 300 ms after a wheel reports programmatic', () => {
wheel();
jest.advanceTimersByTime(300);
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('programmatic');
});

test('an event within 300 ms of a z keyup reports gesture', () => {
keyUp('z');
jest.advanceTimersByTime(299);
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('gesture');
});

test('an event within 300 ms of an r keyup reports gesture', () => {
keyUp('r');
jest.advanceTimersByTime(299);
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('gesture');
});

test('a keyup that is not z or r does not arm input', () => {
keyUp('a');
tracker.noteCameraEvent();

jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledWith('programmatic');
});

// ---- listener management and teardown ----------------------------------

test('the remover returned by addSettledListener stops reports', () => {
const second = jest.fn();
const remove = tracker.addSettledListener(second);
remove();

tracker.noteCameraEvent();
jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledTimes(1);
expect(second).not.toHaveBeenCalled();
});

test('a settle armed before teardown produces no report', () => {
tracker.noteCameraEvent();
tracker.dispose();

jest.advanceTimersByTime(300);

expect(onSettled).not.toHaveBeenCalled();
});

test('after teardown a further event produces no report', () => {
tracker.dispose();

tracker.noteCameraEvent();
jest.advanceTimersByTime(300);

expect(onSettled).not.toHaveBeenCalled();
});
});

/**
* The wasm canvas's own wheel handler and VtkScene's window keyup handler are
* registered before the tracker is constructed, so the camera event raised by a
* single wheel notch or a single z/r press reaches noteCameraEvent() before the
* tracker's own handler has marked input active.
*
* Each test here reproduces that order exactly: a stand-in listener is
* registered on the same target *before* the tracker exists, and calls
* noteCameraEvent() synchronously from inside its own handler.
*/
describe('CameraGestureTracker, when the camera event precedes the tracker handler', () => {
/**@type{HTMLElement}*/
let canvasDiv;
/**@type{HTMLElement}*/
let canvas;
/**@type{CameraGestureTracker|null}*/
let tracker;
/**@type{jest.Mock}*/
let onSettled;
/**@type{Array<()=>void>}*/
let standIns;

beforeEach(() => {
jest.useFakeTimers();
canvasDiv = document.createElement('div');
canvas = document.createElement('canvas');
canvasDiv.appendChild(canvas);
document.body.appendChild(canvasDiv);
tracker = null;
onSettled = jest.fn();
standIns = [];
});

afterEach(() => {
for (const remove of standIns) {
remove();
}
if (tracker != null) {
tracker.dispose();
}
document.body.removeChild(canvasDiv);
jest.useRealTimers();
});

/**
* Registers a handler that raises one camera event, before the tracker is
* constructed, so that it runs first.
*/
const standInBefore = (target, type) => {
const handler = () => tracker.noteCameraEvent();
target.addEventListener(type, handler);
standIns.push(() => target.removeEventListener(type, handler));
};

test('a single wheel event reports gesture', () => {
standInBefore(canvas, 'wheel');
tracker = new CameraGestureTracker(canvasDiv, canvas);
tracker.addSettledListener(onSettled);

canvas.dispatchEvent(new Event('wheel'));
jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledTimes(1);
expect(onSettled).toHaveBeenCalledWith('gesture');
});

test('a single r keyup reports gesture', () => {
standInBefore(window, 'keyup');
tracker = new CameraGestureTracker(canvasDiv, canvas);
tracker.addSettledListener(onSettled);

window.dispatchEvent(new KeyboardEvent('keyup', { key: 'r' }));
jest.advanceTimersByTime(300);

expect(onSettled).toHaveBeenCalledTimes(1);
expect(onSettled).toHaveBeenCalledWith('gesture');
});
});
15 changes: 15 additions & 0 deletions src/ansys/visor/visor-client/src/renderer/IRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export type AppliedCameraState = Readonly<{
parallelScale: number;
}>;

/**
* Where a settled camera change came from: `gesture` if user input was
* active during the change, `programmatic` otherwise.
* See `wasm/CameraGestureTracker.js`.
*/
export type CameraOrigin = 'gesture' | 'programmatic';

/** Descriptor consumed by setColorVariableAsync. */
export type ColorVariableDescriptor = Readonly<{
spectrumId: string;
Expand Down Expand Up @@ -119,6 +126,14 @@ export interface IRenderer {
// ---- Subscriptions (return unsubscribe closures) ------------------------
/** Fires whenever the camera changes. Callback receives a full snapshot. */
addCameraChangedListener(callback: (state: VisorCameraState) => void): () => void;
/**
* Fires once per gesture, after the last camera change has stopped changing for
* CAMERA_SETTLE_MS, with the origin of that change. The callback receives
* the origin only; read the camera with getCameraStateAsync if needed.
* A `gesture` report is the user's own camera; a `programmatic` one is a
* camera the application or the server applied.
*/
addCameraSettledListener(callback: (origin: CameraOrigin) => void): () => void;
/** Fires each frame with the current FPS. */
addFrameRenderedListener(callback: (fps: number) => void): () => void;
/**
Expand Down
7 changes: 7 additions & 0 deletions src/ansys/visor/visor-client/src/renderer/NullRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AppliedCameraState,
CameraOrigin,
ColorVariableDescriptor,
GeometryPickMode,
IRenderer,
Expand Down Expand Up @@ -94,6 +95,12 @@ export class NullRenderer implements IRenderer {
};
}

addCameraSettledListener(_callback: (origin: CameraOrigin) => void): () => void {
return () => {
// no-op unsubscribe
};
}

addFrameRenderedListener(_callback: (fps: number) => void): () => void {
return () => {
// no-op unsubscribe
Expand Down
5 changes: 5 additions & 0 deletions src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AppliedCameraState,
CameraOrigin,
ColorVariableDescriptor,
GeometryPickMode,
IRenderer,
Expand Down Expand Up @@ -205,6 +206,10 @@ export class WasmRenderer implements IRenderer {
});
}

addCameraSettledListener(callback: (origin: CameraOrigin) => void): () => void {
return this.#vtkScene.addCameraSettledListener(callback);
}

addFrameRenderedListener(callback: (fps: number) => void): () => void {
return this.#vtkScene.addFrameRenderedListener(async (fps) => {
callback(fps);
Expand Down
Loading
Loading