Skip to content
Open
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/189.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 4.6: Keep view fitted to the canvas on resize
3 changes: 2 additions & 1 deletion src/ansys/visor/viewer/renderer/server_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ def render_window(self) -> vtkRenderWindow:
return self._render_window

def set_viewport_size(self, width: int, height: int) -> None:
"""Resize the window and its interactor to the client canvas in device pixels.
"""Resize the window and its interactor to the client canvas in device pixels, and fit the orientation widget to it.

Requests no frame: the resize path that calls this schedules its own.
"""
self._render_window_interactor.UpdateSize(width, height)
self._orientation_widget.fit_to_window(width, height)

def set_background_rgb(self, background_rgb: tuple[float, float, float]) -> None:
"""Clear to this colour from the next frame.
Expand Down
14 changes: 14 additions & 0 deletions src/ansys/visor/viewer/vtk/widgets/visor_orientation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

logger = VisorDefaultLogger(__name__)

SIZE_FRACTION: float = 0.15
"""Widget edge as a fraction of the window's shorter side."""


class VisorOrientationWidget:
"""
Expand All @@ -20,6 +23,7 @@ class VisorOrientationWidget:

Methods:
register_with_local_view(local_view)
fit_to_window(width, height)

Properties:
is_on (bool): Whether the widget is on or not.
Expand Down Expand Up @@ -47,6 +51,16 @@ def is_on(self):
"""Return whether the widget is on or not."""
return self.__widget.GetEnabled() == 1

def fit_to_window(self, width: int, height: int) -> None:
"""Size the widget to SIZE_FRACTION of the window's shorter side, in render-window pixels."""
size = self.size_for_window(width, height)
self.__widget.GetRepresentation().SetSize(size, size)

@staticmethod
def size_for_window(width: int, height: int) -> int:
"""Square edge for a window of this size, rounded half up."""
return int(min(width, height) * SIZE_FRACTION + 0.5)

def animate_off(self) -> None:
"""Snap the camera on an axis click instead of animating it over interactor timer events."""
self.__widget.AnimateOff()
Expand Down
4 changes: 0 additions & 4 deletions src/ansys/visor/visor-client/src/VisorFrontend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { IRenderer, VisorCameraState } from './renderer/IRenderer';
import type { TrameTriggerSender } from './renderer/IRenderer';
import { Panel_TopRight_Util } from './components/ui-panels/Panel_TopRight_Util.tsx';
import { Panel_TopLeft_Util } from './components/ui-panels/Panel_TopLeft_Util.tsx';
import { OrientationWidget } from './widgets/orientationWidget.ts';
import { FullscreenWidget } from './widgets/fullscreenWidget.ts';
import { UiScaffoldUtil } from './components/UiScaffold.tsx';

Expand Down Expand Up @@ -62,8 +61,6 @@ export class VisorFrontend {
// use "void" here to suppress the "no await" IDE warning
// void orientationWidget.SetShouldResetCamera(false);

// const orientationWidget = new OrientationWidget(vtkScene, vtkInfo.orientationWidgetWasmId);

let themeDarkCss: string | null | undefined = null;
let themeLightCss: string | null | undefined = null;
let themeStyleElem: HTMLStyleElement | null | undefined = null;
Expand All @@ -79,7 +76,6 @@ export class VisorFrontend {
};
this.resizeAsync = async () => {
await renderer.resizeAsync();
// await orientationWidget.resizeAsync();
};
this.domElement = renderer.domElement;
this.getCameraStateAsync = () => renderer.getCameraStateAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,16 @@ describe('ServerCanvas stream', () => {

expect(session.call).not.toHaveBeenCalled();
});

test('a resize listener hears each non-zero element size and no zero one', () => {
const session = makeSession();
const canvas = new ServerCanvas(session, 'view');
const listener = jest.fn();
canvas.addResizeListener(listener);

observeSize(0, 300);
observeSize(400, 300);

expect(listener).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function stubCanvas(): IServerCanvas {
frameSize: null,
addFrameListener: () => () => {},
addClickListener: () => () => {},
addResizeListener: () => () => {},
};
}

Expand Down Expand Up @@ -119,3 +120,46 @@ describe('ServerRenderer camera cache', () => {
});
});
});

describe('ServerRenderer canvas resize', () => {
let originalDevicePixelRatio: number;

beforeEach(() => {
originalDevicePixelRatio = window.devicePixelRatio;
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 1.5 });
});

afterEach(() => {
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: originalDevicePixelRatio,
});
});

