diff --git a/doc/changelog.d/176.maintenance.md b/doc/changelog.d/176.maintenance.md new file mode 100644 index 00000000..b514489e --- /dev/null +++ b/doc/changelog.d/176.maintenance.md @@ -0,0 +1 @@ +[Remote rendering] Clean up renderer interface before adding a second mode diff --git a/src/ansys/visor/viewer/app/visor_vtk.py b/src/ansys/visor/viewer/app/visor_vtk.py index 6231c12d..cacc1efa 100644 --- a/src/ansys/visor/viewer/app/visor_vtk.py +++ b/src/ansys/visor/viewer/app/visor_vtk.py @@ -18,7 +18,6 @@ from ansys.visor.viewer.core.visor_types import VisorDatasetType from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 from ansys.visor.viewer.vtk.io.visor_file_io import VisorFileIO -from ansys.visor.viewer.vtk.scene.local_scene import VisorLocalScene from ansys.visor.viewer.vtk.variables.visor_part_variables import VisorPartVariables from ansys.visor.viewer.vtk.variables.visor_variable_update import VisorVariableUpdate @@ -494,9 +493,6 @@ def _initialize_server(self, trame_log_dir: str | None) -> TrameServerManager: trame_log_dir, ) - def _initialize_scene(self) -> VisorLocalScene: - return VisorLocalScene(self.server, dark_mode=self._dark_mode) - def _prepare_and_render_scene( self, input: str | VisorDatasetType | None = None, diff --git a/src/ansys/visor/viewer/renderer/base.py b/src/ansys/visor/viewer/renderer/base.py index c55acf1c..09afe1ee 100644 --- a/src/ansys/visor/viewer/renderer/base.py +++ b/src/ansys/visor/viewer/renderer/base.py @@ -9,8 +9,7 @@ Contract covers node lifecycle, per-part visual mutations, camera, widget control (cross-section, bounding box, edges), widget fan-out -(scene bounds, actor count), picking, and render/flush. Not covered yet: -state-authority hooks, trigger-facing methods, round-trip additions. +(scene bounds, actor count), picking, and render. """ @@ -19,19 +18,36 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger +from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState + if TYPE_CHECKING: from vtkmodules.vtkCommonDataModel import vtkDataObject + from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType 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.models.runtime.vtk.renderer_annotation import RendererAnnotation from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode +logger = VisorDefaultLogger(__name__) + class IRenderer(ABC): - """Abstract rendering backend consumed by the scene coordinator.""" + """Abstract rendering backend consumed by the scene coordinator. + + Holds the camera record and the cross-section plane record, and owns the + record half of the camera and plane contracts. An implementation that has + a pipeline camera or widget to project onto overrides the writers, calling + ``super()`` first, and adds the projection. + """ + + _last_camera_state: "VisorCameraState | None" + _last_cross_section_state: "VisorCrossSectionState | None" + + def __init__(self) -> None: + """Initialize the camera and cross-section records to ``None``.""" + self._last_camera_state = None + self._last_cross_section_state = None # ------------------------------------------------------------------------ # Wire contract @@ -139,8 +155,7 @@ def apply_selected( def apply_color_variable( self, node_id: int, - variable_id: str, - array_type: str, + array_type: "VisorVtkVariableType", array_name: str, component: int, min_val: float, @@ -154,10 +169,8 @@ def apply_color_variable( ---------- node_id: Scene-graph node to update. - variable_id: - Server-authoritative opaque ID (stored as-is; not parsed here). array_type: - ``"POINT"`` or ``"CELL"``. + The array's association, already parsed at the trigger boundary. array_name: VTK array name. component: @@ -193,39 +206,38 @@ def reset_camera(self, bounds: list[float]) -> None: write, and the client is not notified. """ - @abstractmethod def get_camera_state(self) -> "VisorCameraState | None": - """ - Return the camera record, or ``None`` if nothing has written one yet. - """ + """Return the camera record, or ``None`` if nothing has written one yet.""" + return self._last_camera_state - @abstractmethod def sync_camera(self, camera_state: "VisorCameraState") -> None: - """ - Write *camera_state* to the record and project it onto the pipeline - camera. + """Store *camera_state* as the record, without copying. - The record stores the object as given, without copying: callers rely - on object identity through :meth:`get_camera_state`. + Callers rely on object identity through :meth:`get_camera_state`. + An implementation with a pipeline camera overrides this, calls + ``super()`` first, then projects the record onto the pipeline, so a + raising VTK setter still leaves the record holding what was asked. On return, the state this renderer serves to the client reflects the write, and the client is not notified. """ + self._last_camera_state = camera_state - @abstractmethod def set_projection(self, parallel: bool) -> None: - """Set parallel projection on the camera record, then project it. + """Write ``parallel_projection`` on the record in place; no record, no write. - Writes ``parallel_projection`` on the existing record in place - (preserving :meth:`sync_camera`'s identity contract) before applying - to the pipeline camera, so a raising VTK setter still leaves the - record holding what was asked. A ``None`` record is not seeded: the - pipeline write still happens, but the value is lost until the next - :meth:`reset_camera` imports it. + In place, preserving :meth:`sync_camera`'s identity contract. A + ``None`` record is not seeded: an implementation with a pipeline + camera still applies the projection there, but the value is not + recorded until the next :meth:`reset_camera` imports it. On return, the state this renderer serves to the client reflects the write, and the client is not notified. """ + if self._last_camera_state is not None: + self._last_camera_state.parallel_projection = parallel + else: + logger.debug("set_projection: no camera record; nothing to write.") # ------------------------------------------------------------------------ # Widget control (cross-section, bounding box, edges) @@ -235,9 +247,7 @@ def set_projection(self, parallel: bool) -> None: def set_edges_visible(self, visible: bool) -> None: """Show or hide edges on every part in the scene. - Scene-wide, not per-node: edges are a single global toggle and the - per-part surface that once mirrored it had no reader, no sender and - no model field. + Scene-wide, not per-node: edges are a single global toggle. On return, the state this renderer serves to the client reflects the write, and the client is not notified. @@ -247,24 +257,24 @@ def set_edges_visible(self, visible: bool) -> None: def set_cross_section_visibility(self, visible: bool) -> None: """Show or hide the cross-section clipping plane.""" - @abstractmethod def sync_cross_section_plane( self, origin: list[float], normal: list[float] ) -> None: - """ - Sync the cross-section plane origin and normal from the frontend back - to server-side VTK objects. + """Store a copy of *origin* and *normal* as the plane record. + + An implementation with a widget overrides this, calls ``super()`` + first, then writes the widget's VTK objects. On return, the state this renderer serves to the client reflects the write, and the client is not notified. """ + self._last_cross_section_state = VisorCrossSectionState( + origin=list(origin), normal=list(normal) + ) - @abstractmethod def get_cross_section_plane(self) -> "VisorCrossSectionState | None": - """ - Return the cross-section plane record, or ``None`` if nothing has - written one yet. - """ + """Return the plane record, or ``None`` if nothing has written one yet.""" + return self._last_cross_section_state @abstractmethod @@ -327,35 +337,11 @@ def pick_geometry( """ # ------------------------------------------------------------------------ - # Render / flush + # Render # ------------------------------------------------------------------------ @abstractmethod def render(self) -> None: """Trigger a render and flush to the frontend.""" - @abstractmethod - def render_window_only(self) -> None: - """Render the VTK window without flushing to the frontend.""" - - def flush_wasm_state(self) -> None: - """ - Serialise current Python VTK object state and push it to the wasm - client without triggering a VTK pipeline re-render. - - No-op default for non-wasm renderers. The local (wasm) renderer - overrides this to call ``local_view.update()``. - """ - - # ------------------------------------------------------------------------ - # Frontend addressing - # ------------------------------------------------------------------------ - - @property - @abstractmethod - def frontend_ref_name(self) -> str: - """Name by which the frontend view is addressed in ``server.js_call``. - - Empty string for renderers with no frontend view. - """ diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index f2c2e8bc..a375e6ea 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from trame_server import Server from trame_vtklocal.widgets.vtklocal import LocalView @@ -43,6 +43,7 @@ from ansys.visor.viewer.vtk.widgets.visor_orientation import VisorOrientationWidget if TYPE_CHECKING: + from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode logger = VisorDefaultLogger(__name__) @@ -63,15 +64,12 @@ class VisorLocalRenderer(IRenderer): # Registered pipelines keyed by scene-graph node id. Sole owner; # mutated exclusively via register_node / deregister_node. _pipelines: dict[int, VtkNodePipeline] - _last_camera_state: Optional["VisorCameraState"] - _last_cross_section_state: Optional["VisorCrossSectionState"] def __init__(self, server: Server): """Build the full local-mode VTK infrastructure and seed wasm state.""" + super().__init__() self._server = server self._pipelines = {} - self._last_camera_state = None - self._last_cross_section_state = None self._vtk_renderer = self._initialize_vtk_renderer() self._render_window = self._initialize_render_window() @@ -82,9 +80,8 @@ def __init__(self, server: Server): self._cross_section_widget = self._initialize_cross_section_widget() self._bounding_box_widget = self._initialize_bounding_box_widget() - # Wasm state seeding -- moved verbatim from - # ``VisorScene._initialize_vtk_pipeline`` so the client-visible - # payload is byte-identical to main. + # Wasm state seeding: the ids and names the client reads to bind to + # this render window, renderer, interactor and picker. self._server.state["wasm_ids"] = { "renderWindowId": self._local_view.get_wasm_id(self._render_window), "rendererId": self._local_view.get_wasm_id(self._vtk_renderer), @@ -102,10 +99,9 @@ def __init__(self, server: Server): def build_renderer_annotation(self) -> WasmRendererAnnotation: """See :meth:`IRenderer.build_renderer_annotation`. - Per-node handles are stamped from the same ``object_manager.GetId`` - call that :class:`SceneGraphStateBuilder` uses today to stamp - ``SceneGraphNodeInfo.wasm_actor_id`` etc. (the R2 accommodation - invariant) -- not a re-keyed or re-indexed id space. + Per-node handles are stamped with ``object_manager.GetId`` on the + registered pipeline's own objects -- not a re-keyed or re-indexed id + space. """ get_id = self._object_manager.GetId nodes = { @@ -250,8 +246,7 @@ def apply_selected( def apply_color_variable( self, node_id: int, - variable_id: str, - array_type: str, + array_type: "VisorVtkVariableType", array_name: str, component: int, min_val: float, @@ -263,9 +258,7 @@ def apply_color_variable( :meth:`VtkNodePipeline.set_color_variable`. *array_type* must already be a :class:`VisorVtkVariableType`; it is parsed at the trigger boundary, never here, and the pipeline compares it by - identity, so any other value is a logged no-op there. - *variable_id* is not forwarded -- it is stored opaquely by the - registry and is not needed to configure the mapper. An unknown + identity, so any other value is a logged no-op there. An unknown *node_id* is a logged no-op, never a raise. Ends with :meth:`_serialize_part_state`, after the mapper write, so @@ -358,35 +351,27 @@ def reset_camera(self, bounds: list[float]) -> None: self._last_camera_state = self._read_pipeline_camera() self._serialize_camera_state() - def get_camera_state(self) -> Optional["VisorCameraState"]: - """See :meth:`IRenderer.get_camera_state`.""" - return self._last_camera_state - def sync_camera(self, camera_state: "VisorCameraState") -> None: - """See :meth:`IRenderer.sync_camera`. + """See :meth:`IRenderer.sync_camera`; then project onto the pipeline camera. - Stores before projecting, so a raising VTK setter still leaves the - record holding what the earlier caller asked for. Ends with + Records through ``super()`` before projecting, so a raising VTK setter + still leaves the record holding what the caller asked for. Ends with :meth:`_serialize_camera_state`, after the projection. Does not notify the client. """ - self._last_camera_state = camera_state + super().sync_camera(camera_state) self._apply_to_pipeline_camera(camera_state) self._serialize_camera_state() def set_projection(self, parallel: bool) -> None: - """See :meth:`IRenderer.set_projection`. + """See :meth:`IRenderer.set_projection`; then apply it to the pipeline camera. - Record first, pipeline second, for the reason :meth:`sync_camera` - gives. Ends with :meth:`_serialize_camera_state`, after the pipeline - write. Does not notify the client. + Record first, through ``super()``, pipeline second, for the reason + :meth:`sync_camera` gives. The pipeline write is unconditional, with + or without a record. Ends with :meth:`_serialize_camera_state`, after + the pipeline write. Does not notify the client. """ - if self._last_camera_state is not None: - self._last_camera_state.parallel_projection = parallel - else: - logger.debug( - "set_projection: no camera record; applying to the pipeline only." - ) + super().set_projection(parallel) self._vtk_renderer.GetActiveCamera().SetParallelProjection(parallel) self._serialize_camera_state() @@ -479,14 +464,14 @@ def set_edges_visible(self, visible: bool) -> None: self._serialize_vtk_object(pipe.actor.GetProperty()) def set_cross_section_visibility(self, visible: bool) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """No-op: the widget's enablement is driven client-side.""" def sync_cross_section_plane( self, origin: list[float], normal: list[float] ) -> None: - """See :meth:`IRenderer.sync_cross_section_plane`. + """See :meth:`IRenderer.sync_cross_section_plane`; then write both VTK objects. - Writes the record, then both VTK objects. ``set_origin`` and + Records through ``super()``, then writes both VTK objects. ``set_origin`` and ``set_normal`` each write the plane *and* the representation, so the clip function and the draggable handle cannot diverge on this path -- which is the defect the client's own set/get asymmetry has. @@ -495,16 +480,11 @@ def sync_cross_section_plane( so the state served to the client carries the new plane. Does not notify the client. """ - self._last_cross_section_state = VisorCrossSectionState( - origin=list(origin), normal=list(normal) - ) + super().sync_cross_section_plane(origin, normal) self._cross_section_widget.set_origin(origin) self._cross_section_widget.set_normal(normal) self._serialize_cross_section_state() - def get_cross_section_plane(self) -> Optional["VisorCrossSectionState"]: - """See :meth:`IRenderer.get_cross_section_plane`.""" - return self._last_cross_section_state def _serialize_cross_section_state(self) -> None: """Make the state served to the client current for the plane. @@ -532,10 +512,10 @@ def _serialize_cross_section_state(self) -> None: ) def set_bounding_box_visibility(self, visible: bool) -> None: - """No-op in Story 1.2. Phase 3 populates.""" + """No-op: the widget's enablement is driven client-side.""" # ------------------------------------------------------------------ - # IRenderer: widget fan-out (called by coordinator today) + # IRenderer: widget fan-out # ------------------------------------------------------------------ def update_bounds(self, bounds: list[float]) -> None: @@ -591,8 +571,8 @@ def pick_geometry( ) -> dict: """See :meth:`IRenderer.pick_geometry`. - Body moved verbatim from ``VisorScene.pick_geometry``; only the - world-position packing changes (tuple in, three scalars out). + The actor is found by matching each renderer actor's object-manager + id against *actor_wasm_id*. """ import math @@ -691,7 +671,7 @@ def pick_geometry( return {"found": False} # ------------------------------------------------------------------ - # IRenderer: render / flush + # IRenderer: render # ------------------------------------------------------------------ def render(self) -> None: @@ -703,29 +683,25 @@ def render(self) -> None: self._local_view.update() timer.log() + # ------------------------------------------------------------------ + # Local-only: window render, wasm flush, frontend addressing + # ------------------------------------------------------------------ + def render_window_only(self) -> None: - """See :meth:`IRenderer.render_window_only`.""" + """Render the VTK window without flushing to the frontend.""" self._render_window.Render() def flush_wasm_state(self) -> None: - """See :meth:`IRenderer.flush_wasm_state`. - - Local (wasm) backend override: pushes current Python VTK object - state to the wasm client via ``LocalView.update()``. - """ + """Push current VTK object state to the wasm client via ``LocalView.update()`` without re-rendering.""" self._local_view.update() - # ------------------------------------------------------------------ - # IRenderer: frontend addressing - # ------------------------------------------------------------------ - @property def frontend_ref_name(self) -> str: - """See :meth:`IRenderer.frontend_ref_name`.""" + """Name by which the LocalView is addressed in ``server.js_call``.""" return self._local_view.ref_name # ------------------------------------------------------------------ - # Internal init helpers (moved verbatim from VisorScene) + # Internal init helpers # ------------------------------------------------------------------ def _initialize_vtk_renderer(self) -> vtkRenderer: diff --git a/src/ansys/visor/viewer/renderer/null_renderer.py b/src/ansys/visor/viewer/renderer/null_renderer.py index 17d9ca78..d0d43da9 100644 --- a/src/ansys/visor/viewer/renderer/null_renderer.py +++ b/src/ansys/visor/viewer/renderer/null_renderer.py @@ -8,41 +8,27 @@ annotated return the simplest valid empty value for that type; all others are ``pass``. No VTK imports, no local view, no side effects. -One exception to "no state": the camera record, ``_last_camera_state``, and -the cross-section plane record, ``_last_cross_section_state``. The record -half of the :class:`IRenderer` camera and plane contracts is not optional on -any implementation -- only the projection half is, and here it is a no-op -because there is no pipeline camera and no widget to project onto. +The camera and cross-section plane records, and their readers and writers, +are inherited from :class:`IRenderer`: there is no pipeline camera and no +widget here to project them onto. """ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING -from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger -from ansys.visor.viewer.models.common.visor_cross_section_state import VisorCrossSectionState from ansys.visor.viewer.renderer.base import IRenderer if TYPE_CHECKING: from vtkmodules.vtkCommonDataModel import vtkDataObject - from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState + from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode -logger = VisorDefaultLogger(__name__) - class NullRenderer(IRenderer): """Null-object implementation of :class:`IRenderer` for use in tests.""" - _last_camera_state: Optional["VisorCameraState"] - _last_cross_section_state: Optional["VisorCrossSectionState"] - - def __init__(self) -> None: - """Initialize the camera and cross-section records.""" - self._last_camera_state = None - self._last_cross_section_state = None - # ------------------------------------------------------------------ # Wire contract # ------------------------------------------------------------------ @@ -89,8 +75,7 @@ def apply_selected(self, node_id: int, selected: bool, diffuse_rgb: list) -> Non def apply_color_variable( self, node_id: int, - variable_id: str, - array_type: str, + array_type: "VisorVtkVariableType", array_name: str, component: int, min_val: float, @@ -101,16 +86,6 @@ def apply_color_variable( def clear_color_variable(self, node_id: int) -> None: pass - def refresh_color_variable_range( - self, - node_id: int, - variable_id: str, - array_type: str, - array_name: str, - component: int, - ) -> None: - pass - # ------------------------------------------------------------------ # Camera # ------------------------------------------------------------------ @@ -123,26 +98,6 @@ def reset_camera(self, bounds: list[float]) -> None: destroy a camera the frontend reported. """ - def get_camera_state(self) -> "VisorCameraState | None": - """See :meth:`IRenderer.get_camera_state`.""" - return self._last_camera_state - - def sync_camera(self, camera_state: "VisorCameraState") -> None: - """See :meth:`IRenderer.sync_camera`.""" - self._last_camera_state = camera_state - - def set_projection(self, parallel: bool) -> None: - """See :meth:`IRenderer.set_projection`. - - Record only: there is no pipeline camera to project onto. - """ - if self._last_camera_state is not None: - self._last_camera_state.parallel_projection = parallel - else: - logger.debug( - "set_projection: no camera record; nothing to write." - ) - # ------------------------------------------------------------------ # Widget control (cross-section, bounding box, edges) @@ -154,25 +109,6 @@ def set_edges_visible(self, visible: bool) -> None: def set_cross_section_visibility(self, visible: bool) -> None: pass - def sync_cross_section_plane( - self, origin: list[float], normal: list[float] - ) -> None: - """See :meth:`IRenderer.sync_cross_section_plane`. - - Record only: there are no VTK widget objects to project onto. The - record takes a copy of each list, as :class:`VisorLocalRenderer` does. - """ - self._last_cross_section_state = VisorCrossSectionState( - origin=list(origin), normal=list(normal) - ) - - def get_cross_section_plane(self) -> "VisorCrossSectionState | None": - """See :meth:`IRenderer.get_cross_section_plane`. - - ``None`` unless :meth:`sync_cross_section_plane` wrote one: there is - no widget here for :meth:`update_bounds` to seed a record from. - """ - return self._last_cross_section_state def set_bounding_box_visibility(self, visible: bool) -> None: @@ -202,20 +138,10 @@ def pick_geometry( return {} # ------------------------------------------------------------------ - # Render / flush + # Render # ------------------------------------------------------------------ def render(self) -> None: pass - def render_window_only(self) -> None: - pass - - # ------------------------------------------------------------------ - # Frontend addressing - # ------------------------------------------------------------------ - - @property - def frontend_ref_name(self) -> str: - return "" diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index 6e5350a7..56e77768 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -57,7 +57,6 @@ def apply_to(self, renderer: IRenderer) -> None: """Configure the part's mapper at this range and re-serialize it; caller holds ``_vtk_lock``.""" renderer.apply_color_variable( self.part_id, - self.record.id, self.record.type, self.record.array_name, self.component, @@ -72,12 +71,12 @@ class VisorSceneBase(ABC): Owns the **scene description** side of the application: - * :class:`VisorVTKSceneGraph` – the pure scene-graph node tree + * :class:`VisorSceneGraph` – the pure scene-graph node tree * :class:`VisorDatasetRegistry` – dataset metadata, variables, unit * :class:`VisorStateMapper` – persisted ↔ runtime state conversion All **rendering** concerns are delegated to an injected :class:`IRenderer` - implementation (``self._vtk_renderer``): VTK pipeline objects, actor + implementation (``self._renderer``): VTK pipeline objects, actor lifecycle, camera, and scene-state serialisation. Subclasses must implement one abstract hook, which captures the difference @@ -113,7 +112,7 @@ def __init__( dark_mode: bool = False, renderer: IRenderer | None = None ): - """Initialize the scene coordinator and its local renderer backend.""" + """Initialize the scene coordinator around the injected *renderer*.""" logger.debug("Initializing %s", type(self).__name__) # The server's UI record, held as a model like the camera record. @@ -164,7 +163,6 @@ def __init__( self._server = server self._scene_graph = None - self._pipelines = {} # Serialises every server-side VTK mutation and wasm push. # @@ -181,8 +179,6 @@ def __init__( if renderer is not None: self._renderer = renderer else: - # Subclass must have assigned self._vtk_renderer before calling - # super().__init__() without a renderer, or override _initialize. raise TypeError( f"{type(self).__name__} must supply a renderer to VisorSceneBase.__init__" ) diff --git a/src/ansys/visor/viewer/vtk/scene/local_scene.py b/src/ansys/visor/viewer/vtk/scene/local_scene.py index 4dfffcd5..918008b7 100644 --- a/src/ansys/visor/viewer/vtk/scene/local_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/local_scene.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState + from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer logger = VisorDefaultLogger(__name__) @@ -27,6 +28,8 @@ class VisorLocalScene(VisorSceneBase): (``RenderingMode.LOCAL``). """ + _renderer: "VisorLocalRenderer" + def __init__(self, server: Server, dark_mode: bool = False): from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer from ansys.visor.viewer.vtk.scene.visor_frontend_bridge import VisorFrontendBridge diff --git a/src/ansys/visor/visor-client/src/CameraSyncReporter.ts b/src/ansys/visor/visor-client/src/CameraSyncReporter.ts index d60a9cc2..a67a6b85 100644 --- a/src/ansys/visor/visor-client/src/CameraSyncReporter.ts +++ b/src/ansys/visor/visor-client/src/CameraSyncReporter.ts @@ -10,8 +10,8 @@ import type { * * This module exists apart from `VisorFrontend` so that it can be tested. A * `VisorFrontend` cannot be constructed under jest -- it needs a real scene - * graph node, the module-global variable manager and a renderer that accepts - * `attachSceneGraph`, and it ends in `Object.freeze` -- so a listener body + * graph node, the module-global variable manager and a renderer, and it ends + * in `Object.freeze` -- so a listener body * written inline there would be pinned by nothing, and the payload shape is * precisely the part no server-side gate can check. `CameraGestureTracker` * was split out for the same reason in the previous increment. diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index eafd0f30..b81db53f 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -14,6 +14,7 @@ import { attachCameraSyncReporter } from './CameraSyncReporter'; import { Panel_TopRight_Util } from './components/ui-panels/Panel_TopRight_Util.tsx'; import { Panel_TopLeft_Util } from './components/ui-panels/Panel_TopLeft_Util.tsx'; import { OrientationWidget } from './widgets/orientationWidget.ts'; +import { FullscreenWidget } from './widgets/fullscreenWidget.ts'; import { UiScaffoldUtil } from './components/UiScaffold.tsx'; export type { VisorCameraState } from './renderer/IRenderer'; @@ -33,7 +34,6 @@ export class VisorFrontend { renderer ); variableManager.finishAddingDataArrayMetadata(); - renderer.attachSceneGraph(sceneGraph); let treeViewUtilSet: boolean = false; let panelTopLeftUtilSet: boolean = false; @@ -256,14 +256,14 @@ export class VisorFrontend { sendUiPanelTriggerAsync('set_panel_top_right_legend_collapsed', { collapsed }); this.sendPanelTopRightTabIndexAsync = (tabIndex) => sendUiPanelTriggerAsync('set_panel_top_right_tab_index', { tabIndex }); - this.toggleFullScreenAsync = () => renderer.toggleFullScreenAsync(); - this.setEdgeVisibilityAsync = (visible) => renderer.setEdgeVisibilityGlobalAsync(visible); + const fullscreenWidget = new FullscreenWidget(); + this.toggleFullScreenAsync = () => fullscreenWidget.setFullScreenAsync(); + this.setEdgeVisibilityGlobalAsync = (visible) => + renderer.setEdgeVisibilityGlobalAsync(visible); this.setCrossSectionVisibilityAsync = (visible) => renderer.setCrossSectionVisibilityAsync(visible); - this.updateCrossSectionBoundsAsync = () => renderer.updateCrossSectionBoundsAsync(); this.setBoundingBoxVisibilityAsync = (visible) => renderer.setBoundingBoxVisibilityAsync(visible); - this.updateBoundingBoxBoundsAsync = () => renderer.updateBoundingBoxBoundsAsync(); this.setOrthographicModeAsync = async () => { await renderer.setOrthographicModeAsync(); const uiScaffold = await uiScaffoldUtilPromise; @@ -580,10 +580,9 @@ export class VisorFrontend { ) => () => void; addFrameRenderedListener: (handler: (fps: number) => void) => () => void; setCrossSectionVisibilityAsync: (visible?: boolean) => Promise; - updateCrossSectionBoundsAsync: () => Promise; setBoundingBoxVisibilityAsync: (visible?: boolean) => Promise; - setEdgeVisibilityAsync: (visible?: boolean | undefined) => Promise; - updateBoundingBoxBoundsAsync: () => Promise; + /** Show, hide or toggle edges on every part. */ + setEdgeVisibilityGlobalAsync: (visible?: boolean | undefined) => Promise; setOrthographicModeAsync: (enable?: boolean) => Promise; getAppStateAsync: (keyedByName?: boolean) => Promise; setAppStateAsync: (state?: StateInput, updateUI?: boolean) => Promise; diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx index 2da23c74..e65752b7 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomMiddle.tsx @@ -62,7 +62,7 @@ export const Panel_BottomMiddle: FC<{ await visorState.render(); }; wireFrameCell.onclick = async () => { - await visorState.setEdgeVisibilityAsync(); + await visorState.setEdgeVisibilityGlobalAsync(); await visorState.render(); }; getFullScreenCell.onclick = async () => { diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx index 0be55a43..094f3b5c 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_TopLeft.tsx @@ -41,7 +41,6 @@ export const Panel_TopLeft: FC<{ for (const node of visorState.sceneGraph.descendantActorNodesOrSelfArray) { await node.setVisibilityAsync(visible.has(node)); } - // await visorState.updateBoundingBoxBoundsAsync(); await visorState.render(); })(); }); diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts b/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts index 8d7a2401..975effc3 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/AppStateProjectionLoad.test.ts @@ -64,7 +64,6 @@ function makeSceneGraphNode() { function makeRendererDouble() { return { // Touched by the constructor. - attachSceneGraph: jest.fn(), domElement: document.createElement('div'), addCameraSettledListener: jest.fn(() => jest.fn()), addViewerClickedListener: jest.fn(() => jest.fn()), diff --git a/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx index caaff6ff..c9096ff5 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/DeliveredStateApply.test.tsx @@ -92,7 +92,6 @@ function makeSceneGraphNode() { */ function makeRendererDouble() { return { - attachSceneGraph: jest.fn(), domElement: document.createElement('div'), addCameraSettledListener: jest.fn(() => jest.fn()), addViewerClickedListener: jest.fn(() => jest.fn()), diff --git a/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx index 77840e4e..da1df7f4 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UiPanelStateSends.test.tsx @@ -87,7 +87,6 @@ function makeSceneGraphNode() { */ function makeRendererDouble() { return { - attachSceneGraph: jest.fn(), domElement: document.createElement('div'), addCameraSettledListener: jest.fn(() => jest.fn()), addViewerClickedListener: jest.fn(() => jest.fn()), diff --git a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx index 87bae09b..5d369610 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VariableRecordProjection.test.tsx @@ -140,7 +140,6 @@ function makeSceneGraphNode() { */ function makeRendererDouble() { return { - attachSceneGraph: jest.fn(), domElement: document.createElement('div'), addCameraSettledListener: jest.fn(() => jest.fn()), addViewerClickedListener: jest.fn(() => jest.fn()), diff --git a/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx index 1f2ae936..5db9b603 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/VisorSceneGraphPartTriggers.test.tsx @@ -67,7 +67,6 @@ function makeRendererDouble() { resetDiffuseColorAsync: jest.fn(async () => undefined), setColorVariableAsync: jest.fn(async () => undefined), clearColorVariableAsync: jest.fn(async () => undefined), - setEdgeVisibilityAsync: jest.fn(async () => undefined), setScalarRangeAsync: jest.fn(async () => undefined), // The per-part sends added for the server path. sendPartVisibilityAsync: jest.fn(async () => undefined), 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 040b6630..50cf9a20 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 @@ -1,7 +1,6 @@ import { WasmRenderer } from '../renderer/WasmRenderer'; import type { WasmRendererAnnotation } from '../renderer/RendererAnnotation'; import type VtkScene from '../wasm/VtkScene'; -import type { VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; import type { TrameTriggerSender } from '../renderer/IRenderer'; /** @@ -52,16 +51,19 @@ const FAN_OUT_ACTOR_IDS = [11, 12, 13]; const PARALLEL = 1; const PERSPECTIVE = 0; -function makeAnnotation(): WasmRendererAnnotation { +/** + * The annotation the renderer is built from. + * + * `EdgesWidget` fans the global toggle out to every node id the annotation + * carries, calling the renderer's own per-node `setEdgeVisibilityAsync`. Every + * node shares one set of handles: the fan-out is addressed by node id, and + * nothing here resolves a pick back to a node. + */ +function makeAnnotation(nodeIds: number[] = [NODE_ID]): WasmRendererAnnotation { + const handles = { actorId: ACTOR_ID, propertyId: PROPERTY_ID, mapperId: MAPPER_ID }; return { rendererKind: 'wasm', - nodes: { - [String(NODE_ID)]: { - actorId: ACTOR_ID, - propertyId: PROPERTY_ID, - mapperId: MAPPER_ID, - }, - }, + nodes: Object.fromEntries(nodeIds.map((id) => [String(id), handles])), widgets: { orientationWidgetId: ORIENTATION_WIDGET_ID, crossSectionPlaneId: PLANE_ID, @@ -84,7 +86,7 @@ function makeFakeWasmObjects() { EdgeVisibilityOff: jest.fn(async () => undefined), }; // Stands in for the plane, the plane representation, the plane widget, - // the bounding-box outline and axes actors, and the box algorithm. The + // and the bounding-box outline and axes actors. The // bounding-box widget queries `GetVisibility` when toggling with no // argument, which is the path every test here takes. const widget = { @@ -93,7 +95,6 @@ function makeFakeWasmObjects() { Off: jest.fn(async () => undefined), GetVisibility: jest.fn(async () => 0), SetVisibility: jest.fn(async () => undefined), - SetBounds: jest.fn(async () => undefined), // Distinctive hand-written literals. They stand in for the // *representation's* origin and normal, and they are deliberately // nothing any default would produce, so a payload carrying them can @@ -113,26 +114,10 @@ function makeFakeWasmObjects() { return { actor, property, widget, orientationWidget }; } -/** - * A scene-graph stand-in for `attachSceneGraph`. - * - * `EdgesWidget` enumerates `descendantActorNodesOrSelfArray` and calls the - * renderer's own per-actor `setEdgeVisibilityAsync` for each node's `id`; - * `BoundingBoxWidget` enumerates the same array when recomputing bounds. - * Neither is the subject of the send tests -- what those pin is the *send* - * that follows the local write, and they pass an empty actor list so the - * fan-out reaches nothing. - */ -function makeSceneGraphDouble(actorIds: number[] = []) { - return { - descendantActorNodesOrSelfArray: actorIds.map((id) => ({ id })), - }; -} - async function makeRenderer( sender: TrameTriggerSender | null, parallelProjection: number = PERSPECTIVE, - actorIds: number[] = [] + nodeIds: number[] = [NODE_ID] ) { const objects = makeFakeWasmObjects(); const camera = { @@ -164,12 +149,10 @@ async function makeRenderer( }; const renderer = await WasmRenderer.createAsync( scene as unknown as VtkScene, - makeAnnotation(), + makeAnnotation(nodeIds), sender ); - const sceneGraph = makeSceneGraphDouble(actorIds); - renderer.attachSceneGraph(sceneGraph as unknown as VisorSceneNodeExtended); - return { renderer, camera, scene, sceneGraph, ...objects }; + return { renderer, camera, scene, ...objects }; } /** A sender that records its calls and resolves. */ diff --git a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts index c776bf31..6f9c7032 100644 --- a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts @@ -1,5 +1,3 @@ -import { VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; - /** Stable integer id of an addressable scene-graph node (part or block). */ export type NodeId = number; @@ -25,17 +23,6 @@ export type VisorCameraState = Readonly<{ parallelScale: number; }>; -/** The seven camera fields that can be applied. No derived fields. */ -export type AppliedCameraState = Readonly<{ - position: readonly number[]; - focalPoint: readonly number[]; - viewUp: readonly number[]; - clippingRange: readonly number[]; - parallelProjection: boolean; - viewAngle: number; - parallelScale: number; -}>; - /** * Where a settled camera change came from: `gesture` if user input was * active during the change, `programmatic` otherwise. @@ -90,14 +77,6 @@ export interface IRenderer { // ---- Canvas / lifecycle ------------------------------------------------- /** DOM element hosting the render output (canvas + overlay). */ readonly domElement: HTMLDivElement; - /** - * Hand the live scene graph to the renderer. Called by VisorFrontend immediately - * after CreateVisorSceneGraph. Temporary: exists because the bounding-box and - * edges widgets read live per-node bounds and visibility. - * A RemoteRenderer gets those from the server and implements this as a no-op. - * TODO: Remove after user wire contract is updated to allow further decoupling. - */ - attachSceneGraph(sceneGraph: VisorSceneNodeExtended): void; /** Kick a frame flush. Wasm: wasm.update() + Render(). Remote: no-op / RPC. */ renderAsync(): Promise; /** Resize output to its container. Call after container size changes. */ @@ -114,14 +93,6 @@ export interface IRenderer { setCameraParallelProjectionAsync(enabled: boolean): Promise; setCameraViewAngleAsync(angle: number): Promise; setCameraParallelScaleAsync(scale: number): Promise; - /** - * Apply the seven applied camera fields in one RPC. WasmRenderer implements it - * by delegating to the seven per-field setters above. See §11 for the - * Story 3.2 rationale (server-tracked camera + sync-back). - */ - setCameraStateAsync(state: AppliedCameraState): Promise; - /** Frame the scene on the given bounds; used by scene-graph rebuilds. */ - resetCameraAsync(bounds?: readonly number[]): Promise; // ---- Subscriptions (return unsubscribe closures) ------------------------ /** Fires whenever the camera changes. Callback receives a full snapshot. */ @@ -177,7 +148,6 @@ export interface IRenderer { selected: boolean, currentDiffuseRgb: readonly number[] ): Promise; - setEdgeVisibilityAsync(nodeId: NodeId, edgeVisible: boolean): Promise; setOpacityAsync(nodeId: NodeId, opacity: number): Promise; setColorVariableAsync(nodeId: NodeId, descriptor: ColorVariableDescriptor): Promise; clearColorVariableAsync(nodeId: NodeId): Promise; @@ -226,7 +196,6 @@ export interface IRenderer { applyCrossSectionVisibilityAsync(visible: boolean): Promise; /** Whether the cross-section plane is shown. See contract note above. */ isCrossSectionVisible(): boolean; // cached bool, sync - updateCrossSectionBoundsAsync(): Promise; getCrossSectionOriginAsync(): Promise; getCrossSectionNormalAsync(): Promise; setCrossSectionOriginAsync(origin: readonly number[]): Promise; @@ -237,7 +206,6 @@ export interface IRenderer { applyBoundingBoxVisibilityAsync(visible: boolean): Promise; /** Whether the bounding-box outline is shown. See contract note above. */ isBoundingBoxVisible(): boolean; - updateBoundingBoxBoundsAsync(): Promise; setOrthographicModeAsync(enable?: boolean): Promise; /** Show or hide the widget; send nothing. */ @@ -256,8 +224,6 @@ export interface IRenderer { /** Whether edges are shown on every part in the scene. See contract note above. */ areEdgesVisibleGlobally(): boolean; - toggleFullScreenAsync(): Promise; - // ---- Picking (geometry pick — the vertex/edge/face path) --------------- /** * Perform a geometry pick at the given viewport-normalised coordinates. diff --git a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts index efbdc0a8..87de2be6 100644 --- a/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/NullRenderer.ts @@ -1,5 +1,4 @@ import { - AppliedCameraState, CameraOrigin, ColorVariableDescriptor, GeometryPickMode, @@ -8,7 +7,6 @@ import { PickGeometryResult, VisorCameraState, } from './IRenderer'; -import { VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; const NULL_CAMERA_STATE: VisorCameraState = { distance: 0, @@ -26,17 +24,11 @@ const NULL_CAMERA_STATE: VisorCameraState = { }; /** - * No-op implementation of IRenderer. Used to confirm the interface is - * buildable and to serve as a placeholder renderer until a concrete one is - * wired in. Nothing imports this yet. + * No-op renderer for tests that need an `IRenderer` with no wasm behind it. */ export class NullRenderer implements IRenderer { readonly domElement: HTMLDivElement = document.createElement('div'); - attachSceneGraph(_sceneGraph: VisorSceneNodeExtended): void { - // no-op - } - async renderAsync(): Promise { // no-op } @@ -81,14 +73,6 @@ export class NullRenderer implements IRenderer { // no-op } - async setCameraStateAsync(_state: AppliedCameraState): Promise { - // no-op - } - - async resetCameraAsync(_bounds?: readonly number[]): Promise { - // no-op - } - addCameraChangedListener(_callback: (state: VisorCameraState) => void): () => void { return () => { // no-op unsubscribe @@ -151,10 +135,6 @@ export class NullRenderer implements IRenderer { // no-op } - async setEdgeVisibilityAsync(_nodeId: NodeId, _edgeVisible: boolean): Promise { - // no-op - } - async setOpacityAsync(_nodeId: NodeId, _opacity: number): Promise { // no-op } @@ -221,10 +201,6 @@ export class NullRenderer implements IRenderer { // no-op } - async updateCrossSectionBoundsAsync(): Promise { - // no-op - } - async getCrossSectionOriginAsync(): Promise { return [0, 0, 0]; } @@ -253,10 +229,6 @@ export class NullRenderer implements IRenderer { // no-op } - async updateBoundingBoxBoundsAsync(): Promise { - // no-op - } - async setOrthographicModeAsync(_enable?: boolean): Promise { // no-op } @@ -281,10 +253,6 @@ export class NullRenderer implements IRenderer { // no-op } - async toggleFullScreenAsync(): Promise { - // no-op - } - async pickGeometryAsync( _normX: number, _normY: number, diff --git a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts index cb04ebd7..8e9f5981 100644 --- a/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts @@ -1,5 +1,4 @@ import { - AppliedCameraState, CameraOrigin, ColorVariableDescriptor, GeometryPickMode, @@ -11,11 +10,9 @@ import { } from './IRenderer'; import VtkScene from '../wasm/VtkScene'; import { WasmNodeHandles, WasmRendererAnnotation } from './RendererAnnotation'; -import { VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; import { CrossSectionWidget } from '../widgets/crossSectionWidget.ts'; import { BoundingBoxWidget } from '../widgets/boundingBoxWidget.ts'; import { OrthographicWidget } from '../widgets/orthographicWidget.ts'; -import { FullscreenWidget } from '../widgets/fullscreenWidget.ts'; import { EdgesWidget } from '../widgets/edgesWidget.ts'; const SELECTION_TINT_RGB: Readonly = [0 / 255, 62 / 255, 111 / 255]; @@ -49,15 +46,18 @@ export class WasmRenderer implements IRenderer { this.#nodeIdToHandles.set(nodeId, handles); } - // Widgets that only need vtkScene are built here. BoundingBoxWidget and - // EdgesWidget need the live scene graph and are built in attachSceneGraph. this.#crossSectionWidget = new CrossSectionWidget(vtkScene, { planeWasmId: wasmPlaneId, representationWasmId: wasmPlaneRepId, widgetWasmId: wasmPlaneWidgetId, }); this.#orthographicWidget = new OrthographicWidget(vtkScene); - this.#fullscreenWidget = new FullscreenWidget(); + this.#boundingBoxWidget = new BoundingBoxWidget(vtkScene, { + outlineActorId: annotation.widgets.boundingBoxOutlineActorId, + axesActorId: annotation.widgets.boundingBoxAxesActorId, + outlineQueryId: annotation.widgets.boundingBoxOutlineActorId, + }); + this.#edgesWidget = new EdgesWidget(this); const planeWidget = vtkScene.getVtkObject(wasmPlaneWidgetId); planeWidget.observe('InteractionEvent', this.#crossSectionWidget.interactionHandler); @@ -112,13 +112,6 @@ export class WasmRenderer implements IRenderer { vtkScene.noteWidgetGesture(); }); - // Bounding-box ids are stashed for attachSceneGraph, which is the - // point at which the live sceneGraph (needed by BoundingBoxWidget) - // becomes available. - this.#wasmBoundingBoxActorId = annotation.widgets.boundingBoxOutlineActorId; - this.#wasmBoundingBoxAxesActorId = annotation.widgets.boundingBoxAxesActorId; - this.#boundingBoxBoxAlgorithmWasmId = annotation.widgets.boundingBoxAlgorithmId; - this.domElement = vtkScene.canvasDiv; } @@ -155,35 +148,12 @@ export class WasmRenderer implements IRenderer { readonly #nodeIdToHandles = new Map(); readonly #crossSectionWidget: CrossSectionWidget; readonly #orthographicWidget: OrthographicWidget; - readonly #fullscreenWidget: FullscreenWidget; - readonly #wasmBoundingBoxActorId: number; - readonly #wasmBoundingBoxAxesActorId: number; - readonly #boundingBoxBoxAlgorithmWasmId: number; - #boundingBoxWidget: BoundingBoxWidget | null = null; - #edgesWidget: EdgesWidget | null = null; - - #requireSceneGraphAttached(methodName: string): void { - if (this.#boundingBoxWidget == null || this.#edgesWidget == null) { - throw new Error( - `WasmRenderer.${methodName} called before attachSceneGraph(). ` + - 'VisorFrontend must call attachSceneGraph immediately after CreateVisorSceneGraph.' - ); - } - } + readonly #boundingBoxWidget: BoundingBoxWidget; + readonly #edgesWidget: EdgesWidget; // ---- Canvas / lifecycle ------------------------------------------------- readonly domElement: HTMLDivElement; - attachSceneGraph(sceneGraph: VisorSceneNodeExtended): void { - this.#boundingBoxWidget = new BoundingBoxWidget(this.#vtkScene, sceneGraph, { - outlineActorId: this.#wasmBoundingBoxActorId, - axesActorId: this.#wasmBoundingBoxAxesActorId, - boxAlgorithmWasmId: this.#boundingBoxBoxAlgorithmWasmId, - outlineQueryId: this.#wasmBoundingBoxActorId, - }); - this.#edgesWidget = new EdgesWidget(sceneGraph, this); - } - async renderAsync(): Promise { this.#vtkScene.render(); } @@ -248,26 +218,6 @@ export class WasmRenderer implements IRenderer { await this.#vtkScene.camera.setParallelScale(scale); } - async setCameraStateAsync(state: AppliedCameraState): Promise { - // Sequential, not Promise.all, to preserve the observable ordering of - // camera events any FPS/camera-changed listener sees (§11). - await this.setCameraPositionAsync(state.position); - await this.setCameraFocalPointAsync(state.focalPoint); - await this.setCameraViewUpAsync(state.viewUp); - await this.setCameraClippingRangeAsync(state.clippingRange); - await this.setCameraParallelProjectionAsync(state.parallelProjection); - await this.setCameraViewAngleAsync(state.viewAngle); - await this.setCameraParallelScaleAsync(state.parallelScale); - } - - async resetCameraAsync(bounds?: readonly number[]): Promise { - if (bounds != null) { - await this.#vtkScene.renderer.ResetCamera(bounds); - } else { - await this.#vtkScene.renderer.ResetCamera(); - } - } - // ---- Subscriptions ---------------------------------------------------- addCameraChangedListener(callback: (state: VisorCameraState) => void): () => void { return this.#vtkScene.addCameraChangedListener(async (_) => { @@ -359,6 +309,12 @@ export class WasmRenderer implements IRenderer { await this.#applyDiffuseColorAsync(nodeId, currentDiffuseRgb, selected); } + /** The id of every node the annotation gave handles for. */ + nodeIds(): IterableIterator { + return this.#nodeIdToHandles.keys(); + } + + /** Show or hide one node's edges; the edges widget fans the global toggle out through this. */ async setEdgeVisibilityAsync(nodeId: NodeId, edgeVisible: boolean): Promise { const handles = this.#nodeIdToHandles.get(nodeId); if (handles == null) { @@ -568,10 +524,6 @@ export class WasmRenderer implements IRenderer { await this.#crossSectionWidget.setVisibilityAsync(visible); } - async updateCrossSectionBoundsAsync(): Promise { - await this.#crossSectionWidget.updateBoundsAsync(); - } - async getCrossSectionOriginAsync(): Promise { return await this.#crossSectionWidget.getOriginAsync(); } @@ -589,26 +541,18 @@ export class WasmRenderer implements IRenderer { } async setBoundingBoxVisibilityAsync(visible?: boolean): Promise { - this.#requireSceneGraphAttached('setBoundingBoxVisibilityAsync'); - await this.#boundingBoxWidget!.setVisibilityAsync(visible); + await this.#boundingBoxWidget.setVisibilityAsync(visible); await this.#sendWidgetTriggerAsync('set_bounding_box_visibility', { visible: this.isBoundingBoxVisible(), }); } isBoundingBoxVisible(): boolean { - this.#requireSceneGraphAttached('isBoundingBoxVisible'); - return this.#boundingBoxWidget!.enabled; + return this.#boundingBoxWidget.enabled; } async applyBoundingBoxVisibilityAsync(visible: boolean): Promise { - this.#requireSceneGraphAttached('applyBoundingBoxVisibilityAsync'); - await this.#boundingBoxWidget!.setVisibilityAsync(visible); - } - - async updateBoundingBoxBoundsAsync(): Promise { - this.#requireSceneGraphAttached('updateBoundingBoxBoundsAsync'); - await this.#boundingBoxWidget!.updateBoundsAsync(); + await this.#boundingBoxWidget.setVisibilityAsync(visible); } async setOrthographicModeAsync(enable?: boolean): Promise { @@ -627,25 +571,18 @@ export class WasmRenderer implements IRenderer { } async setEdgeVisibilityGlobalAsync(visible?: boolean): Promise { - this.#requireSceneGraphAttached('setEdgeVisibilityGlobalAsync'); - await this.#edgesWidget!.setEdgesVisibleAsync(visible); + await this.#edgesWidget.setEdgesVisibleAsync(visible); await this.#sendWidgetTriggerAsync('set_edges_visible', { - visible: this.#edgesWidget!.enabled, + visible: this.#edgesWidget.enabled, }); } areEdgesVisibleGlobally(): boolean { - this.#requireSceneGraphAttached('areEdgesVisibleGlobally'); - return this.#edgesWidget!.enabled; + return this.#edgesWidget.enabled; } async applyEdgeVisibilityGlobalAsync(visible: boolean): Promise { - this.#requireSceneGraphAttached('applyEdgeVisibilityGlobalAsync'); - await this.#edgesWidget!.setEdgesVisibleAsync(visible); - } - - async toggleFullScreenAsync(): Promise { - await this.#fullscreenWidget.setFullScreenAsync(); + await this.#edgesWidget.setEdgesVisibleAsync(visible); } // ---- Picking ------------------------------------------------------------ diff --git a/src/ansys/visor/visor-client/src/widgets/boundingBoxWidget.ts b/src/ansys/visor/visor-client/src/widgets/boundingBoxWidget.ts index 06f8928a..b3fdee96 100644 --- a/src/ansys/visor/visor-client/src/widgets/boundingBoxWidget.ts +++ b/src/ansys/visor/visor-client/src/widgets/boundingBoxWidget.ts @@ -12,27 +12,22 @@ function toNumericId(id: string | number | undefined, name = 'id'): number { } export class BoundingBoxWidget { + /** Toggle the bounding-box outline and axes. */ constructor( vtkScene: VtkScene, - sceneGraph: any, ids: { outlineActorId: string | number; axesActorId: string | number; - boxAlgorithmWasmId: string | number; - outlineQueryId?: string | number; + outlineQueryId: string | number; } ) { - const outlineQueryIdRaw = ids.outlineQueryId ?? ids.outlineActorId; - // convert once and validate - const outlineQueryId = toNumericId(outlineQueryIdRaw, 'outlineQueryId'); + const outlineQueryId = toNumericId(ids.outlineQueryId, 'outlineQueryId'); const outlineActorId = toNumericId(ids.outlineActorId, 'outlineActorId'); const axesActorId = toNumericId(ids.axesActorId, 'axesActorId'); - const boxAlgorithmWasmId = toNumericId(ids.boxAlgorithmWasmId, 'boxAlgorithmWasmId'); const outlineQuery = vtkScene!.getVtkObject(outlineQueryId); const outlineActor = vtkScene!.getVtkObject(outlineActorId); const axesActor = vtkScene!.getVtkObject(axesActorId); - const boxAlgorithm = vtkScene!.getVtkObject(boxAlgorithmWasmId); const self = this; async function setVisibilityAsync(visible?: boolean) { @@ -48,31 +43,7 @@ export class BoundingBoxWidget { await axesActor.SetVisibility(self.#enabled ? 1 : 0); } - async function updateBoundsAsync() { - let x_min = Number.MAX_SAFE_INTEGER; - let x_max = Number.MIN_SAFE_INTEGER; - let y_min = Number.MAX_SAFE_INTEGER; - let y_max = Number.MIN_SAFE_INTEGER; - let z_min = Number.MAX_SAFE_INTEGER; - let z_max = Number.MIN_SAFE_INTEGER; - - const nodes = sceneGraph?.descendantActorNodesOrSelfArray ?? []; - nodes.forEach((node: any) => { - if (!node.visible) return; - x_min = Math.min(x_min, node.bounds[0]); - x_max = Math.max(x_max, node.bounds[1]); - y_min = Math.min(y_min, node.bounds[2]); - y_max = Math.max(y_max, node.bounds[3]); - z_min = Math.min(z_min, node.bounds[4]); - z_max = Math.max(z_max, node.bounds[5]); - }); - - const finalBounds = [x_min, x_max, y_min, y_max, z_min, z_max]; - await boxAlgorithm.SetBounds([finalBounds]); - } - this.setVisibilityAsync = setVisibilityAsync; - this.updateBoundsAsync = updateBoundsAsync; } #enabled: boolean = false; @@ -81,5 +52,4 @@ export class BoundingBoxWidget { } setVisibilityAsync: (visible?: boolean) => Promise; - updateBoundsAsync: () => Promise; } diff --git a/src/ansys/visor/visor-client/src/widgets/crossSectionWidget.ts b/src/ansys/visor/visor-client/src/widgets/crossSectionWidget.ts index 041b1938..f7d11e96 100644 --- a/src/ansys/visor/visor-client/src/widgets/crossSectionWidget.ts +++ b/src/ansys/visor/visor-client/src/widgets/crossSectionWidget.ts @@ -16,7 +16,6 @@ function toNumericId(id: string | number | undefined, name = 'id'): number { * - toggling visibility (queries widget for authoritative state when toggling) * - checking enabled state * - an interaction handler that copies origin/normal from the widget representation to the plane and triggers a render - * - updateBoundsAsync is provided as a placeholder (keeps parity with bounding-box API) */ export class CrossSectionWidget { constructor( @@ -43,11 +42,6 @@ export class CrossSectionWidget { await widget.Off(); } }; - this.updateBoundsAsync = async () => { - // placeholder: cross-section bounds logic is currently empty in the original code - // keep a no-op to preserve API parity; implement later if needed - return; - }; this.interactionHandler = async (/* event */) => { // copy origin & normal from representation to the underlying plane and render try { @@ -105,6 +99,5 @@ export class CrossSectionWidget { await this.#plane.SetNormal(normal); }; setVisibilityAsync: (visible?: boolean) => Promise; - updateBoundsAsync: () => Promise; interactionHandler: (event?: any) => Promise; } diff --git a/src/ansys/visor/visor-client/src/widgets/edgesWidget.ts b/src/ansys/visor/visor-client/src/widgets/edgesWidget.ts index 7cccd914..1daef788 100644 --- a/src/ansys/visor/visor-client/src/widgets/edgesWidget.ts +++ b/src/ansys/visor/visor-client/src/widgets/edgesWidget.ts @@ -1,9 +1,8 @@ -import { VisorSceneNodeExtended } from '../state/VisorSceneGraph.tsx'; -import type { IRenderer } from '../renderer/IRenderer.ts'; +import type { WasmRenderer } from '../renderer/WasmRenderer.ts'; export class EdgesWidget { - constructor(sceneGraph: VisorSceneNodeExtended, renderer: IRenderer) { - this.#sceneGraph = sceneGraph; + /** Fan the global edges toggle out to every node the renderer holds. */ + constructor(renderer: WasmRenderer) { this.#renderer = renderer; } @@ -12,14 +11,13 @@ export class EdgesWidget { return this.#enabled; } - #sceneGraph: VisorSceneNodeExtended; - #renderer: IRenderer; + #renderer: WasmRenderer; setEdgesVisibleAsync = async (enable?: boolean | null): Promise => { this.#enabled = enable ?? !this.#enabled; const promises = []; - for (const n of this.#sceneGraph.descendantActorNodesOrSelfArray) { - promises.push(this.#renderer.setEdgeVisibilityAsync(n.id, this.#enabled)); + for (const nodeId of this.#renderer.nodeIds()) { + promises.push(this.#renderer.setEdgeVisibilityAsync(nodeId, this.#enabled)); } await Promise.all(promises); }; diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index 5ab9030a..36ebb747 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -676,12 +676,12 @@ def test_apply_selected_unknown_node_id_is_logged_no_op(self, renderer): # ------------------------------------------------------------------ def test_apply_color_variable_delegates_with_given_arguments(self, renderer): - """The association is forwarded unchanged; variable_id is not forwarded.""" + """The association is forwarded unchanged with the array, component and range.""" pipe = MagicMock(name="pipeline") renderer._pipelines[4] = pipe renderer.apply_color_variable( - 4, "sp-1", VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 + 4, VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 ) pipe.set_color_variable.assert_called_once_with( @@ -697,7 +697,7 @@ def test_apply_color_variable_unknown_node_id_is_logged_no_op(self, renderer): "ansys.visor.viewer.renderer.local_renderer.logger" ) as mock_logger: renderer.apply_color_variable( - 9999, "sp-1", VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 + 9999, VisorVtkVariableType.POINT, "pressure", 1, 0.0, 7.5 ) # must not raise mock_logger.debug.assert_called_once() @@ -727,7 +727,7 @@ def test_apply_color_variable_serializes_the_mapper_after_the_write(self, render ) renderer.apply_color_variable( - 4, "sp-1", VisorVtkVariableType.POINT, "pressure", 0, 2.0, 8.0 + 4, VisorVtkVariableType.POINT, "pressure", 0, 2.0, 8.0 ) assert recorded == [(8150003, (2.0, 8.0))] @@ -1174,7 +1174,7 @@ def test_set_projection_with_no_record_still_applies_to_the_pipeline( camera = renderer._vtk_renderer.GetActiveCamera.return_value camera.calls.clear() - with patch("ansys.visor.viewer.renderer.local_renderer.logger") as log: + with patch("ansys.visor.viewer.renderer.base.logger") as log: renderer.set_projection(True) assert camera.calls == [("SetParallelProjection", True)] diff --git a/tests/unit/renderer/test_null_renderer.py b/tests/unit/renderer/test_null_renderer.py index e92e8aa7..b2f3a4b4 100644 --- a/tests/unit/renderer/test_null_renderer.py +++ b/tests/unit/renderer/test_null_renderer.py @@ -131,7 +131,7 @@ def test_set_projection_with_no_record_is_a_logged_no_op(): """ renderer = NullRenderer() - with patch("ansys.visor.viewer.renderer.null_renderer.logger") as log: + with patch("ansys.visor.viewer.renderer.base.logger") as log: renderer.set_projection(True) assert renderer.get_camera_state() is None