From 55a248518720a483ae153cd82765f871dd82d4b9 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 2 Nov 2026 15:15:45 -0700 Subject: [PATCH 1/7] feat: show client-mode geo highlight from server scene --- src/ansys/visor/viewer/app/trame/local_app.py | 30 ++++- src/ansys/visor/viewer/app/visor_vtk_local.py | 6 +- .../runtime/requests/pixel_scale_payload.py | 11 ++ .../visor/viewer/renderer/local_renderer.py | 2 +- .../viewer/renderer/pipeline_renderer.py | 8 ++ .../visor/viewer/renderer/server_renderer.py | 7 - .../visor/viewer/vtk/scene/local_scene.py | 8 ++ .../WasmRendererWidgetTriggers.test.tsx | 62 +++++++++ .../visor-client/src/renderer/IRenderer.ts | 4 +- .../src/renderer/SceneTriggerSends.ts | 5 + .../visor-client/src/renderer/WasmRenderer.ts | 9 +- .../visor/visor-client/src/wasm/VtkScene.js | 120 ------------------ tests/unit/app/test_scene_trigger_app.py | 45 ++++++- tests/unit/renderer/test_local_renderer.py | 17 +++ tests/unit/vtk/scene/test_local_scene.py | 14 ++ 15 files changed, 206 insertions(+), 142 deletions(-) create mode 100644 src/ansys/visor/viewer/models/runtime/requests/pixel_scale_payload.py diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index aefe75da..1d727a0c 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -1,13 +1,19 @@ from logging import Logger +from typing import Protocol from trame.app.core import Server from trame.decorators import TrameApp, trigger from vtk import vtkObject -from ansys.visor.viewer.app.trame.scene_trigger_app import SceneMutationApi, SceneTriggerApp +from ansys.visor.viewer.app.trame.scene_trigger_app import ( + SceneMutationApi, + SceneTriggerApp, + parse_payload, +) from ansys.visor.viewer.config import settings from ansys.visor.viewer.core.visor_enums import RenderingMode from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger +from ansys.visor.viewer.models.runtime.requests.pixel_scale_payload import SetPixelScalePayload logger = VisorDefaultLogger(__name__) @@ -15,6 +21,12 @@ vtkObject.GlobalWarningDisplayOff() +class LocalSceneMutationApi(SceneMutationApi, Protocol): + """The scene surface LocalApp's triggers call: the shared one plus the client-only pixel scale.""" + + def set_pixel_scale(self, pixel_scale: float) -> None: ... + + @TrameApp() class LocalApp(SceneTriggerApp): """ @@ -28,6 +40,7 @@ class LocalApp(SceneTriggerApp): pick_geometry: picks the geometry for rendering perf_report_wasm: reports the performance of the wasm update cycle perf_report_server_update: reports the performance of the server update cycle + set_pixel_scale: sets the device pixel ratio the geometry highlight is drawn at set_only_cookie: sets a cookie on the server (note: Trame server only allows a single cookie header) """ def __init__( @@ -37,7 +50,7 @@ def __init__( standalone: bool = True, trame_logger: Logger | None = None, pick_geometry=None, - scene_mutation_api: SceneMutationApi | None = None, + scene_mutation_api: LocalSceneMutationApi | None = None, ): # Callable for sub-geometry picking (optional), local-only. self._pick_geometry = pick_geometry @@ -61,6 +74,19 @@ def pick_geometry(self, actor_wasm_id, cell_id, mode: str, world_x: float, world return {"found": False} return self._pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z) + @trigger("set_pixel_scale") + @parse_payload(SetPixelScalePayload) + def set_pixel_scale(self, payload) -> None: + """Frontend -> Backend: the client reports its device pixel ratio for the geometry highlight. + + Server mode learns the same ratio from the remote-canvas size report, + so only client mode registers this. + """ + api = self._mutation_api("set_pixel_scale", payload) + if api is None: + return + api.set_pixel_scale(payload.scale) + @trigger("perf_report_wasm") def perf_report_wasm(self, payload: dict): """ diff --git a/src/ansys/visor/viewer/app/visor_vtk_local.py b/src/ansys/visor/viewer/app/visor_vtk_local.py index ea982ad9..690734b2 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_local.py +++ b/src/ansys/visor/viewer/app/visor_vtk_local.py @@ -51,9 +51,9 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> world_x, world_y, world_z: self._scene.pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z), - # The scene satisfies LocalApp's SceneMutationApi protocol - # structurally: the six per-part coordinator methods carry exactly - # the names and signatures the triggers call. + # The scene satisfies LocalApp's LocalSceneMutationApi protocol + # structurally: the coordinator methods carry exactly the names + # and signatures the triggers call. scene_mutation_api=self._scene, ) diff --git a/src/ansys/visor/viewer/models/runtime/requests/pixel_scale_payload.py b/src/ansys/visor/viewer/models/runtime/requests/pixel_scale_payload.py new file mode 100644 index 00000000..d9f38e1d --- /dev/null +++ b/src/ansys/visor/viewer/models/runtime/requests/pixel_scale_payload.py @@ -0,0 +1,11 @@ +"""Model for the ``set_pixel_scale`` trigger payload.""" + +from typing import Annotated + +from pydantic import BaseModel, Field + + +class SetPixelScalePayload(BaseModel): + """set_pixel_scale payload: the client's device pixels per CSS pixel, finite and positive.""" + + scale: Annotated[float, Field(gt=0, allow_inf_nan=False)] diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index ae83a5d7..6b3335cb 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -200,7 +200,7 @@ def _present_geometry_highlight(self) -> None: self.push_without_rebuild() def _initial_highlight_pixel_scale(self) -> float: - """1.0: the client draws its own overlay here, so the actor's sizes are not shown.""" + """1.0 until the client reports its device pixel ratio.""" return 1.0 # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/renderer/pipeline_renderer.py b/src/ansys/visor/viewer/renderer/pipeline_renderer.py index eef73f8b..d25dd521 100644 --- a/src/ansys/visor/viewer/renderer/pipeline_renderer.py +++ b/src/ansys/visor/viewer/renderer/pipeline_renderer.py @@ -539,6 +539,14 @@ def show_geometry_highlight(self, pick: GeometryPick) -> None: pick.highlight_with(self._geometry_highlight) self._present_geometry_highlight() + def set_pixel_scale(self, pixel_scale: float) -> None: + """Rescale the geometry highlight to this many device pixels per CSS pixel; requests no frame and pushes nothing. + + Server mode's resize path, which calls this, schedules its own frame. + In client mode the next highlight shown carries the new size. + """ + self._geometry_highlight.set_pixel_scale(pixel_scale) + # ------------------------------------------------------------------ # Internal init helpers # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index bdffb5fc..df72e7bf 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -88,13 +88,6 @@ def set_viewport_size(self, width: int, height: int) -> None: self._render_window_interactor.UpdateSize(width, height) self._orientation_widget.fit_to_window(width, height) - def set_pixel_scale(self, pixel_scale: float) -> None: - """Rescale the geometry highlight to this many device pixels per CSS pixel. - - Requests no frame: the resize path that calls this schedules its own. - """ - self._geometry_highlight.set_pixel_scale(pixel_scale) - def set_background_rgb(self, background_rgb: tuple[float, float, float]) -> None: """Clear to this colour from the next frame. diff --git a/src/ansys/visor/viewer/vtk/scene/local_scene.py b/src/ansys/visor/viewer/vtk/scene/local_scene.py index 3abc2f74..654659dd 100644 --- a/src/ansys/visor/viewer/vtk/scene/local_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/local_scene.py @@ -78,6 +78,14 @@ def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) actor_wasm_id, cell_id, mode, (world_x, world_y, world_z) ) + def set_pixel_scale(self, pixel_scale: float) -> None: + """Draw the geometry highlight at this many device pixels per CSS pixel; no push, no notify. + + The next highlight shown carries the new size to the client. + """ + with self._vtk_lock: + self._renderer.set_pixel_scale(pixel_scale) + def pick_geometry_in_view(self, view: "PickView", x: int, y: int, mode: str) -> "GeometryPick": """Find the geometry feature under a pixel of a client's view, under the scene lock.""" with self._vtk_lock: 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 2c73a02f..142fd96a 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 @@ -31,6 +31,14 @@ import type { TrameTriggerSender } from '../renderer/IRenderer'; * Also pinned: `resizeAsync` fits the orientation widget to the window as it * stands after the scene resize. * + * Also pinned: `resizeAsync` reports the window's device pixel ratio to + * `set_pixel_scale`, so the server draws the geometry highlight at the size + * the wasm canvas shows it. + * + * Also pinned: a geometry highlight and a clear each send + * `set_geometry_highlight` with the pick, so the server draws and drops it; + * the scene no longer draws one of its own. + * * Modelled on `WasmRendererPartTriggers.test.tsx`, which does the same for * the six per-part sends. */ @@ -416,3 +424,57 @@ describe('WasmRenderer fits the orientation widget on resize', () => { expect(orientationRepresentation.SetSize).toHaveBeenCalledWith([105, 105]); }); }); + +/** Let the sends that are not awaited run to completion. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('WasmRenderer reports its device pixel ratio on resize', () => { + let originalDevicePixelRatio: number; + + beforeEach(() => { + originalDevicePixelRatio = window.devicePixelRatio; + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }); + }); + + afterEach(() => { + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value: originalDevicePixelRatio, + }); + }); + + test("resizeAsync sends set_pixel_scale with the window's device pixel ratio", async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.resizeAsync(); + await settle(); + + expect(sender.mock.calls).toStrictEqual([['set_pixel_scale', { scale: 2 }]]); + }); +}); + +describe('WasmRenderer geometry highlight sends', () => { + test('a highlight then a clear each send set_geometry_highlight with the pick', async () => { + const sender = makeSender(); + const { renderer } = await makeRenderer(sender); + + await renderer.setGeometryHighlightAsync({ + found: true, + mode: 'vertex', + position: [1, 2, 3], + }); + renderer.clearGeometryHighlight(); + await settle(); + + expect(sender.mock.calls).toStrictEqual([ + [ + 'set_geometry_highlight', + { pick: { found: true, mode: 'vertex', position: [1, 2, 3] } }, + ], + ['set_geometry_highlight', { pick: { found: false } }], + ]); + }); +}); diff --git a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts index 7fe1c5ae..c6b078f3 100644 --- a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts @@ -75,13 +75,13 @@ export type GeometryPickMode = 'vertex' | 'edge' | 'face'; export interface IRenderer { // ---- Canvas / lifecycle ------------------------------------------------- - /** DOM element hosting the render output (canvas + overlay). */ + /** DOM element hosting the render output. */ readonly domElement: HTMLDivElement; /** Kick a frame flush. Wasm: wasm.update() + Render(). Server: no-op. */ renderAsync(): Promise; /** Resize output to its container. Call after container size changes. */ resizeAsync(): Promise; - /** Drop listeners, wasm observers, overlay canvas, RPC sessions, etc. */ + /** Drop listeners, wasm observers, RPC sessions, etc. */ dispose(): void; // ---- Camera (all reads async; state may live off-thread / off-box) ------ diff --git a/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts b/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts index c98c5347..309758de 100644 --- a/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts +++ b/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts @@ -162,4 +162,9 @@ export class SceneTriggerSends { async sendGeometryHighlightAsync(pick: PickGeometryResult): Promise { await this.#sendWidgetTriggerAsync('set_geometry_highlight', { pick }); } + + /** Send the device pixels per CSS pixel the server should draw the geometry highlight at. */ + async sendPixelScaleAsync(scale: number): Promise { + await this.#sendWidgetTriggerAsync('set_pixel_scale', { scale }); + } } diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index 4fbfdf65..620c37d7 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -165,6 +165,9 @@ export class WasmRenderer implements IRenderer { } async resizeAsync(): Promise { + // Not awaited: the resize does not wait on a round trip, and the send + // logs and swallows its own rejection. + void this.#sends.sendPixelScaleAsync(window.devicePixelRatio); await this.#vtkScene.resizeAsync(); await this.#orientationWidget.fitToWindowAsync(); this.#vtkScene.render(); @@ -513,11 +516,13 @@ export class WasmRenderer implements IRenderer { return (await this.#vtkScene.pickGeometryAsync(normX, normY, mode)) as PickGeometryResult; } + /** Ask the server to highlight the picked feature; a not-found result clears it. */ async setGeometryHighlightAsync(result: PickGeometryResult): Promise { - await this.#vtkScene.setGeometryHighlightAsync(result); + await this.#sends.sendGeometryHighlightAsync(result); } + /** Ask the server to clear the highlight; the send is not awaited. */ clearGeometryHighlight(): void { - this.#vtkScene.clearGeometryHighlight(); + void this.#sends.sendGeometryHighlightAsync({ found: false }); } } diff --git a/src/ansys/visor/visor-client/src/wasm/VtkScene.js b/src/ansys/visor/visor-client/src/wasm/VtkScene.js index dc1aa7a0..91cefb3f 100644 --- a/src/ansys/visor/visor-client/src/wasm/VtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/VtkScene.js @@ -73,7 +73,6 @@ export default class VtkScene { this.#setupFpsMonitor(); this.#setupCamera(); this.#setupPicker(); - this.#setupHighlightOverlay(); } /**@type{RemoteVtkScene}*/ @@ -225,125 +224,6 @@ export default class VtkScene { } }; - // ── Client-side geometry highlight (Canvas 2D overlay) ────────────────── - /**@type{HTMLCanvasElement|null}*/ - #highlightCanvas = null; - /**@type{any|null}*/ - #highlightResult = null; - - #setupHighlightOverlay = () => { - const overlay = document.createElement('canvas'); - overlay.style.position = 'absolute'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.pointerEvents = 'none'; - this.canvasDiv.appendChild(overlay); - this.#highlightCanvas = overlay; - // Redraw when camera moves so the projected positions stay correct. - this.addCameraChangedListener(() => { - this.#drawHighlight().catch(() => {}); - }); - }; - - /** - * Project a world-space point to overlay canvas pixels (top-left origin). - * @param {number[]} worldPt [x, y, z] - * @return {Promise} [canvasX, canvasY] - */ - #projectWorldToCanvas = async (worldPt) => { - await this.renderer.SetWorldPoint(worldPt[0], worldPt[1], worldPt[2], 1.0); - await this.renderer.WorldToDisplay(); - const dp = await this.renderer.GetDisplayPoint(); - // dp: [x, y, z] — VTK display coords (x left→right, y bottom→top, in render-window pixels) - const winSize = await this.renderWindow.GetSize(); - const normX = dp[0] / winSize[0]; - const normY = dp[1] / winSize[1]; // 0 = bottom - const overlay = this.#highlightCanvas; - const canvasX = normX * overlay.width; - const canvasY = (1 - normY) * overlay.height; // flip Y for canvas2d - return [canvasX, canvasY]; - }; - - #drawHighlight = async () => { - const canvas = this.#highlightCanvas; - if (!canvas) return; - // Match canvas size to its CSS display size - const rect = this.canvasDiv.getBoundingClientRect(); - canvas.width = rect.width; - canvas.height = rect.height; - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - - const result = this.#highlightResult; - if (!result || !result.found) return; - - ctx.strokeStyle = 'rgba(255, 165, 0, 1)'; // orange - ctx.fillStyle = 'rgba(255, 165, 0, 0.35)'; - ctx.lineWidth = 3; - ctx.lineJoin = 'round'; - ctx.lineCap = 'round'; - - if (result.mode === 'vertex') { - // Project the exact snapped vertex world position to canvas. - const [cx, cy] = await this.#projectWorldToCanvas(result.position); - ctx.beginPath(); - ctx.arc(cx, cy, 9, 0, 2 * Math.PI); - ctx.fill(); - ctx.stroke(); - } else if (result.mode === 'edge') { - const [ax, ay] = await this.#projectWorldToCanvas(result.pointA); - const [bx, by] = await this.#projectWorldToCanvas(result.pointB); - ctx.lineWidth = 4; - ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.lineTo(bx, by); - ctx.stroke(); - // endpoint dots - ctx.beginPath(); - ctx.arc(ax, ay, 5, 0, 2 * Math.PI); - ctx.fill(); - ctx.beginPath(); - ctx.arc(bx, by, 5, 0, 2 * Math.PI); - ctx.fill(); - } else if (result.mode === 'face' && result.points && result.points.length >= 3) { - const screenPts = []; - for (const p of result.points) { - screenPts.push(await this.#projectWorldToCanvas(p)); - } - ctx.beginPath(); - ctx.moveTo(screenPts[0][0], screenPts[0][1]); - for (let i = 1; i < screenPts.length; i++) { - ctx.lineTo(screenPts[i][0], screenPts[i][1]); - } - ctx.closePath(); - ctx.fill(); - ctx.stroke(); - } - }; - - /** - * Draw a highlight overlay for a pick result returned by pickGeometryAsync. - * @param {any} result - * @return {Promise} - */ - setGeometryHighlightAsync = async (result) => { - this.#highlightResult = result; - await this.#drawHighlight(); - }; - - /** - * Clear the geometry highlight overlay. - */ - clearGeometryHighlight = () => { - this.#highlightResult = null; - const canvas = this.#highlightCanvas; - if (canvas) { - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - } - }; - // ──────────────────────────────────────────────────────────────────────── - /** * @param {(fps:number)=>void} handler * @return {()=>void} diff --git a/tests/unit/app/test_scene_trigger_app.py b/tests/unit/app/test_scene_trigger_app.py index 78d6d22e..a10c3ee0 100644 --- a/tests/unit/app/test_scene_trigger_app.py +++ b/tests/unit/app/test_scene_trigger_app.py @@ -2,12 +2,14 @@ Coverage targets ---------------- -1. ``LocalApp`` registers exactly these 22 trigger names: the 19 shared ones - defined on ``SceneTriggerApp`` plus its own three local-only ones. +1. ``LocalApp`` registers exactly these 23 trigger names: the 19 shared ones + defined on ``SceneTriggerApp`` plus its own four local-only ones. Neither a missing nor a doubled registration passes this. 2. ``LocalApp`` seeds ``rendering_mode`` into server state as ``"client"`` before the server starts. 3. ``set_geometry_highlight`` hands the coordinator the parsed pick. +4. ``set_pixel_scale`` hands the coordinator the parsed scale, and a scale + that is not positive never reaches it. The coordinator and payload behaviour of the other 18 shared triggers is already pinned, trigger by trigger, in ``test_local_app.py`` and its sibling @@ -21,8 +23,8 @@ from ansys.visor.viewer.app.trame.local_app import LocalApp from ansys.visor.viewer.models.runtime.picking.geometry_pick import EdgePick -# The exact 22 trigger names LocalApp registers: the 19 names shared with -# every rendering mode, plus LocalApp's own three local-only ones. Written +# The exact 23 trigger names LocalApp registers: the 19 names shared with +# every rendering mode, plus LocalApp's own four local-only ones. Written # out by hand, not derived from the production trigger list, so a missing or # a doubled registration fails this rather than following along. LOCAL_APP_TRIGGER_NAMES = sorted( @@ -49,6 +51,7 @@ "pick_geometry", "perf_report_wasm", "perf_report_server_update", + "set_pixel_scale", ] ) @@ -83,7 +86,7 @@ def mock_server(): def test_local_app_registers_exactly_these_triggers(mock_server): - """The registered trigger names are exactly the hand-written 22, sorted. + """The registered trigger names are exactly the hand-written 23, sorted. A missing registration shortens the list; a doubled one (``@TrameApp()`` on both the base and the leaf) duplicates a name in @@ -138,3 +141,35 @@ def test_set_geometry_highlight_forwards_the_parsed_pick(mock_server): api.set_geometry_highlight.assert_called_once_with( EdgePick(point_a=[0.0, 0.0, 0.0], point_b=[3.0, 4.0, 0.0], length=5.0) ) + + +def test_set_pixel_scale_forwards_the_parsed_scale(mock_server): + """The client's device pixel ratio reaches the coordinator as the float it sent.""" + api = MagicMock(name="scene_mutation_api") + app = LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + scene_mutation_api=api, + ) + + result = app.set_pixel_scale({"scale": 2.0}) + + assert result is None + api.set_pixel_scale.assert_called_once_with(2.0) + + +@pytest.mark.parametrize("scale", [0.0, -1.0]) +def test_set_pixel_scale_ignores_a_scale_that_is_not_positive(mock_server, scale): + """A scale of zero or less would draw no highlight at all, so it never reaches the coordinator.""" + api = MagicMock(name="scene_mutation_api") + app = LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + scene_mutation_api=api, + ) + + app.set_pixel_scale({"scale": scale}) + + api.set_pixel_scale.assert_not_called() diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index a0d97616..5caea119 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -1225,6 +1225,23 @@ def test_clear_of_a_hidden_highlight_pushes_nothing(self, renderer): renderer._local_view.update.assert_not_called() +class TestHighlightPixelScale: + """The client's device pixel ratio sizes the highlight the next time it is shown.""" + + def test_a_vertex_highlight_after_set_pixel_scale_is_drawn_at_the_scaled_size(self, renderer): + renderer.set_pixel_scale(2.0) + + renderer.show_geometry_highlight(VertexPick(position=[1.0, 2.0, 3.0])) + + # 21 CSS px vertex diameter at 2 device pixels per CSS pixel. + assert renderer._geometry_highlight.actor.GetProperty().GetPointSize() == 42.0 + + def test_set_pixel_scale_pushes_nothing(self, renderer): + renderer.set_pixel_scale(2.0) + + renderer._local_view.update.assert_not_called() + + class TestPickGeometry: diff --git a/tests/unit/vtk/scene/test_local_scene.py b/tests/unit/vtk/scene/test_local_scene.py index 5d07425b..4c680096 100644 --- a/tests/unit/vtk/scene/test_local_scene.py +++ b/tests/unit/vtk/scene/test_local_scene.py @@ -733,6 +733,20 @@ def recording_pick_geometry_in_view(view, x, y, mode): assert depths == [1] +def test_set_pixel_scale_holds_the_lock_at_the_renderer_call(pipeline_instance): + """The renderer's rescale runs with the scene lock held, and passes the scale through.""" + pipeline_instance._vtk_lock = LockSpy() + calls = [] + + def recording_set_pixel_scale(pixel_scale): + calls.append((pixel_scale, pipeline_instance._vtk_lock.depth)) + + pipeline_instance._renderer.set_pixel_scale = recording_set_pixel_scale + pipeline_instance.set_pixel_scale(2.0) + assert calls == [(2.0, 1)] + assert pipeline_instance._vtk_lock.depth == 0 + + # --------------------------------------------------------------------------- # Tests moved from tests/unit/app/test_finalize_scene.py # --------------------------------------------------------------------------- From c28dcbeac48a0e9baf6bf5c7da122abc52ac0dbf Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Mon, 2 Nov 2026 15:53:03 -0700 Subject: [PATCH 2/7] fix: remove render call from data only path --- src/ansys/visor/viewer/renderer/local_renderer.py | 8 ++++++-- tests/unit/renderer/test_local_renderer.py | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 6b3335cb..1f971900 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -309,12 +309,16 @@ def flush_wasm_state(self) -> None: self._local_view.update() def push_without_rebuild(self) -> None: - """Render, then push the whole render window to the wasm client as data only, so its frontend is not rebuilt. + """Push the whole render window to the wasm client as data only, so its frontend is not rebuilt; renders nothing. No object is named, so the window and everything it holds is sent, including an actor that has just become visible. + + The window is not rendered: this runs on the trigger thread, while the + window's GL context is current on the thread that rendered it first + (the API caller's), and a context is current on one thread at a time. + Only serialisation is needed: the wasm client draws the pushed state. """ - self._render_window.Render() self._local_view.update(**{DATA_ONLY_UPDATE_OPTION: True}) @property diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index 5caea119..73faaa0c 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -1220,6 +1220,12 @@ def test_clear_of_a_visible_highlight_pushes_once_with_data_only(self, renderer) renderer._local_view.update.assert_called_once_with(dataOnly=True) + def test_set_does_not_render_the_window(self, renderer): + """The push runs on the trigger thread, which does not own the window's GL context.""" + renderer.show_geometry_highlight(VertexPick(position=[1.0, 2.0, 3.0])) + renderer._render_window.Render.assert_not_called() + renderer._local_view.update.assert_called_once_with(dataOnly=True) + def test_clear_of_a_hidden_highlight_pushes_nothing(self, renderer): renderer.show_geometry_highlight(NoGeometryPick()) renderer._local_view.update.assert_not_called() From 7f8b38fef5f5ff3300d3df5e1eef76d32b5dc0aa Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 3 Nov 2026 08:19:49 -0700 Subject: [PATCH 3/7] fix: guard against resizing a 0x0 wasm window in client mode --- .../src/jest-tests/CanvasPixelSize.test.ts | 24 +++++++++++++++++++ .../visor-client/src/wasm/CanvasPixelSize.ts | 20 ++++++++++++++++ .../visor-client/src/wasm/RemoteVtkScene.js | 11 ++++++--- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts create mode 100644 src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts diff --git a/src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts b/src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts new file mode 100644 index 00000000..e801cdcb --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts @@ -0,0 +1,24 @@ +import { CanvasPixelSize } from '../wasm/CanvasPixelSize'; + +/** + * The wasm render window is sized from its container's CSS box at the + * device pixel ratio. A box that rounds to 0 on either side gives no size, + * so the window keeps its last one: a 0-sized window is never set. + */ + +describe('CanvasPixelSize.of', () => { + test('scales the CSS box by the pixel ratio and rounds to the nearest pixel', () => { + const size = CanvasPixelSize.of(400.3, 300.2, 2); + expect(size).not.toBeNull(); + expect([size!.width, size!.height]).toEqual([801, 600]); + }); + + test.each([ + ['a zero-sized box', 0, 0], + ['a zero width', 0, 300], + ['a zero height', 400, 0], + ['a box that rounds to zero', 0.2, 0.2], + ])('gives no size for %s', (_name, width, height) => { + expect(CanvasPixelSize.of(width, height, 1)).toBeNull(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts b/src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts new file mode 100644 index 00000000..00e08d8b --- /dev/null +++ b/src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts @@ -0,0 +1,20 @@ +/** A render-window size in device pixels, never 0 on either side. */ +export class CanvasPixelSize { + /** The device-pixel size of a CSS box at a pixel ratio, or null when either side rounds to 0. */ + static of(cssWidth: number, cssHeight: number, pixelRatio: number): CanvasPixelSize | null { + const width = Math.floor(cssWidth * pixelRatio + 0.5); + const height = Math.floor(cssHeight * pixelRatio + 0.5); + if (width <= 0 || height <= 0) { + return null; + } + return new CanvasPixelSize(width, height); + } + + private constructor(width: number, height: number) { + this.width = width; + this.height = height; + } + + readonly width: number; + readonly height: number; +} diff --git a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js index a5e817aa..50cda156 100644 --- a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js @@ -1,4 +1,5 @@ import { VtkWASMHandler } from '@kitware/vtk-wasm/remote'; +import { CanvasPixelSize } from './CanvasPixelSize'; import { SerialUpdateQueue } from './SerialUpdateQueue'; import TrameInterface from './TrameInterface.js'; import VtkScene from './VtkScene.js'; @@ -312,13 +313,17 @@ export default class RemoteVtkScene { return this.#wasmHandler.sceneManager.render(this.renderWindowId); }; /** + * Size the wasm window to its container; a container with no area, as before it is placed or while it is + * between frontends, leaves the window at its last size. * @return {Promise} */ resizeAsync = async () => { const { width, height } = this.canvasDiv.getBoundingClientRect(); - const w = Math.floor(width * window.devicePixelRatio + 0.5); - const h = Math.floor(height * window.devicePixelRatio + 0.5); - await this.#wasmHandler.setSize(this.renderWindowId, w, h); + const size = CanvasPixelSize.of(width, height, window.devicePixelRatio); + if (size === null) { + return; + } + await this.#wasmHandler.setSize(this.renderWindowId, size.width, size.height); }; /** * @param {number} wasmId From f2f6084e54e68332e0a32dcaddaf9372e2664482 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:57:53 +0000 Subject: [PATCH 4/7] chore: adding changelog file 204.added.md [dependabot-skip] --- doc/changelog.d/204.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/204.added.md diff --git a/doc/changelog.d/204.added.md b/doc/changelog.d/204.added.md new file mode 100644 index 00000000..c2bc9d9f --- /dev/null +++ b/doc/changelog.d/204.added.md @@ -0,0 +1 @@ +Pick and highlight geometry through the server in client mode From a34332b177b2939cf1fc3dbec1a3470ad321dc6d Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 3 Nov 2026 12:41:27 -0700 Subject: [PATCH 5/7] feat: pick and highlight geometry through the server in client mode --- src/ansys/visor/viewer/app/trame/local_app.py | 33 ++-- src/ansys/visor/viewer/app/visor_vtk_local.py | 8 +- .../requests/geometry_pick_payloads.py | 16 +- .../visor/viewer/renderer/local_renderer.py | 68 -------- .../visor/viewer/vtk/scene/local_scene.py | 11 +- .../visor-client/src/CameraSyncReporter.ts | 59 ++++--- .../WasmRendererWidgetTriggers.test.tsx | 59 +++++++ .../visor-client/src/renderer/WasmRenderer.ts | 18 +- .../visor/visor-client/src/wasm/VtkScene.js | 50 ------ tests/integration/test_visor_local_scene.py | 65 ------- tests/unit/app/test_local_app.py | 2 + .../unit/app/test_local_app_set_projection.py | 2 + tests/unit/app/test_local_app_sync_camera.py | 2 + ...test_local_app_sync_cross_section_plane.py | 1 + .../app/test_local_app_ui_panel_triggers.py | 1 + .../app/test_local_app_widget_triggers.py | 2 + tests/unit/app/test_scene_trigger_app.py | 86 +++++++++- tests/unit/app/test_visor_trame_app.py | 8 + tests/unit/app/test_visor_vtk_local.py | 12 +- tests/unit/renderer/test_local_renderer.py | 162 +----------------- .../vtk/picking/test_cell_feature_finder.py | 109 ++++++++++++ 21 files changed, 369 insertions(+), 405 deletions(-) create mode 100644 tests/unit/vtk/picking/test_cell_feature_finder.py diff --git a/src/ansys/visor/viewer/app/trame/local_app.py b/src/ansys/visor/viewer/app/trame/local_app.py index 1d727a0c..f28f4b0d 100644 --- a/src/ansys/visor/viewer/app/trame/local_app.py +++ b/src/ansys/visor/viewer/app/trame/local_app.py @@ -1,5 +1,5 @@ from logging import Logger -from typing import Protocol +from typing import Callable, Protocol from trame.app.core import Server from trame.decorators import TrameApp, trigger @@ -13,7 +13,10 @@ from ansys.visor.viewer.config import settings from ansys.visor.viewer.core.visor_enums import RenderingMode from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger +from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick +from ansys.visor.viewer.models.runtime.requests.geometry_pick_payloads import PickGeometryInViewPayload from ansys.visor.viewer.models.runtime.requests.pixel_scale_payload import SetPixelScalePayload +from ansys.visor.viewer.vtk.picking.part_cell_hit import PickView logger = VisorDefaultLogger(__name__) @@ -31,13 +34,14 @@ def set_pixel_scale(self, pixel_scale: float) -> None: ... class LocalApp(SceneTriggerApp): """ LocalApp serves the scene triggers shared by every rendering mode, plus - the local-only triggers for sub-geometry picking and client performance - reporting, for client-side rendering via VTK-WASM in the browser. + the local-only triggers for sub-geometry picking through the client's + view and client performance reporting, for client-side rendering via + VTK-WASM in the browser. Attributes: server (Server): the Trame server instance Methods: - pick_geometry: picks the geometry for rendering + pick_geometry_in_view: picks the geometry feature under a pixel of the client's window perf_report_wasm: reports the performance of the wasm update cycle perf_report_server_update: reports the performance of the server update cycle set_pixel_scale: sets the device pixel ratio the geometry highlight is drawn at @@ -49,11 +53,11 @@ def __init__( get_scene_details_json: callable, standalone: bool = True, trame_logger: Logger | None = None, - pick_geometry=None, scene_mutation_api: LocalSceneMutationApi | None = None, + *, + pick_geometry_in_view: Callable[[PickView, int, int, str], GeometryPick], ): - # Callable for sub-geometry picking (optional), local-only. - self._pick_geometry = pick_geometry + self._pick_geometry_in_view = pick_geometry_in_view super().__init__( server=server, get_scene_details_json=get_scene_details_json, @@ -67,12 +71,15 @@ def rendering_mode(self) -> RenderingMode: """This app serves client-side rendering via VTK-WASM in the browser.""" return RenderingMode.CLIENT - @trigger("pick_geometry") - def pick_geometry(self, actor_wasm_id, cell_id, mode: str, world_x: float, world_y: float, world_z: float): - """Frontend → Backend: perform sub-geometry picking given a WASM actor/cell ID and world-space point.""" - if self._pick_geometry is None: - return {"found": False} - return self._pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z) + @trigger("pick_geometry_in_view") + @parse_payload(PickGeometryInViewPayload) + def pick_geometry_in_view(self, payload) -> dict: + """Frontend -> Backend: the geometry feature under a pixel of the client's window, dumped by alias. + + Picked through the client's camera and window size; highlights nothing. + """ + view = PickView(camera_state=payload.camera, width=payload.width, height=payload.height) + return self._pick_geometry_in_view(view, payload.x, payload.y, payload.mode).model_dump(by_alias=True) @trigger("set_pixel_scale") @parse_payload(SetPixelScalePayload) diff --git a/src/ansys/visor/viewer/app/visor_vtk_local.py b/src/ansys/visor/viewer/app/visor_vtk_local.py index 690734b2..77b730ff 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_local.py +++ b/src/ansys/visor/viewer/app/visor_vtk_local.py @@ -44,17 +44,11 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> lambda: self._scene.get_scene_details_json(), standalone, trame_logger=get_trame_logger(trame_log_dir), - pick_geometry=lambda - actor_wasm_id, - cell_id, - mode, - world_x, - world_y, - world_z: self._scene.pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z), # The scene satisfies LocalApp's LocalSceneMutationApi protocol # structurally: the coordinator methods carry exactly the names # and signatures the triggers call. scene_mutation_api=self._scene, + pick_geometry_in_view=self._scene.pick_geometry_in_view, ) def _post_scene_change_hook(self) -> None: diff --git a/src/ansys/visor/viewer/models/runtime/requests/geometry_pick_payloads.py b/src/ansys/visor/viewer/models/runtime/requests/geometry_pick_payloads.py index 673a47df..de52f7a4 100644 --- a/src/ansys/visor/viewer/models/runtime/requests/geometry_pick_payloads.py +++ b/src/ansys/visor/viewer/models/runtime/requests/geometry_pick_payloads.py @@ -1,7 +1,10 @@ """Models for the geometry-pick trigger payloads.""" -from pydantic import BaseModel +from typing import Annotated +from pydantic import BaseModel, Field + +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick, GeometryPickMode @@ -13,6 +16,17 @@ class PickGeometryAtPayload(BaseModel): mode: GeometryPickMode +class PickGeometryInViewPayload(BaseModel): + """pick_geometry_in_view payload: a pixel of the client's window, origin bottom-left, the window's size, its camera record, and the mode.""" + + x: int + y: int + width: Annotated[int, Field(gt=0)] + height: Annotated[int, Field(gt=0)] + mode: GeometryPickMode + camera: VisorCameraState + + class SetGeometryHighlightPayload(BaseModel): """set_geometry_highlight payload: the pick whose feature to highlight; a not-found pick clears it.""" diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index 1f971900..0a35c175 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -23,14 +23,12 @@ from ansys.visor.viewer.core.perf_timer import PerfTimer from ansys.visor.viewer.core.visor_colors import VisorColors from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger -from ansys.visor.viewer.models.runtime.picking.geometry_pick import NoGeometryPick from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import ( WasmNodeHandles, WasmRendererAnnotation, WasmWidgetHandles, ) from ansys.visor.viewer.renderer.pipeline_renderer import VtkPipelineRenderer -from ansys.visor.viewer.vtk.picking.cell_feature_finder import CellFeatureFinder from ansys.visor.viewer.vtk.widgets.visor_bounding_box import VisorBoundingBox from ansys.visor.viewer.vtk.widgets.visor_cross_section import VisorCrossSectionWidget from ansys.visor.viewer.vtk.widgets.visor_orientation import VisorOrientationWidget @@ -217,72 +215,6 @@ def set_cross_section_visibility(self, visible: bool) -> None: def set_bounding_box_visibility(self, visible: bool) -> None: """No-op: the widget's enablement is driven client-side.""" - # ------------------------------------------------------------------ - # Local-only: picking - # ------------------------------------------------------------------ - - def pick_geometry( - self, - actor_wasm_id: int, - cell_id: int, - mode: str, - world_pos: tuple[float, float, float], - ) -> dict: - """ - Resolve a client-initiated pick event to a sub-geometry payload. - - The actor is found by matching each renderer actor's object-manager - id against *actor_wasm_id*; the coordinator never performs that - resolution. - - Parameters - ---------- - actor_wasm_id: - Client-supplied opaque actor identifier from the wasm-side picker. - cell_id: - Cell index within the picked actor's mapper dataset. - mode: - ``"vertex" | "edge" | "face"``. - world_pos: - World-space pick position from the client picker, used to select - the nearest vertex/edge for multi-vertex/edge cells. - - Returns - ------- - dict - Backend-neutral pick result. Always contains ``"found": bool``. - When ``found`` is ``True``, contains ``"mode"`` and mode-specific - fields (``position`` / ``pointA``+``pointB``+``edgeLength`` / - ``points``+``area``). - """ - actor_wasm_id = int(actor_wasm_id) - cell_id = int(cell_id) - pick_pos = (float(world_pos[0]), float(world_pos[1]), float(world_pos[2])) - - if actor_wasm_id < 0 or cell_id < 0: - return NoGeometryPick().model_dump(by_alias=True) - - # Locate the actor by iterating the renderer's actors and matching - # WASM IDs (avoids depending on a GetObjectAtId reverse-lookup API). - actor = None - actors = self._vtk_renderer.GetActors() - actors.InitTraversal() - a = actors.GetNextActor() - while a is not None: - if self._object_manager.GetId(a) == actor_wasm_id: - actor = a - break - a = actors.GetNextActor() - - if actor is None: - return NoGeometryPick().model_dump(by_alias=True) - - finder = CellFeatureFinder.from_dataset( - actor.GetMapper().GetInputDataObject(0, 0), cell_id, pick_pos - ) - pick = finder.find(mode) if finder is not None else NoGeometryPick() - return pick.model_dump(by_alias=True) - # ------------------------------------------------------------------ # IRenderer: render # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/vtk/scene/local_scene.py b/src/ansys/visor/viewer/vtk/scene/local_scene.py index 654659dd..60c71344 100644 --- a/src/ansys/visor/viewer/vtk/scene/local_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/local_scene.py @@ -66,18 +66,9 @@ def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None: self._frontend_bridge.set_state(runtime_app_state) # ------------------------------------------------------------------------- - # Picking — wasm: the client's picker reports a wasm actor id + # Picking — through the client's view, on the server picker # ------------------------------------------------------------------------- - def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) -> dict: - """ - Frontend-trigger entry point for cell picking. Packs the world-space - pick position into a tuple and delegates to the renderer backend. - """ - return self._renderer.pick_geometry( - actor_wasm_id, cell_id, mode, (world_x, world_y, world_z) - ) - def set_pixel_scale(self, pixel_scale: float) -> None: """Draw the geometry highlight at this many device pixels per CSS pixel; no push, no notify. diff --git a/src/ansys/visor/visor-client/src/CameraSyncReporter.ts b/src/ansys/visor/visor-client/src/CameraSyncReporter.ts index 51b02b1b..7c3139ac 100644 --- a/src/ansys/visor/visor-client/src/CameraSyncReporter.ts +++ b/src/ansys/visor/visor-client/src/CameraSyncReporter.ts @@ -16,18 +16,21 @@ import type { WasmRenderer } from './renderer/WasmRenderer'; * event is what the debounce exists to avoid. */ +/** The seven camera fields the server applies and picks through. Mirrors the server's camera model. */ +export type CameraRecord = Readonly<{ + position: readonly number[]; + focalPoint: readonly number[]; + viewUp: readonly number[]; + clippingRange: readonly number[]; + parallelProjection: boolean; + viewAngle: number; + parallelScale: number; +}>; + /** The payload of the `sync_camera` trigger. Mirrors the server's model. */ export type SyncCameraPayload = Readonly<{ origin: CameraOrigin; - camera: Readonly<{ - position: readonly number[]; - focalPoint: readonly number[]; - viewUp: readonly number[]; - clippingRange: readonly number[]; - parallelProjection: boolean; - viewAngle: number; - parallelScale: number; - }>; + camera: CameraRecord; }>; /** @@ -42,7 +45,7 @@ export type CameraSyncSource = Pick< >; /** - * Build the trigger payload from a settled camera snapshot. + * The server's camera record from a camera snapshot. * * The seven applied fields are named one by one, deliberately, rather than * spread from the snapshot. `VisorCameraState` carries five further derived @@ -52,13 +55,30 @@ export type CameraSyncSource = Pick< * accept it silently: pydantic ignores unknown keys, so every server-side * gate would stay green while the wire contract quietly became "whatever the * client's snapshot type happens to hold today". Naming the seven is the only - * place that shape is decided, which is why the test asserts the key set. + * place that shape is decided, which is why the tests assert the key set. * * `parallelProjection` is carried verbatim. `getCameraStateAsync` has already - * narrowed the wasm camera's raw value to a boolean; this module does not + * narrowed the wasm camera's raw value to a boolean; this function does not * re-derive it. + */ +export function cameraRecordOf(camera: VisorCameraState): CameraRecord { + return { + position: camera.position, + focalPoint: camera.focalPoint, + viewUp: camera.viewUp, + clippingRange: camera.clippingRange, + parallelProjection: camera.parallelProjection, + viewAngle: camera.viewAngle, + parallelScale: camera.parallelScale, + }; +} + +/** + * Build the trigger payload from a settled camera snapshot. * - * `origin` is carried verbatim too, for both values. The client never + * The camera is the record `cameraRecordOf` names, never the snapshot. + * + * `origin` is carried verbatim, for both values. The client never * suppresses a report it believes is an echo -- the server decides, and logs * what it dropped. */ @@ -66,18 +86,7 @@ export function buildSyncCameraPayload( origin: CameraOrigin, camera: VisorCameraState ): SyncCameraPayload { - return { - origin, - camera: { - position: camera.position, - focalPoint: camera.focalPoint, - viewUp: camera.viewUp, - clippingRange: camera.clippingRange, - parallelProjection: camera.parallelProjection, - viewAngle: camera.viewAngle, - parallelScale: camera.parallelScale, - }, - }; + return { origin, camera: cameraRecordOf(camera) }; } /** 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 142fd96a..71d7db39 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 @@ -39,6 +39,10 @@ import type { TrameTriggerSender } from '../renderer/IRenderer'; * `set_geometry_highlight` with the pick, so the server draws and drops it; * the scene no longer draws one of its own. * + * Also pinned: a geometry pick sends `pick_geometry_in_view` once, with the + * clicked pixel of the window in render-window pixels, the window's size and + * the camera record, so the server picks through the view the user sees. + * * Modelled on `WasmRendererPartTriggers.test.tsx`, which does the same for * the six per-part sends. */ @@ -135,8 +139,18 @@ async function makeRenderer( nodeIds: number[] = [NODE_ID] ) { const objects = makeFakeWasmObjects(); + // The six camera reads besides the projection return distinctive + // hand-written literals, none a vtkCamera default, so a record carrying + // them can only have come from reading this camera. const camera = { GetParallelProjection: jest.fn(async () => parallelProjection), + GetParallelScale: jest.fn(async () => 7), + GetDistance: jest.fn(async () => 9), + GetPosition: jest.fn(async () => [1, 2, 3]), + GetFocalPoint: jest.fn(async () => [4, 5, 6]), + GetViewUp: jest.fn(async () => [0, 0, 1]), + GetClippingRange: jest.fn(async () => [0.5, 50]), + GetViewAngle: jest.fn(async () => 25), ParallelProjectionOn: jest.fn(async () => undefined), ParallelProjectionOff: jest.fn(async () => undefined), }; @@ -150,6 +164,7 @@ async function makeRenderer( renderWindow: { GetSize: jest.fn(async () => windowSize), }, + getViewPortHeightAsync: jest.fn(async () => windowSize[1]), 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 @@ -478,3 +493,47 @@ describe('WasmRenderer geometry highlight sends', () => { ]); }); }); + +describe('WasmRenderer geometry pick', () => { + // (0.25, 0.25) on 600x400 is pixel (150, 100). A y flip would give 300, + // an x flip 450, and swapped axes (100, 150). + test('pickGeometryAsync sends one pick_geometry_in_view with the window pixel, its size and the camera record', async () => { + const sender = jest.fn(async () => ({ found: false })) as unknown as jest.Mock & + TrameTriggerSender; + const { renderer, scene } = await makeRenderer(sender); + scene.renderWindow.GetSize.mockResolvedValue([600, 400]); + + const result = await renderer.pickGeometryAsync(0.25, 0.25, 'vertex'); + + expect(result).toStrictEqual({ found: false }); + expect(sender.mock.calls).toStrictEqual([ + [ + 'pick_geometry_in_view', + { + x: 150, + y: 100, + width: 600, + height: 400, + mode: 'vertex', + camera: { + position: [1, 2, 3], + focalPoint: [4, 5, 6], + viewUp: [0, 0, 1], + clippingRange: [0.5, 50], + parallelProjection: false, + viewAngle: 25, + parallelScale: 7, + }, + }, + ], + ]); + }); + + test('pickGeometryAsync with no trigger sender rejects', async () => { + const { renderer } = await makeRenderer(null); + + await expect(renderer.pickGeometryAsync(0.25, 0.25, 'vertex')).rejects.toThrow( + 'no trigger sender' + ); + }); +}); diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index 620c37d7..030349fb 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -16,6 +16,9 @@ import { OrthographicWidget } from '../widgets/orthographicWidget.ts'; import { EdgesWidget } from '../widgets/edgesWidget.ts'; import { OrientationWidget } from '../widgets/orientationWidget.ts'; import { SceneTriggerSends } from './SceneTriggerSends'; +import { ViewportPixel } from '../input/ViewportPixel'; +import { GeometryPickReader } from './GeometryPickReader'; +import { cameraRecordOf } from '../CameraSyncReporter'; const SELECTION_TINT_RGB: Readonly = [0 / 255, 62 / 255, 111 / 255]; @@ -32,6 +35,7 @@ export class WasmRenderer implements IRenderer { triggerSender: TrameTriggerSender | null ) { this.#vtkScene = vtkScene; + this.#triggerSender = triggerSender; this.#sends = new SceneTriggerSends(triggerSender); const wasmPlaneId = annotation.widgets.crossSectionPlaneId; @@ -148,6 +152,7 @@ export class WasmRenderer implements IRenderer { } readonly #vtkScene: VtkScene; + readonly #triggerSender: TrameTriggerSender | null; readonly #sends: SceneTriggerSends; readonly #actorIdToNodeId = new Map(); readonly #nodeIdToHandles = new Map(); @@ -508,12 +513,23 @@ export class WasmRenderer implements IRenderer { } // ---- Picking ------------------------------------------------------------ + /** Ask the server for the feature under a point of this window, seen through its camera, in render-window pixels; rejects with no trigger sender. */ async pickGeometryAsync( normX: number, normY: number, mode: GeometryPickMode ): Promise { - return (await this.#vtkScene.pickGeometryAsync(normX, normY, mode)) as PickGeometryResult; + const send = this.#triggerSender; + if (send == null) { + throw new Error('no trigger sender'); + } + const [width, height] = await this.#vtkScene.renderWindow.GetSize(); + const x = ViewportPixel.of(normX, width); + const y = ViewportPixel.of(normY, height); + const camera = cameraRecordOf(await this.getCameraStateAsync()); + return GeometryPickReader.parse( + await send('pick_geometry_in_view', { x, y, width, height, mode, camera }) + ); } /** Ask the server to highlight the picked feature; a not-found result clears it. */ diff --git a/src/ansys/visor/visor-client/src/wasm/VtkScene.js b/src/ansys/visor/visor-client/src/wasm/VtkScene.js index 91cefb3f..b5a96b54 100644 --- a/src/ansys/visor/visor-client/src/wasm/VtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/VtkScene.js @@ -81,8 +81,6 @@ export default class VtkScene { canvas; /**@type{HTMLDivElement}*/ canvasDiv; - /** Last WASM display coords from mouseup – used by pickGeometryAsync. */ - #lastPickDisplayPos = null; /**@type{FrontendVtkObject}*/ camera; /**@type{FrontendVtkObject}*/ @@ -177,53 +175,6 @@ export default class VtkScene { addViewerClickedListener = (handler) => { return this.#viewerClicks.addListener(handler); }; - /** - * Pick geometry at the last clicked position. - * - * The interactor's picker is a vtkCellPicker (set server-side), which - * returns the exact hit cell via GetCellId(). The actor's WASM object ID - * is retrieved from the actor proxy. Both are forwarded to the server so - * it can look up the cell vertices directly - * - * @param {number} normX - Normalized x [0..1] (kept for API compatibility) - * @param {number} normY - Normalized y [0..1] bottom→top (VTK convention) - * @param {string} mode - 'vertex' | 'edge' | 'face' - * @return {Promise<{found:boolean, mode?:string, position?:number[], pointA?:number[], pointB?:number[], edgeLength?:number, area?:number, points?:number[][]}>} - */ - pickGeometryAsync = async (normX, normY, mode) => { - const displayPos = this.#lastPickDisplayPos; - if (!displayPos) return { found: false }; - const [dx, dy] = displayPos; - - // Re-pick at the exact WASM display coordinates. - const found = await this.picker.Pick([dx, dy, 0], this.renderer); - if (!found) return { found: false }; - - const rawPos = await this.picker.GetPickPosition(); - const position = [rawPos[0], rawPos[1], rawPos[2]]; - - // For all modes (vertex, edge, face): use vtkCellPicker's exact cell ID - // and actor WASM ID so the server can snap to the nearest vertex / edge / face. - try { - const cellId = await this.picker.GetCellId(); - if (cellId < 0) return { found: false }; - const actorProxy = await this.picker.GetActor(); - const actorWasmId = actorProxy?.id ?? null; - if (actorWasmId == null) return { found: false }; - return await this.#remoteVtkScene.trameTriggerAsync( - 'pick_geometry', - actorWasmId, - cellId, - mode, - position[0], - position[1], - position[2] - ); - } catch (_) { - return { found: false }; - } - }; - /** * @param {(fps:number)=>void} handler * @return {()=>void} @@ -272,7 +223,6 @@ export default class VtkScene { }; await this.#viewerClicks.dispatchAsync(click, async () => { const [x, y] = await interactor.GetEventPosition(); - this.#lastPickDisplayPos = [x, y]; const found = await picker.Pick([x, y, 0], renderer); const actor = found ? await picker.getActor() : null; return actor?.id ?? null; diff --git a/tests/integration/test_visor_local_scene.py b/tests/integration/test_visor_local_scene.py index 3c481488..61b34f58 100644 --- a/tests/integration/test_visor_local_scene.py +++ b/tests/integration/test_visor_local_scene.py @@ -1,7 +1,6 @@ # python # File: `tests/integration/test_visor_local_scene.py` import json -import math import os from unittest.mock import PropertyMock, patch @@ -190,67 +189,3 @@ def test_list_and_update_variables(pipeline_instance): assert part.variables[0].name == "Normal" assert part.variables[0].type == VisorVtkVariableType.POINT assert part.variables[0].num_components == num_components - - -def test_pick_geometry(pipeline_instance): - """Test that pick_geometry works.""" - file = get_test_file() - dataset = file_to_dataset(file) - metadata = ExtendedMetadata(name="pick_test", unit="m") - dataset_id = pipeline_instance.add_dataset(dataset, metadata) - pipeline_instance.populate_scene() - # ensure VTK pipeline has produced outputs - pipeline_instance.render() - - dataset_node = pipeline_instance._scene_graph.get_descendant_node(dataset_id) - assert dataset_node is not None - actors = [ - pipeline_instance._renderer._pipelines[part.id].actor - for part in dataset_node.get_descendant_part_nodes(include_self=True) - if part.id in pipeline_instance._renderer._pipelines - ] - assert len(actors) > 0 - # choose an actor whose mapper input dataset contains cells - actor = None - for a in actors: - try: - ds = a.GetMapper().GetInputDataObject(0, 0) - if ds is not None and ds.GetNumberOfCells() > 0: - actor = a - pick_ds = ds - break - except Exception: - continue - assert actor is not None - actor_wasm_id = pipeline_instance._renderer._object_manager.GetId(actor) - - # Test face pick for first cell - res_face = pipeline_instance.pick_geometry(actor_wasm_id, 0, 'face', 0.0, 0.0, 0.0) - assert res_face["found"] is True - assert res_face["mode"] == "face" - assert "area" in res_face and res_face["area"] > 0 - assert len(res_face["points"]) >= 3 - - # Test vertex pick using the first point of cell 0 - # Use the mapper's dataset for cell/point selection - cell = pick_ds.GetCell(0) - pts = cell.GetPoints() - p0 = pts.GetPoint(0) - res_vertex = pipeline_instance.pick_geometry(actor_wasm_id, 0, 'vertex', p0[0], p0[1], p0[2]) - assert res_vertex["found"] is True - assert res_vertex["mode"] == "vertex" - assert tuple(res_vertex["position"]) == tuple(p0) - - # Test edge pick for first edge (p0 -> p1) using midpoint - if cell.GetNumberOfPoints() >= 2: - p1 = pts.GetPoint(1) - mid = ((p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0, (p0[2] + p1[2]) / 2.0) - res_edge = pipeline_instance.pick_geometry(actor_wasm_id, 0, 'edge', mid[0], mid[1], mid[2]) - assert res_edge["found"] is True - assert res_edge["mode"] == "edge" - assert tuple(res_edge["pointA"]) == tuple(p0) - assert tuple(res_edge["pointB"]) == tuple(p1) - assert math.isclose(res_edge["edgeLength"], math.dist(p0, p1)) - else: - pytest.skip("not enough points for edge pick test") - diff --git a/tests/unit/app/test_local_app.py b/tests/unit/app/test_local_app.py index 27eefc02..b09d5214 100644 --- a/tests/unit/app/test_local_app.py +++ b/tests/unit/app/test_local_app.py @@ -110,6 +110,7 @@ def app(mock_server, api): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) @@ -120,6 +121,7 @@ def app_without_api(mock_server): server=mock_server, get_scene_details_json=MagicMock(), standalone=True, + pick_geometry_in_view=MagicMock(), ) diff --git a/tests/unit/app/test_local_app_set_projection.py b/tests/unit/app/test_local_app_set_projection.py index bf2af6b2..3e7a8e1b 100644 --- a/tests/unit/app/test_local_app_set_projection.py +++ b/tests/unit/app/test_local_app_set_projection.py @@ -84,6 +84,7 @@ def app(mock_server, api): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) @@ -94,6 +95,7 @@ def app_without_api(mock_server): server=mock_server, get_scene_details_json=MagicMock(), standalone=True, + pick_geometry_in_view=MagicMock(), ) diff --git a/tests/unit/app/test_local_app_sync_camera.py b/tests/unit/app/test_local_app_sync_camera.py index 61c8868d..e35c63ec 100644 --- a/tests/unit/app/test_local_app_sync_camera.py +++ b/tests/unit/app/test_local_app_sync_camera.py @@ -103,6 +103,7 @@ def app(mock_server, api): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) @@ -113,6 +114,7 @@ def app_without_api(mock_server): server=mock_server, get_scene_details_json=MagicMock(), standalone=True, + pick_geometry_in_view=MagicMock(), ) diff --git a/tests/unit/app/test_local_app_sync_cross_section_plane.py b/tests/unit/app/test_local_app_sync_cross_section_plane.py index 38cfd2bc..9b137d89 100644 --- a/tests/unit/app/test_local_app_sync_cross_section_plane.py +++ b/tests/unit/app/test_local_app_sync_cross_section_plane.py @@ -80,6 +80,7 @@ def app(mock_server, api): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) diff --git a/tests/unit/app/test_local_app_ui_panel_triggers.py b/tests/unit/app/test_local_app_ui_panel_triggers.py index 55b5e0e9..b2f00731 100644 --- a/tests/unit/app/test_local_app_ui_panel_triggers.py +++ b/tests/unit/app/test_local_app_ui_panel_triggers.py @@ -85,6 +85,7 @@ def app(mock_server, api): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) diff --git a/tests/unit/app/test_local_app_widget_triggers.py b/tests/unit/app/test_local_app_widget_triggers.py index 07fffb1d..4dbb1ce7 100644 --- a/tests/unit/app/test_local_app_widget_triggers.py +++ b/tests/unit/app/test_local_app_widget_triggers.py @@ -80,6 +80,7 @@ def app(mock_server, api): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) @@ -90,6 +91,7 @@ def app_without_api(mock_server): server=mock_server, get_scene_details_json=MagicMock(), standalone=True, + pick_geometry_in_view=MagicMock(), ) diff --git a/tests/unit/app/test_scene_trigger_app.py b/tests/unit/app/test_scene_trigger_app.py index a10c3ee0..67fe3338 100644 --- a/tests/unit/app/test_scene_trigger_app.py +++ b/tests/unit/app/test_scene_trigger_app.py @@ -10,6 +10,9 @@ 3. ``set_geometry_highlight`` hands the coordinator the parsed pick. 4. ``set_pixel_scale`` hands the coordinator the parsed scale, and a scale that is not positive never reaches it. +5. ``pick_geometry_in_view`` hands the server picker the client's pixel, + window size, camera and mode, and returns its pick dumped by alias; a + window with no width never reaches the picker. The coordinator and payload behaviour of the other 18 shared triggers is already pinned, trigger by trigger, in ``test_local_app.py`` and its sibling @@ -21,7 +24,9 @@ import pytest from ansys.visor.viewer.app.trame.local_app import LocalApp +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState from ansys.visor.viewer.models.runtime.picking.geometry_pick import EdgePick +from ansys.visor.viewer.vtk.picking.part_cell_hit import PickView # The exact 23 trigger names LocalApp registers: the 19 names shared with # every rendering mode, plus LocalApp's own four local-only ones. Written @@ -48,7 +53,7 @@ "set_panel_top_right_legend_collapsed", "set_panel_top_right_tab_index", "set_geometry_highlight", - "pick_geometry", + "pick_geometry_in_view", "perf_report_wasm", "perf_report_server_update", "set_pixel_scale", @@ -97,6 +102,7 @@ def test_local_app_registers_exactly_these_triggers(mock_server): server=mock_server, get_scene_details_json=MagicMock(), standalone=True, + pick_geometry_in_view=MagicMock(), ) names = sorted(call.args[0] for call in mock_server.trigger.call_args_list) @@ -110,6 +116,7 @@ def test_local_app_seeds_rendering_mode_client(mock_server): server=mock_server, get_scene_details_json=MagicMock(), standalone=True, + pick_geometry_in_view=MagicMock(), ) assert mock_server.state["rendering_mode"] == "client" @@ -123,6 +130,7 @@ def test_set_geometry_highlight_forwards_the_parsed_pick(mock_server): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) result = app.set_geometry_highlight( @@ -151,6 +159,7 @@ def test_set_pixel_scale_forwards_the_parsed_scale(mock_server): get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) result = app.set_pixel_scale({"scale": 2.0}) @@ -168,8 +177,83 @@ def test_set_pixel_scale_ignores_a_scale_that_is_not_positive(mock_server, scale get_scene_details_json=MagicMock(), standalone=True, scene_mutation_api=api, + pick_geometry_in_view=MagicMock(), ) app.set_pixel_scale({"scale": scale}) api.set_pixel_scale.assert_not_called() + + +# The camera record and the window, as the client sends them for a pick. +_PICK_CAMERA = { + "position": [1.0, 2.0, 3.0], + "focalPoint": [4.0, 5.0, 6.0], + "viewUp": [0.0, 0.0, 1.0], + "clippingRange": [0.5, 50.0], + "parallelProjection": False, + "viewAngle": 25.0, + "parallelScale": 7.0, +} + + +def test_pick_geometry_in_view_forwards_a_pick_view_and_dumps_by_alias(mock_server): + """The client's pixel, window size, camera and mode reach the server picker; its pick returns keyed by alias.""" + picker = MagicMock( + name="pick_geometry_in_view", + return_value=EdgePick(point_a=[0.0, 0.0, 0.0], point_b=[3.0, 4.0, 0.0], length=5.0), + ) + app = LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + pick_geometry_in_view=picker, + ) + + result = app.pick_geometry_in_view( + {"x": 150, "y": 100, "width": 600, "height": 400, "mode": "edge", "camera": _PICK_CAMERA} + ) + + picker.assert_called_once_with( + PickView( + camera_state=VisorCameraState( + position=[1.0, 2.0, 3.0], + focal_point=[4.0, 5.0, 6.0], + view_up=[0.0, 0.0, 1.0], + clipping_range=[0.5, 50.0], + parallel_projection=False, + view_angle=25.0, + parallel_scale=7.0, + ), + width=600, + height=400, + ), + 150, + 100, + "edge", + ) + assert result == { + "found": True, + "mode": "edge", + "pointA": [0.0, 0.0, 0.0], + "pointB": [3.0, 4.0, 0.0], + "edgeLength": 5.0, + } + + +def test_pick_geometry_in_view_rejects_a_zero_width(mock_server): + """A window with no width has no pixel to pick, so the server picker is never asked.""" + picker = MagicMock(name="pick_geometry_in_view") + app = LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + pick_geometry_in_view=picker, + ) + + result = app.pick_geometry_in_view( + {"x": 0, "y": 0, "width": 0, "height": 400, "mode": "vertex", "camera": _PICK_CAMERA} + ) + + assert result is None + picker.assert_not_called() diff --git a/tests/unit/app/test_visor_trame_app.py b/tests/unit/app/test_visor_trame_app.py index 99f5a84e..ff0e4e94 100644 --- a/tests/unit/app/test_visor_trame_app.py +++ b/tests/unit/app/test_visor_trame_app.py @@ -56,6 +56,7 @@ def test_init_sets_attributes( get_scene_details_json=mock_get_scene_details_json, standalone=True, trame_logger=mock_logger, + pick_geometry_in_view=MagicMock(), ) assert app.server == mock_server assert app._get_scene_details_json == mock_get_scene_details_json @@ -70,6 +71,7 @@ def test_register_lifecycle_hooks_adds_handlers( server=mock_server, get_scene_details_json=mock_get_scene_details_json, standalone=True, + pick_geometry_in_view=MagicMock(), ) assert mock_server.controller.add_call_count >= 5 @@ -82,6 +84,7 @@ def test_set_headers_sets_http_headers( server=mock_server, get_scene_details_json=mock_get_scene_details_json, standalone=True, + pick_geometry_in_view=MagicMock(), ) mock_server.http_headers.set_header.assert_any_call( "Access-Control-Allow-Origin", "*" @@ -103,6 +106,7 @@ def test_set_www_path_sets_www( server=mock_server, get_scene_details_json=mock_get_scene_details_json, standalone=True, + pick_geometry_in_view=MagicMock(), ) assert mock_server._www == "/mock/path" @@ -115,6 +119,7 @@ def test_get_visor_scene_details_json_returns_scene_details( server=mock_server, get_scene_details_json=mock_get_scene_details_json, standalone=True, + pick_geometry_in_view=MagicMock(), ) result = app.get_visor_scene_details_json() assert result == {"scene": "details"} @@ -134,6 +139,7 @@ def test_trame_logger_is_set_when_provided( get_scene_details_json=mock_get_scene_details_json, standalone=True, trame_logger=mock_logger, + pick_geometry_in_view=MagicMock(), ) assert app._SceneTriggerApp__trame_logger == mock_logger @@ -150,6 +156,7 @@ def test_trame_logger_is_none_when_not_provided( server=mock_server, get_scene_details_json=mock_get_scene_details_json, standalone=True, + pick_geometry_in_view=MagicMock(), ) assert app._SceneTriggerApp__trame_logger is None @@ -168,6 +175,7 @@ def test_lifecycle_logging_calls_debug( get_scene_details_json=mock_get_scene_details_json, standalone=True, trame_logger=mock_logger, + pick_geometry_in_view=MagicMock(), ) # Trigger each lifecycle event diff --git a/tests/unit/app/test_visor_vtk_local.py b/tests/unit/app/test_visor_vtk_local.py index e73e8706..4350929a 100644 --- a/tests/unit/app/test_visor_vtk_local.py +++ b/tests/unit/app/test_visor_vtk_local.py @@ -673,6 +673,13 @@ def test_local_app_receives_the_scene_as_the_scene_mutation_api(local_app_call): assert call.kwargs["scene_mutation_api"] is instance._scene +def test_local_app_receives_the_scene_pick_geometry_in_view(local_app_call): + """The scene's own server picker is injected, not a lambda around it.""" + call, scene, _instance = local_app_call + + assert call.kwargs["pick_geometry_in_view"] is scene.pick_geometry_in_view + + def test_local_app_still_receives_the_pre_existing_arguments(local_app_call): """The boundary is extended, not broken: the earlier arguments survive.""" call, scene, instance = local_app_call @@ -681,20 +688,17 @@ def test_local_app_still_receives_the_pre_existing_arguments(local_app_call): assert server is instance.server assert callable(get_scene_details_json) assert standalone is True - assert callable(call.kwargs["pick_geometry"]) assert "trame_logger" in call.kwargs def test_the_pre_existing_lambdas_still_delegate_to_the_scene(local_app_call): - """The two original callables are untouched and still reach the scene.""" + """The original callable is untouched and still reaches the scene.""" call, scene, _instance = local_app_call _server, get_scene_details_json, _standalone = call.args get_scene_details_json() - call.kwargs["pick_geometry"](2, 3, "vertex", 0.0, 1.0, 2.0) scene.get_scene_details_json.assert_called_once_with() - scene.pick_geometry.assert_called_once_with(2, 3, "vertex", 0.0, 1.0, 2.0) @pytest.mark.parametrize("name", SCENE_MUTATION_API_METHODS) diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index 73faaa0c..3ce48138 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -15,8 +15,7 @@ 4. Camera -- reset_camera, sync_camera / get_camera_state, set_projection, each re-serializing the active camera without notifying the client. 5. Render / flush delegation. -6. pick_geometry -- vertex, edge, face modes. -7. Cross-section plane -- the record, the two VTK writes, the two-id +6. Cross-section plane -- the record, the two VTK writes, the two-id re-serialization, and the seed. """ @@ -38,75 +37,6 @@ from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline from ansys.visor.viewer.vtk.widgets.visor_cross_section import VisorCrossSectionWidget -# --------------------------------------------------------------------------- -# Lightweight VTK-data stubs (no real VTK objects required for pick tests) -# --------------------------------------------------------------------------- - -class _DummyPoints: - def __init__(self, pts): - self._pts = pts - - def GetPoint(self, i): # noqa: N802 - return tuple(self._pts[i]) - - -class _DummyCell: - def __init__(self, points): - self._pts = _DummyPoints(points) - self._n = len(points) - - def GetNumberOfPoints(self): # noqa: N802 - return self._n - - def GetPoints(self): # noqa: N802 - return self._pts - - -class _DummyDataset: - def __init__(self, cells): - self._cells = cells - - def GetNumberOfCells(self): # noqa: N802 - return len(self._cells) - - def GetCell(self, idx): # noqa: N802 - return self._cells[idx] if 0 <= idx < len(self._cells) else None - - -class _DummyMapper: - def __init__(self, dataset): - self._ds = dataset - - def GetInputDataObject(self, a, b): # noqa: N802 - return self._ds - - -class _DummyActor: - def __init__(self, mapper): - self._mapper = mapper - - def GetMapper(self): # noqa: N802 - return self._mapper - - -class _DummyActors: - """Minimal traversable actor collection matching vtkActorCollection's API.""" - - def __init__(self, actors): - self._actors = actors - self._idx = 0 - - def InitTraversal(self): # noqa: N802 - self._idx = 0 - - def GetNextActor(self): # noqa: N802 - if self._idx >= len(self._actors): - return None - a = self._actors[self._idx] - self._idx += 1 - return a - - # --------------------------------------------------------------------------- # Camera double # @@ -291,23 +221,6 @@ def renderer(): return r -# --------------------------------------------------------------------------- -# Helper: seed one actor into the renderer's actor list for pick tests -# --------------------------------------------------------------------------- - -def _seed_actor(renderer, actor_wasm_id: int, dataset): - """Wire a dummy actor+dataset into the renderer's VTK actor traversal. - - Returns the actor so callers can make additional assertions. - """ - actor = _DummyActor(_DummyMapper(dataset)) - renderer._vtk_renderer.GetActors.return_value = _DummyActors([actor]) - renderer._object_manager.GetId.side_effect = ( - lambda a: actor_wasm_id if a is actor else -1 - ) - return actor - - # =========================================================================== # 1. Interface conformance # =========================================================================== @@ -1249,79 +1162,8 @@ def test_set_pixel_scale_pushes_nothing(self, renderer): -class TestPickGeometry: - - def test_pick_vertex_returns_nearest_point(self, renderer): - """Vertex mode returns the cell point closest to the pick position.""" - pts = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - dataset = _DummyDataset([_DummyCell(pts)]) - _seed_actor(renderer, 123, dataset) - - res = renderer.pick_geometry(123, 0, "vertex", (0.9, 0.1, 0.0)) - - assert res["found"] is True - assert res["mode"] == "vertex" - assert tuple(res["position"]) == (1.0, 0.0, 0.0) - - def test_pick_edge_returns_closest_edge_endpoints_and_length(self, renderer): - """Edge mode returns the two endpoints of the edge nearest to the pick.""" - pts = [(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)] - dataset = _DummyDataset([_DummyCell(pts)]) - _seed_actor(renderer, 7, dataset) - - res = renderer.pick_geometry(7, 0, "edge", (2.01, 1.0, 0.0)) - - assert res["found"] is True - assert res["mode"] == "edge" - assert tuple(res["pointA"]) == (2.0, 0.0, 0.0) - assert tuple(res["pointB"]) == (2.0, 2.0, 0.0) - assert pytest.approx(res["edgeLength"]) == 2.0 - - def test_pick_face_returns_area_and_all_points(self, renderer): - """Face mode returns the polygon area and every cell point.""" - pts = [(0, 0, 0), (1, 0, 0), (1, 2, 0), (0, 2, 0)] - dataset = _DummyDataset([_DummyCell(pts)]) - _seed_actor(renderer, 5, dataset) - - res = renderer.pick_geometry(5, 0, "face", (0.5, 1.0, 0.0)) - - assert res["found"] is True - assert res["mode"] == "face" - assert pytest.approx(res["area"]) == 2.0 - assert len(res["points"]) == 4 - - def test_pick_negative_actor_id_returns_not_found(self, renderer): - res = renderer.pick_geometry(-1, 0, "vertex", (0.0, 0.0, 0.0)) - assert res == {"found": False} - - def test_pick_negative_cell_id_returns_not_found(self, renderer): - res = renderer.pick_geometry(1, -1, "vertex", (0.0, 0.0, 0.0)) - assert res == {"found": False} - - def test_pick_unregistered_actor_returns_not_found(self, renderer): - """If no actor matches the given wasm id, pick returns not-found.""" - renderer._vtk_renderer.GetActors.return_value = _DummyActors([]) - res = renderer.pick_geometry(42, 0, "vertex", (0.0, 0.0, 0.0)) - assert res["found"] is False - - def test_pick_cell_id_out_of_range_returns_not_found(self, renderer): - """Cell index beyond dataset size returns not-found.""" - dataset = _DummyDataset([_DummyCell([(0, 0, 0), (1, 0, 0), (0, 1, 0)])]) - _seed_actor(renderer, 10, dataset) - res = renderer.pick_geometry(10, 99, "vertex", (0.0, 0.0, 0.0)) - assert res["found"] is False - - def test_pick_unknown_mode_returns_not_found(self, renderer): - """An unrecognised mode string falls through to not-found.""" - pts = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] - dataset = _DummyDataset([_DummyCell(pts)]) - _seed_actor(renderer, 1, dataset) - res = renderer.pick_geometry(1, 0, "INVALID_MODE", (0.0, 0.0, 0.0)) - assert res == {"found": False} - - # =========================================================================== -# 7. The cross-section plane record +# 6. The cross-section plane record # # Hand-written widget double rather than a MagicMock, on the precedent # ``_CameraDouble`` sets in this module: the seed reads ``GetOrigin`` and diff --git a/tests/unit/vtk/picking/test_cell_feature_finder.py b/tests/unit/vtk/picking/test_cell_feature_finder.py new file mode 100644 index 00000000..503b18bb --- /dev/null +++ b/tests/unit/vtk/picking/test_cell_feature_finder.py @@ -0,0 +1,109 @@ +""" +Unit tests for CellFeatureFinder. + +Coverage targets +---------------- +1. find -- vertex, edge and face modes, dumped by alias as the wire carries + them; an unknown mode is not found. +2. from_dataset -- a negative cell id, a cell id past the last cell, and no + dataset at all each give no finder. +""" + +from __future__ import annotations + +import pytest + +from ansys.visor.viewer.vtk.picking.cell_feature_finder import CellFeatureFinder + +# --------------------------------------------------------------------------- +# Lightweight VTK-data stubs (no real VTK objects required) +# --------------------------------------------------------------------------- + +class _DummyPoints: + def __init__(self, pts): + self._pts = pts + + def GetPoint(self, i): # noqa: N802 + return tuple(self._pts[i]) + + +class _DummyCell: + def __init__(self, points): + self._pts = _DummyPoints(points) + self._n = len(points) + + def GetNumberOfPoints(self): # noqa: N802 + return self._n + + def GetPoints(self): # noqa: N802 + return self._pts + + +class _DummyDataset: + def __init__(self, cells): + self._cells = cells + + def GetNumberOfCells(self): # noqa: N802 + return len(self._cells) + + def GetCell(self, idx): # noqa: N802 + return self._cells[idx] if 0 <= idx < len(self._cells) else None + + +class TestCellFeatureFinder: + + def test_vertex_returns_nearest_point(self): + """Vertex mode returns the cell point closest to the pick position.""" + pts = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + dataset = _DummyDataset([_DummyCell(pts)]) + + res = CellFeatureFinder.from_dataset(dataset, 0, (0.9, 0.1, 0.0)).find("vertex").model_dump(by_alias=True) + + assert res["found"] is True + assert res["mode"] == "vertex" + assert tuple(res["position"]) == (1.0, 0.0, 0.0) + + def test_edge_returns_closest_edge_endpoints_and_length(self): + """Edge mode returns the two endpoints of the edge nearest to the pick.""" + pts = [(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)] + dataset = _DummyDataset([_DummyCell(pts)]) + + res = CellFeatureFinder.from_dataset(dataset, 0, (2.01, 1.0, 0.0)).find("edge").model_dump(by_alias=True) + + assert res["found"] is True + assert res["mode"] == "edge" + assert tuple(res["pointA"]) == (2.0, 0.0, 0.0) + assert tuple(res["pointB"]) == (2.0, 2.0, 0.0) + assert pytest.approx(res["edgeLength"]) == 2.0 + + def test_face_returns_area_and_all_points(self): + """Face mode returns the polygon area and every cell point.""" + pts = [(0, 0, 0), (1, 0, 0), (1, 2, 0), (0, 2, 0)] + dataset = _DummyDataset([_DummyCell(pts)]) + + res = CellFeatureFinder.from_dataset(dataset, 0, (0.5, 1.0, 0.0)).find("face").model_dump(by_alias=True) + + assert res["found"] is True + assert res["mode"] == "face" + assert pytest.approx(res["area"]) == 2.0 + assert len(res["points"]) == 4 + + def test_negative_cell_id_is_not_found(self): + dataset = _DummyDataset([_DummyCell([(0, 0, 0), (1, 0, 0), (0, 1, 0)])]) + assert CellFeatureFinder.from_dataset(dataset, -1, (0.0, 0.0, 0.0)) is None + + def test_cell_id_out_of_range_is_not_found(self): + """Cell index beyond dataset size gives no finder.""" + dataset = _DummyDataset([_DummyCell([(0, 0, 0), (1, 0, 0), (0, 1, 0)])]) + assert CellFeatureFinder.from_dataset(dataset, 99, (0.0, 0.0, 0.0)) is None + + def test_unknown_mode_returns_not_found(self): + """An unrecognised mode string falls through to not-found.""" + pts = [(0, 0, 0), (1, 0, 0), (0, 1, 0)] + dataset = _DummyDataset([_DummyCell(pts)]) + res = CellFeatureFinder.from_dataset(dataset, 0, (0.0, 0.0, 0.0)).find("INVALID_MODE").model_dump(by_alias=True) + assert res == {"found": False} + + def test_none_dataset_is_not_found(self): + """A picked actor whose mapper has no input dataset gives no finder.""" + assert CellFeatureFinder.from_dataset(None, 0, (0.0, 0.0, 0.0)) is None From e19cecaa50e1f6d5e7c713a457995f67ae111ff9 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Tue, 3 Nov 2026 13:46:23 -0700 Subject: [PATCH 6/7] chore: skip resizing the wasm window when canvas size has not changed --- .../src/jest-tests/CanvasPixelSize.test.ts | 10 ++++++++++ .../visor/visor-client/src/wasm/CanvasPixelSize.ts | 5 +++++ .../visor/visor-client/src/wasm/RemoteVtkScene.js | 11 +++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts b/src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts index e801cdcb..c3a06aa7 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/CanvasPixelSize.test.ts @@ -22,3 +22,13 @@ describe('CanvasPixelSize.of', () => { expect(CanvasPixelSize.of(width, height, 1)).toBeNull(); }); }); + +describe('CanvasPixelSize.equals', () => { + test('is true for the same size and false for a different width, height or null', () => { + const size = CanvasPixelSize.of(400, 300, 1)!; + expect(size.equals(CanvasPixelSize.of(400, 300, 1))).toBe(true); + expect(size.equals(CanvasPixelSize.of(401, 300, 1))).toBe(false); + expect(size.equals(CanvasPixelSize.of(400, 301, 1))).toBe(false); + expect(size.equals(null)).toBe(false); + }); +}); diff --git a/src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts b/src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts index 00e08d8b..93756948 100644 --- a/src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts +++ b/src/ansys/visor/visor-client/src/wasm/CanvasPixelSize.ts @@ -15,6 +15,11 @@ export class CanvasPixelSize { this.height = height; } + /** Whether other is the same device-pixel size; false for null. */ + equals(other: CanvasPixelSize | null): boolean { + return other !== null && other.width === this.width && other.height === this.height; + } + readonly width: number; readonly height: number; } diff --git a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js index 50cda156..f36b5821 100644 --- a/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/RemoteVtkScene.js @@ -314,17 +314,24 @@ export default class RemoteVtkScene { }; /** * Size the wasm window to its container; a container with no area, as before it is placed or while it is - * between frontends, leaves the window at its last size. + * between frontends, leaves the window at its last size. A size equal to the one last applied is not set + * again: vtk-wasm's `setSize` renders, and its `update` already re-applies the last size it was given. * @return {Promise} */ resizeAsync = async () => { const { width, height } = this.canvasDiv.getBoundingClientRect(); const size = CanvasPixelSize.of(width, height, window.devicePixelRatio); - if (size === null) { + if (size === null || size.equals(this.#appliedSize)) { return; } await this.#wasmHandler.setSize(this.renderWindowId, size.width, size.height); + this.#appliedSize = size; }; + /** + * The size last handed to vtk-wasm's `setSize`, recorded once it resolves; null until the first. + * @type {?CanvasPixelSize} + */ + #appliedSize = null; /** * @param {number} wasmId * @return {FrontendVtkObject} From 53b5e220e5f8195bd849a673bbc2adad7c8d7444 Mon Sep 17 00:00:00 2001 From: Laura Kasian Date: Wed, 4 Nov 2026 09:36:29 -0700 Subject: [PATCH 7/7] fix: read the picked actor with invoke instead of a property read --- src/ansys/visor/visor-client/src/wasm/VtkScene.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ansys/visor/visor-client/src/wasm/VtkScene.js b/src/ansys/visor/visor-client/src/wasm/VtkScene.js index b5a96b54..77315671 100644 --- a/src/ansys/visor/visor-client/src/wasm/VtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/VtkScene.js @@ -224,7 +224,7 @@ export default class VtkScene { await this.#viewerClicks.dispatchAsync(click, async () => { const [x, y] = await interactor.GetEventPosition(); const found = await picker.Pick([x, y, 0], renderer); - const actor = found ? await picker.getActor() : null; + const actor = found ? await picker.GetActor() : null; return actor?.id ?? null; }); } finally {