test('a canvas resize gives camera listeners the snapshot at the truncated device-pixel height', () => {
const element = document.createElement('div');
Object.defineProperty(element, 'clientHeight', { configurable: true, value: 100 });
const resizeListeners: (() => void)[] = [];
const canvas: IServerCanvas = {
element,
frameSize: null,
addFrameListener: () => () => {},
addClickListener: () => () => {},
addResizeListener: (callback) => {
resizeListeners.push(callback);
return () => {};
},
};
const state = new FakeState(cameraRecord([0, 0, 9], [0, 0, 0]));
const renderer = new ServerRenderer(canvas, state as ServerStateSource, jest.fn());
const listener = jest.fn();
renderer.addCameraChangedListener(listener);
Object.defineProperty(element, 'clientHeight', { configurable: true, value: 301 });

resizeListeners.forEach((callback) => callback());

expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].viewPortHeight).toBe(451);
expect(listener.mock.calls[0][0].unitsPerPixel).toBeCloseTo(0.004434589800443459, 12);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function stubCanvas() {
clicks.push(callback);
return () => {};
},
addResizeListener: () => () => {},
};
return { canvas, click: (c: CanvasClick) => clicks.forEach((callback) => callback(c)) };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import type { TrameTriggerSender } from '../renderer/IRenderer';
* throw, and a failed widget send is logged under its own prefix, naming the
* trigger and carrying no node id.
*
* Also pinned: `resizeAsync` fits the orientation widget to the window as it
* stands after the scene resize.
*
* Modelled on `WasmRendererPartTriggers.test.tsx`, which does the same for
* the six per-part sends.
*/
Expand Down Expand Up @@ -108,10 +111,14 @@ function makeFakeWasmObjects() {
// 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 orientationRepresentation = {
SetSize: jest.fn(async () => undefined),
};
const orientationWidget = {
observe: jest.fn(),
GetRepresentation: jest.fn(async () => orientationRepresentation),
};
return { actor, property, widget, orientationWidget };
return { actor, property, widget, orientationWidget, orientationRepresentation };
}

async function makeRenderer(
Expand All @@ -125,9 +132,16 @@ async function makeRenderer(
ParallelProjectionOn: jest.fn(async () => undefined),
ParallelProjectionOff: jest.fn(async () => undefined),
};
let windowSize = [300, 300];
const scene = {
canvasDiv: document.createElement('div'),
render: jest.fn(),
resizeAsync: jest.fn(async () => {
windowSize = [1200, 700];
}),
renderWindow: {
GetSize: jest.fn(async () => windowSize),
},
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
Expand Down Expand Up @@ -388,3 +402,17 @@ describe('WasmRenderer marks an orientation-widget move as a gesture', () => {
expect(sender).not.toHaveBeenCalled();
});
});

describe('WasmRenderer fits the orientation widget on resize', () => {
// The scene double's resize moves the window from 300x300 to 1200x700, so
// a fit that read the window before the resize would carry [45, 45].
test('resizeAsync sizes the orientation widget from the resized window', async () => {
const { renderer, scene, orientationRepresentation } = await makeRenderer(null);

await renderer.resizeAsync();

expect(scene.resizeAsync).toHaveBeenCalledTimes(1);
expect(orientationRepresentation.SetSize).toHaveBeenCalledTimes(1);
expect(orientationRepresentation.SetSize).toHaveBeenCalledWith([105, 105]);
});
});
17 changes: 16 additions & 1 deletion src/ansys/visor/visor-client/src/rca/ServerCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface IServerCanvas {
readonly frameSize: FrameSize | null;
addFrameListener(callback: (fps: number) => void): () => void;
addClickListener(callback: (click: CanvasClick) => void): () => void;
addResizeListener(callback: () => void): () => void;
}

/** One message on the stream topic: an encoded frame for one area. */
Expand Down Expand Up @@ -116,6 +117,15 @@ export class ServerCanvas implements IServerCanvas {
return remover;
}

/** Call callback after each non-zero element size is sent to the area; returns its remover. */
addResizeListener(callback: () => void): () => void {
const remover = () => {
this.#resizeListeners.delete(remover);
};
this.#resizeListeners.set(remover, callback);
return remover;
}

