diff --git a/doc/changelog.d/196.added.md b/doc/changelog.d/196.added.md new file mode 100644 index 00000000..fa85a7ed --- /dev/null +++ b/doc/changelog.d/196.added.md @@ -0,0 +1 @@ +Remote rendering 4.5b: Send the cross-section plane to the server-mode client diff --git a/src/ansys/visor/dash/src/lib/components/Visordash.react.js b/src/ansys/visor/dash/src/lib/components/Visordash.react.js index a6f68b69..dc241a0f 100644 --- a/src/ansys/visor/dash/src/lib/components/Visordash.react.js +++ b/src/ansys/visor/dash/src/lib/components/Visordash.react.js @@ -83,7 +83,15 @@ const Visordash = (props) => { (async () => { const snapFn = window.__visorState?.getAppStateAsync; if (typeof snapFn === 'function' && typeof setPropsRef.current === 'function') { - const appState = await snapFn(/*pass 'true' to key by name*/ true); + let appState; + try { + appState = await snapFn(/*pass 'true' to key by name*/ true); + } catch (err) { + // The last snapshot is kept; fireSnapshot is reset so the next request runs. + console.error('[VISOR] snapshot failed', err); + setPropsRef.current({ fireSnapshot: false }); + return; + } setPropsRef.current({ parts: appState.toDict(), fireSnapshot: false, @@ -145,6 +153,7 @@ Visordash.propTypes = { fireSnapshot: PropTypes.bool, /** * Contains the latest snapshot of the parts state, returned from the frontend when `fireSnapshot` is triggered. + * A failed snapshot leaves it unchanged and logs `[VISOR] snapshot failed` to the browser console. */ parts: PropTypes.any, /** diff --git a/src/ansys/visor/viewer/app/trame/server_state_publisher.py b/src/ansys/visor/viewer/app/trame/server_state_publisher.py index b1a750c1..7691f412 100644 --- a/src/ansys/visor/viewer/app/trame/server_state_publisher.py +++ b/src/ansys/visor/viewer/app/trame/server_state_publisher.py @@ -15,16 +15,20 @@ from ansys.visor.viewer.app.trame.async_runner import AsyncRunner from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState -from ansys.visor.viewer.renderer.server_renderer import CameraPublisher +from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState +from ansys.visor.viewer.renderer.server_renderer import ViewRecordPublisher SERVER_CAMERA_STATE_KEY: str = "server_camera_state" """State key carrying the server's camera record, as its camelCase fields, or None.""" +SERVER_CROSS_SECTION_STATE_KEY: str = "server_cross_section_state" +"""State key carrying the server's cross-section plane record, as ``origin`` and ``normal``, or None.""" + SCENE_VERSION_KEY: str = "scene_version" """State key counting scene changes; absent until the first change, then 1, 2, ...""" -class ServerStatePublisher(CameraPublisher): +class ServerStatePublisher(ViewRecordPublisher): """Writes server-originated state keys from any thread onto the trame event loop.""" _async_runner: AsyncRunner @@ -44,6 +48,15 @@ def publish_camera(self, camera_state: VisorCameraState | None) -> None: camera_record = None if camera_state is None else camera_state.model_dump(by_alias=True) self._async_runner.call_soon(lambda: self._write(SERVER_CAMERA_STATE_KEY, camera_record)) + def publish_cross_section(self, cross_section_state: VisorCrossSectionState | None) -> None: + """Dump the record by alias on the calling thread, or None, then write server_cross_section_state on the loop thread.""" + cross_section_record = ( + None if cross_section_state is None else cross_section_state.model_dump(by_alias=True) + ) + self._async_runner.call_soon( + lambda: self._write(SERVER_CROSS_SECTION_STATE_KEY, cross_section_record) + ) + def bump_scene_version(self) -> None: """Increment scene_version on the loop thread: read, add one, write and flush in one callback; callable from any thread. diff --git a/src/ansys/visor/viewer/app/visor_vtk_server.py b/src/ansys/visor/viewer/app/visor_vtk_server.py index 5ab43b3a..2e0cf065 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_server.py +++ b/src/ansys/visor/viewer/app/visor_vtk_server.py @@ -32,14 +32,15 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> One bridge serves both: the scene's renderer requests frames through it, and the app binds it to the view handler when the server is ready. - The publisher writes the renderer's camera record into this viewer's - server state, and the scene_version bump after each scene change. + The publisher writes the renderer's camera and cross-section plane + records into this viewer's server state, and the scene_version bump + after each scene change. """ bridge = ServerLoopBridge(self._server_manager.async_runner) self._state_publisher = ServerStatePublisher(self._server_manager.async_runner, self.server.state) self._scene = VisorServerScene( - self.server, dark_mode=self._dark_mode, bridge=bridge, camera_publisher=self._state_publisher + self.server, dark_mode=self._dark_mode, bridge=bridge, view_record_publisher=self._state_publisher ) self._trame_app = ServerApp( diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index 7e66c172..296e0fa4 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -6,8 +6,9 @@ Owns an off-screen VTK pipeline and presents each write by asking a :class:`FrameRequester` for a streamed frame. It never calls ``Render()`` itself: every render happens on the streaming path, under the scene lock. -Each camera write also hands the camera record to a :class:`CameraPublisher`, -since a client of a streamed view has no camera of its own to read. +Each camera and cross-section plane write also hands the record to a +:class:`ViewRecordPublisher`, since a client of a streamed view has no camera +or plane of its own to read. """ from __future__ import annotations @@ -21,6 +22,7 @@ from vtkmodules.vtkRenderingCore import vtkRenderWindow from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState +from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState from ansys.visor.viewer.renderer.pipeline_renderer import VtkPipelineRenderer INITIAL_WINDOW_SIZE: tuple[int, int] = (300, 300) @@ -34,33 +36,36 @@ def request_frame(self) -> None: """Ask for a streamed frame; callable from any thread.""" -class CameraPublisher(Protocol): - """Where a ServerRenderer publishes its camera record.""" +class ViewRecordPublisher(Protocol): + """Where a ServerRenderer publishes its camera and cross-section plane records.""" def publish_camera(self, camera_state: VisorCameraState | None) -> None: """Publish the camera record to clients, or None when there is none; callable from any thread.""" + def publish_cross_section(self, cross_section_state: VisorCrossSectionState | None) -> None: + """Publish the plane record to clients, or None when there is none; callable from any thread.""" + class ServerRenderer(VtkPipelineRenderer): """Off-screen VTK pipeline whose writes are presented by requesting a streamed frame.""" _background_rgb: tuple[float, float, float] _frame_requester: FrameRequester - _camera_publisher: CameraPublisher + _view_record_publisher: ViewRecordPublisher _bounding_box_visible: bool def __init__( self, background_rgb: tuple[float, float, float], frame_requester: FrameRequester, - camera_publisher: CameraPublisher, + view_record_publisher: ViewRecordPublisher, ) -> None: - """Build the off-screen pipeline; every write ends in a frame request, every camera write also publishes the record.""" + """Build the off-screen pipeline; every write ends in a frame request, every camera or plane write also publishes the record.""" # All three are read during the base build: the background by # _initialize_vtk_renderer, the requester and publisher by any publish hook. self._background_rgb = background_rgb self._frame_requester = frame_requester - self._camera_publisher = camera_publisher + self._view_record_publisher = view_record_publisher self._bounding_box_visible = False super().__init__() self._cross_section_widget.link_plane_to_handle() @@ -102,19 +107,21 @@ def record_pipeline_camera(self) -> None: self._publish_camera_record() def record_cross_section_plane(self) -> None: - """Record a dragged clip plane through the ``sync_cross_section_plane`` step; requests no frame. + """Record a dragged clip plane through the ``sync_cross_section_plane`` step and publish it; requests no frame. Does nothing when no drag step ran since the last call. Reads the clip plane, not the handle: the handle clamps its origin to the widget bounds and normalises its normal, so reading it would rewrite a loaded - plane that no drag touched. Nothing is published: the plane's only - publish is a frame request, and the caller is producing a frame - already. Takes no lock; the caller holds the scene lock. + plane that no drag touched. The step is + :meth:`sync_cross_section_plane`'s without its frame request: the + caller is producing a frame already. Takes no lock; the caller holds + the scene lock. """ if not self._cross_section_widget.take_handle_moved(): return plane = self._cross_section_widget.plane self._write_cross_section(list(plane.GetOrigin()), list(plane.GetNormal())) + self._publish_cross_section_record() # ------------------------------------------------------------------ # IRenderer: wire contract @@ -142,6 +149,15 @@ def set_bounding_box_visibility(self, visible: bool) -> None: self._apply_bounding_box_visibility() self._frame_requester.request_frame() + def update_bounds(self, bounds: list[float]) -> None: + """See :meth:`VtkPipelineRenderer.update_bounds`; then publish the plane record, which the first call seeds; requests no frame. + + Publishes after the base call, so the first call publishes the seeded + record rather than ``None``. + """ + super().update_bounds(bounds) + self._publish_cross_section_record() + def update_actor_count(self, count: int) -> None: """Record the part count and re-apply the remembered outline visibility. @@ -183,13 +199,18 @@ def _publish_camera(self) -> None: self._frame_requester.request_frame() def _publish_camera_record(self) -> None: - """Hand the camera record to the camera publisher; requests no frame.""" - self._camera_publisher.publish_camera(self.get_camera_state()) + """Hand the camera record to the record publisher; requests no frame.""" + self._view_record_publisher.publish_camera(self.get_camera_state()) def _publish_cross_section(self) -> None: - """Request a frame showing the written plane.""" + """Publish the plane record, then request a frame showing the written plane.""" + self._publish_cross_section_record() self._frame_requester.request_frame() + def _publish_cross_section_record(self) -> None: + """Hand the plane record to the record publisher; requests no frame.""" + self._view_record_publisher.publish_cross_section(self.get_cross_section_plane()) + # ------------------------------------------------------------------ # VtkPipelineRenderer: backend hooks # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/vtk/scene/server_scene.py b/src/ansys/visor/viewer/vtk/scene/server_scene.py index e6c42b17..3775f47f 100644 --- a/src/ansys/visor/viewer/vtk/scene/server_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/server_scene.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState - from ansys.visor.viewer.renderer.server_renderer import CameraPublisher + from ansys.visor.viewer.renderer.server_renderer import ViewRecordPublisher BACKGROUND_RGB: dict[bool, tuple[float, float, float]] = { False: (247 / 255, 247 / 255, 247 / 255), @@ -47,10 +47,10 @@ def __init__( server: Server, dark_mode: bool, bridge: ServerLoopBridge, - camera_publisher: CameraPublisher, + view_record_publisher: ViewRecordPublisher, ) -> None: """Build the ServerRenderer and the RCA window over this scene's lock.""" - renderer = ServerRenderer(BACKGROUND_RGB[dark_mode], bridge, camera_publisher) + renderer = ServerRenderer(BACKGROUND_RGB[dark_mode], bridge, view_record_publisher) super().__init__(server, dark_mode, renderer=renderer) self._rca_window = VisorRcaWindow( VtkRemoteControlledArea(renderer.render_window), diff --git a/src/ansys/visor/visor-client/src/jest-tests/ServerRendererCache.test.ts b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererCache.test.ts index 12df5b21..e5254add 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/ServerRendererCache.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererCache.test.ts @@ -3,9 +3,10 @@ import type { IServerCanvas } from '../rca/ServerCanvas'; /** * The server-rendering renderer keeps a cache the server feeds: the camera - * from the camera state key, the widget flags from its own writes. A - * no-argument toggle sends the value it settled on; the camera key is the - * echo of the client's own camera writes, so receiving it sends nothing. + * and the cross-section plane from their state keys, the widget flags from + * its own writes. A no-argument toggle sends the value it settled on; the + * camera key is the echo of the client's own camera writes, so receiving it + * sends nothing. * * Expected values are hand-written literals. */ @@ -23,28 +24,31 @@ function cameraRecord(position: number[], focalPoint: number[]) { }; } -/** A trame state holding one camera key value whose watchers can be told of a new one. */ +/** A trame state holding one value per key whose watchers can be told of a new one. */ class FakeState { - constructor(value: unknown) { - this.#value = value; + constructor(values: Record) { + this.#values = new Map(Object.entries(values)); } watch(keys: string[], fn: (...values: unknown[]) => void): () => void { - this.#watchers.push(fn); - fn(this.#value); + const key = keys[0]; + this.#watchers.push({ key, fn }); + fn(this.#values.get(key)); return () => {}; } - /** Write the camera key and notify its watchers, as a server push does. */ - push(value: unknown): void { - this.#value = value; - for (const fn of this.#watchers) { - fn(value); + /** Write one key and notify that key's watchers, as a server push does. */ + push(key: string, value: unknown): void { + this.#values.set(key, value); + for (const watcher of this.#watchers) { + if (watcher.key === key) { + watcher.fn(value); + } } } - #value: unknown; - readonly #watchers: ((...values: unknown[]) => void)[] = []; + readonly #values: Map; + readonly #watchers: { key: string; fn: (...values: unknown[]) => void }[] = []; } function stubCanvas(): IServerCanvas { @@ -57,7 +61,7 @@ function stubCanvas(): IServerCanvas { }; } -function makeRenderer(initial: unknown) { +function makeRenderer(initial: Record) { const state = new FakeState(initial); const sender = jest.fn(async (_name: string, _payload: unknown) => undefined); const renderer = new ServerRenderer(stubCanvas(), state as ServerStateSource, sender); @@ -66,7 +70,7 @@ function makeRenderer(initial: unknown) { describe('ServerRenderer toggles', () => { test('a no-argument bounding-box toggle sends the value it settled on', async () => { - const { renderer, sender } = makeRenderer(undefined); + const { renderer, sender } = makeRenderer({}); await renderer.setBoundingBoxVisibilityAsync(); @@ -76,7 +80,7 @@ describe('ServerRenderer toggles', () => { }); test('a no-argument bounding-box toggle flips the cached flag', async () => { - const { renderer } = makeRenderer(undefined); + const { renderer } = makeRenderer({}); await renderer.setBoundingBoxVisibilityAsync(); @@ -86,9 +90,11 @@ describe('ServerRenderer toggles', () => { describe('ServerRenderer camera cache', () => { test('a camera record replaces the cached camera', async () => { - const { renderer, state } = makeRenderer(cameraRecord([0, 0, 9], [0, 0, 0])); + const { renderer, state } = makeRenderer({ + server_camera_state: cameraRecord([0, 0, 9], [0, 0, 0]), + }); - state.push(cameraRecord([1, 2, 3], [4, 6, 3])); + state.push('server_camera_state', cameraRecord([1, 2, 3], [4, 6, 3])); const camera = await renderer.getCameraStateAsync(); expect(camera.position).toEqual([1, 2, 3]); @@ -96,24 +102,28 @@ describe('ServerRenderer camera cache', () => { }); test('a camera record sends nothing', async () => { - const { state, sender } = makeRenderer(cameraRecord([0, 0, 9], [0, 0, 0])); + const { state, sender } = makeRenderer({ + server_camera_state: cameraRecord([0, 0, 9], [0, 0, 0]), + }); - state.push(cameraRecord([1, 2, 3], [4, 6, 3])); + state.push('server_camera_state', cameraRecord([1, 2, 3], [4, 6, 3])); expect(sender).not.toHaveBeenCalled(); }); test('None after a record leaves the cached camera', async () => { - const { renderer, state } = makeRenderer(cameraRecord([1, 2, 3], [4, 6, 3])); + const { renderer, state } = makeRenderer({ + server_camera_state: cameraRecord([1, 2, 3], [4, 6, 3]), + }); - state.push(null); + state.push('server_camera_state', null); const camera = await renderer.getCameraStateAsync(); expect(camera.position).toEqual([1, 2, 3]); }); test('the camera read rejects before any record arrives', async () => { - const { renderer } = makeRenderer(undefined); + const { renderer } = makeRenderer({}); await expect(renderer.getCameraStateAsync()).rejects.toMatchObject({ name: 'CameraStateUnavailableError', @@ -121,6 +131,28 @@ describe('ServerRenderer camera cache', () => { }); }); +describe('ServerRenderer cross-section cache', () => { + test('a cross-section record fills the plane reads', async () => { + const { renderer, state } = makeRenderer({}); + + state.push('server_cross_section_state', { origin: [1, 2, 3], normal: [0, 0, 1] }); + + expect(await renderer.getCrossSectionOriginAsync()).toEqual([1, 2, 3]); + expect(await renderer.getCrossSectionNormalAsync()).toEqual([0, 0, 1]); + }); + + test('None after a cross-section record leaves the cached plane', async () => { + const { renderer, state } = makeRenderer({ + server_cross_section_state: { origin: [1, 2, 3], normal: [0, 0, 1] }, + }); + + state.push('server_cross_section_state', null); + + expect(await renderer.getCrossSectionOriginAsync()).toEqual([1, 2, 3]); + expect(await renderer.getCrossSectionNormalAsync()).toEqual([0, 0, 1]); + }); +}); + describe('ServerRenderer canvas resize', () => { let originalDevicePixelRatio: number; @@ -150,7 +182,7 @@ describe('ServerRenderer canvas resize', () => { return () => {}; }, }; - const state = new FakeState(cameraRecord([0, 0, 9], [0, 0, 0])); + const state = new FakeState({ server_camera_state: cameraRecord([0, 0, 9], [0, 0, 0]) }); const renderer = new ServerRenderer(canvas, state as ServerStateSource, jest.fn()); const listener = jest.fn(); renderer.addCameraChangedListener(listener); diff --git a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts index 97a303a5..94e73aa3 100644 --- a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts @@ -14,6 +14,9 @@ import { SceneTriggerSends } from './SceneTriggerSends'; /** The trame state key the server writes the camera record to. */ export const SERVER_CAMERA_STATE_KEY = 'server_camera_state'; +/** The trame state key the server writes the cross-section plane record to. */ +export const SERVER_CROSS_SECTION_STATE_KEY = 'server_cross_section_state'; + /** The part of trame's state the renderer reads. */ export type ServerStateSource = Readonly<{ watch(keys: string[], fn: (...values: unknown[]) => void): () => void; @@ -54,6 +57,12 @@ type CameraRecord = Readonly<{ parallelScale: number; }>; +/** The cross-section plane the server publishes. */ +type CrossSectionRecord = Readonly<{ + origin: readonly number[]; + normal: readonly number[]; +}>; + /** The seven camera fields the server publishes, cached as they arrive. */ class CameraRecordCache { /** Read a server_camera_state value: the record, or null for None/absent; throws TypeError if malformed. */ @@ -141,7 +150,7 @@ class CameraRecordCache { /** IRenderer for server rendering: frames come from the canvas, writes go to the server, reads come from a cache. */ export class ServerRenderer implements IRenderer { - /** Watch server_camera_state on state, show canvas, and send every mutation through triggerSender. */ + /** Watch the camera and plane keys on state, show canvas, and send every mutation through triggerSender. */ constructor( canvas: IServerCanvas, state: ServerStateSource, @@ -153,6 +162,10 @@ export class ServerRenderer implements IRenderer { this.domElement = canvas.element; // `watch` also calls back at once with the key's current value. this.#unwatch = state.watch([SERVER_CAMERA_STATE_KEY], this.#onCameraState); + this.#unwatchCrossSection = state.watch( + [SERVER_CROSS_SECTION_STATE_KEY], + this.#onCrossSectionState + ); this.#removeClickListener = canvas.addClickListener(this.#onCanvasClick); this.#removeResizeListener = canvas.addResizeListener(this.#onCanvasResize); } @@ -161,6 +174,7 @@ export class ServerRenderer implements IRenderer { readonly #triggerSender: TrameTriggerSender; readonly #sends: SceneTriggerSends; readonly #unwatch: () => void; + readonly #unwatchCrossSection: () => void; readonly #removeClickListener: () => void; readonly #removeResizeListener: () => void; readonly #camera = new CameraRecordCache(); @@ -193,6 +207,48 @@ export class ServerRenderer implements IRenderer { this.#fireCameraChanged(); }; + /** Take the server's plane record into the cache; nothing is sent from here. */ + #onCrossSectionState = (value: unknown): void => { + let record: CrossSectionRecord | null; + try { + record = ServerRenderer.#parseCrossSection(value); + } catch (err) { + console.error('[VISOR] server_cross_section_state malformed', err); + return; + } + if (record == null) { + return; + } + this.#crossSectionOrigin = record.origin; + this.#crossSectionNormal = record.normal; + }; + + /** Read a server_cross_section_state value: the record, or null for None/absent; throws TypeError if malformed. */ + static #parseCrossSection(value: unknown): CrossSectionRecord | null { + if (value == null) { + return null; + } + if (typeof value !== 'object') { + throw new TypeError(`cross-section record is not an object`); + } + const obj = value as Record; + return { + origin: ServerRenderer.#planeVector(obj.origin, 'origin'), + normal: ServerRenderer.#planeVector(obj.normal, 'normal'), + }; + } + + static #planeVector(value: unknown, name: string): readonly number[] { + if ( + !Array.isArray(value) || + value.length !== 3 || + !value.every((v) => typeof v === 'number') + ) { + throw new TypeError(`cross-section record ${name} is not 3 numbers`); + } + return [...value]; + } + /** Pick the part under a click on the last frame, then tell every viewer-clicked listener once. */ #onCanvasClick = (click: CanvasClick): void => { const frameSize = this.#canvas.frameSize; @@ -283,6 +339,7 @@ export class ServerRenderer implements IRenderer { dispose(): void { this.#unwatch(); + this.#unwatchCrossSection(); this.#removeClickListener(); this.#removeResizeListener(); this.#cameraChangedListeners.clear(); diff --git a/tests/integration/test_server_window_render.py b/tests/integration/test_server_window_render.py index daa2ad95..caaf63a0 100644 --- a/tests/integration/test_server_window_render.py +++ b/tests/integration/test_server_window_render.py @@ -31,15 +31,19 @@ def request_frame(self) -> None: self.count += 1 -class _RecordingCameraPublisher: - """Records every published camera record.""" +class _RecordingViewRecordPublisher: + """Records every published camera and plane record.""" def __init__(self) -> None: self.cameras: list = [] + self.planes: list = [] def publish_camera(self, camera_state) -> None: self.cameras.append(camera_state) + def publish_cross_section(self, cross_section_state) -> None: + self.planes.append(cross_section_state) + def test_first_frame_is_background(): renderer = ServerRenderer((0.2, 0.4, 0.6), _RecordingFrameRequester(), MagicMock(name="camera_publisher")) @@ -68,8 +72,8 @@ def requester() -> _RecordingFrameRequester: @pytest.fixture -def publisher() -> _RecordingCameraPublisher: - return _RecordingCameraPublisher() +def publisher() -> _RecordingViewRecordPublisher: + return _RecordingViewRecordPublisher() @pytest.fixture @@ -155,13 +159,14 @@ def test_click_on_orientation_ball_records_snapped_camera(window, renderer): @pytest.fixture -def plane_window(renderer, requester) -> VisorRcaWindow: - """The sphere scene with the widget placed on its bounds and turned on; one frame drawn, requests cleared.""" +def plane_window(renderer, requester, publisher) -> VisorRcaWindow: + """The sphere scene with the widget placed on its bounds and turned on; one frame drawn, recorders cleared.""" renderer.update_bounds([-0.5, 0.5, -0.5, 0.5, -0.5, 0.5]) renderer.set_cross_section_visibility(True) w = VisorRcaWindow(VtkRemoteControlledArea(renderer.render_window), renderer, threading.RLock(), lambda: None) w.img_cols_rows requester.count = 0 + publisher.planes.clear() return w @@ -190,6 +195,14 @@ def test_plane_drag_records_plane_at_release_only(plane_window, renderer): assert renderer.get_cross_section_plane().normal != [0.0, 0.0, 1.0] +def test_plane_drag_publishes_plane_at_release_only(plane_window, renderer, publisher): + _drag_plane_handle(plane_window) + assert publisher.planes == [] + _release_plane_handle(plane_window) + assert len(publisher.planes) == 1 + assert publisher.planes[0] is renderer.get_cross_section_plane() + + def test_plane_drag_release_requests_no_frame(plane_window, requester): _drag_plane_handle(plane_window) _release_plane_handle(plane_window) diff --git a/tests/unit/app/test_server_state_publisher.py b/tests/unit/app/test_server_state_publisher.py index 274f51e9..4a1f70be 100644 --- a/tests/unit/app/test_server_state_publisher.py +++ b/tests/unit/app/test_server_state_publisher.py @@ -15,6 +15,7 @@ from ansys.visor.viewer.app.trame.async_runner import AsyncRunner from ansys.visor.viewer.app.trame.server_state_publisher import ServerStatePublisher from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState +from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState WAIT_TIMEOUT_S = 5.0 @@ -119,6 +120,22 @@ def test_publish_camera_none_writes_none(runner, state): assert state.events == [("set", "server_camera_state", None), ("flush",)] +def test_publish_cross_section_writes_record(runner, state): + plane = VisorCrossSectionState(origin=[0.0, 0.0, 0.5], normal=[0.0, 1.0, 0.0]) + ServerStatePublisher(runner, state).publish_cross_section(plane) + runner.callbacks[0]() + assert state.events == [ + ("set", "server_cross_section_state", {"origin": [0.0, 0.0, 0.5], "normal": [0.0, 1.0, 0.0]}), + ("flush",), + ] + + +def test_publish_cross_section_none_writes_none(runner, state): + ServerStatePublisher(runner, state).publish_cross_section(None) + runner.callbacks[0]() + assert state.events == [("set", "server_cross_section_state", None), ("flush",)] + + def test_bumps_queued_together_count_up(runner, state): """Both bumps are queued before either runs, so each must read the value on the loop.""" publisher = ServerStatePublisher(runner, state) diff --git a/tests/unit/app/test_visor_vtk_server.py b/tests/unit/app/test_visor_vtk_server.py index a8fb7a09..f87087bd 100644 --- a/tests/unit/app/test_visor_vtk_server.py +++ b/tests/unit/app/test_visor_vtk_server.py @@ -63,7 +63,7 @@ def test_scene_publishes_camera_into_the_viewer_state(wiring): _app_cls, scene_cls, _bridge_cls, mgr, publishers, _viewer = wiring assert [args for args, _ in publishers] == [(mgr.async_runner, mgr.server.state)] - assert scene_cls.call_args.kwargs["camera_publisher"] is publishers[0][1] + assert scene_cls.call_args.kwargs["view_record_publisher"] is publishers[0][1] def test_scene_change_bumps_the_one_publisher(wiring): diff --git a/tests/unit/renderer/test_server_renderer.py b/tests/unit/renderer/test_server_renderer.py index 3b415483..471a8ccd 100644 --- a/tests/unit/renderer/test_server_renderer.py +++ b/tests/unit/renderer/test_server_renderer.py @@ -2,7 +2,7 @@ Unit tests for ServerRenderer. Every test builds a real ServerRenderer (an off-screen VTK window that is -never rendered) with a recording frame requester and a recording camera +never rendered) with a recording frame requester and a recording record publisher. Expected values are hand-written literals. """ @@ -33,15 +33,19 @@ def request_frame(self) -> None: self.count += 1 -class _RecordingCameraPublisher: - """Records every published camera record.""" +class _RecordingViewRecordPublisher: + """Records every published camera and plane record.""" def __init__(self) -> None: self.cameras: list = [] + self.planes: list = [] def publish_camera(self, camera_state) -> None: self.cameras.append(camera_state) + def publish_cross_section(self, cross_section_state) -> None: + self.planes.append(cross_section_state) + def _unit_sphere(): source = vtkSphereSource() @@ -62,8 +66,8 @@ def requester() -> _RecordingFrameRequester: @pytest.fixture -def publisher() -> _RecordingCameraPublisher: - return _RecordingCameraPublisher() +def publisher() -> _RecordingViewRecordPublisher: + return _RecordingViewRecordPublisher() @pytest.fixture @@ -162,6 +166,25 @@ def test_sync_camera_publishes_record(renderer, publisher): } +# --------------------------------------------------------------------------- +# Plane writes publish the plane record +# --------------------------------------------------------------------------- + + +def test_sync_cross_section_plane_publishes_record(renderer, publisher): + renderer.sync_cross_section_plane([0.0, 0.0, 0.5], [0.0, 1.0, 0.0]) + assert publisher.planes[-1].model_dump(by_alias=True) == { + "origin": [0.0, 0.0, 0.5], + "normal": [0.0, 1.0, 0.0], + } + + +def test_update_bounds_publishes_seeded_plane(requester, publisher): + r = ServerRenderer((0.2, 0.4, 0.6), requester, publisher) + r.update_bounds(PART_BOUNDS) + assert publisher.planes[-1] is r.get_cross_section_plane() + + # --------------------------------------------------------------------------- # Never renders # ---------------------------------------------------------------------------