/** Unsubscribe, stop observing the size, remove DOM listeners and revoke the current frame URL. */
dispose(): void {
this.#subscription.unsubscribe();
Expand All @@ -132,6 +142,7 @@ export class ServerCanvas implements IServerCanvas {
element.removeEventListener('contextmenu', this.#onContextMenu);
this.#frameListeners.clear();
this.#clickListeners.clear();
this.#resizeListeners.clear();
this.#revokeFrameUrl();
}

Expand All @@ -143,6 +154,7 @@ export class ServerCanvas implements IServerCanvas {
readonly #frameListeners = new Map<() => void, (fps: number) => void>();
readonly #frameTimes: number[] = [];
readonly #clickListeners = new Map<() => void, (click: CanvasClick) => void>();
readonly #resizeListeners = new Map<() => void, () => void>();
readonly #buttons = new MouseButtonState();
readonly #clickTracker = new ClickTracker();
#frameSize: FrameSize | null = null;
Expand Down Expand Up @@ -201,7 +213,7 @@ export class ServerCanvas implements IServerCanvas {
console.error('[VISOR] frame decode failed');
};

/** Send each non-zero element size to the area; the server resizes its window and renders a frame. */
/** Send each non-zero element size to the area, then tell the resize listeners; the server resizes its window and renders a frame. */
#onResize = (entries: ResizeObserverEntry[]): void => {
for (const entry of entries) {
const w = Math.round(entry.contentRect.width);
Expand All @@ -213,6 +225,9 @@ export class ServerCanvas implements IServerCanvas {
this.#session
.call('trame.rca.size', [this.#areaName, MESSAGE_ORIGIN, { w, h, p }])
.catch((err) => console.error('[VISOR] trame.rca.size send failed', err));
for (const callback of this.#resizeListeners.values()) {
callback();
}
}
};

Expand Down
14 changes: 11 additions & 3 deletions src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@ export class ServerRenderer implements IRenderer {
// `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);
this.#removeResizeListener = canvas.addResizeListener(this.#onCanvasResize);
}

readonly #canvas: IServerCanvas;
readonly #triggerSender: TrameTriggerSender;
readonly #sends: SceneTriggerSends;
readonly #unwatch: () => void;
readonly #removeClickListener: () => void;
readonly #removeResizeListener: () => void;
readonly #camera = new CameraRecordCache();
readonly #cameraChangedListeners = new Map<() => void, (state: VisorCameraState) => void>();
readonly #viewerClicks = new ViewerClickDispatcher();
Expand Down Expand Up @@ -207,6 +209,11 @@ export class ServerRenderer implements IRenderer {
.catch((err) => console.error('[VISOR] pick_part_at failed', err));
};

/** Give every camera listener the snapshot at the canvas's new height. */
#onCanvasResize = (): void => {
this.#fireCameraChanged();
};

/** 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);
Expand Down Expand Up @@ -238,9 +245,9 @@ export class ServerRenderer implements IRenderer {
}
}

/** The view's height in device pixels. */
/** The render window's height in device pixels: CSS height times pixel ratio, truncated as the server sizes it. */
#viewPortHeight(): number {
return this.domElement.clientHeight * (window.devicePixelRatio ?? 1);
return Math.floor(this.domElement.clientHeight * (window.devicePixelRatio ?? 1));
}

/** The full snapshot: record plus distance, orthographic, orthographicScale, unitsPerPixel, viewPortHeight. */
Expand Down Expand Up @@ -270,12 +277,13 @@ export class ServerRenderer implements IRenderer {
/** The server renders after every write it receives. */
async renderAsync(): Promise<void> {}

/** The canvas reports its own size to the server. */
/** The canvas reports its own size to the server and tells this renderer, which refreshes the camera listeners. */
async resizeAsync(): Promise<void> {}

dispose(): void {
this.#unwatch();
this.#removeClickListener();
this.#removeResizeListener();
this.#cameraChangedListeners.clear();
this.#viewerClicks.clear();
}
Expand Down
7 changes: 7 additions & 0 deletions src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CrossSectionWidget } from '../widgets/crossSectionWidget.ts';
import { BoundingBoxWidget } from '../widgets/boundingBoxWidget.ts';
import { OrthographicWidget } from '../widgets/orthographicWidget.ts';
import { EdgesWidget } from '../widgets/edgesWidget.ts';
import { OrientationWidget } from '../widgets/orientationWidget.ts';
import { SceneTriggerSends } from './SceneTriggerSends';

const SELECTION_TINT_RGB: Readonly<number[]> = [0 / 255, 62 / 255, 111 / 255];
Expand Down Expand Up @@ -59,6 +60,10 @@ export class WasmRenderer implements IRenderer {
outlineQueryId: annotation.widgets.boundingBoxOutlineActorId,
});
this.#edgesWidget = new EdgesWidget(this);
this.#orientationWidget = new OrientationWidget(
vtkScene,
annotation.widgets.orientationWidgetId
);

const planeWidget = vtkScene.getVtkObject(wasmPlaneWidgetId);
planeWidget.observe('InteractionEvent', this.#crossSectionWidget.interactionHandler);
Expand Down Expand Up @@ -150,6 +155,7 @@ export class WasmRenderer implements IRenderer {
readonly #orthographicWidget: OrthographicWidget;
readonly #boundingBoxWidget: BoundingBoxWidget;
readonly #edgesWidget: EdgesWidget;
readonly #orientationWidget: OrientationWidget;

// ---- Canvas / lifecycle -------------------------------------------------
readonly domElement: HTMLDivElement;
Expand All @@ -160,6 +166,7 @@ export class WasmRenderer implements IRenderer {

async resizeAsync(): Promise<void> {
await this.#vtkScene.resizeAsync();
await this.#orientationWidget.fitToWindowAsync();
this.#vtkScene.render();
}

Expand Down
Loading
Loading