diff --git a/doc/changelog.d/199.added.md b/doc/changelog.d/199.added.md new file mode 100644 index 00000000..f2c3b6a7 --- /dev/null +++ b/doc/changelog.d/199.added.md @@ -0,0 +1 @@ +Add geometry picker for server mode diff --git a/src/ansys/visor/viewer/app/trame/scene_trigger_app.py b/src/ansys/visor/viewer/app/trame/scene_trigger_app.py index 13f510a4..a68b13c9 100644 --- a/src/ansys/visor/viewer/app/trame/scene_trigger_app.py +++ b/src/ansys/visor/viewer/app/trame/scene_trigger_app.py @@ -12,6 +12,10 @@ from ansys.visor.viewer.core.visor_enums import RenderingMode, VisorVtkVariableType from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState +from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick +from ansys.visor.viewer.models.runtime.requests.geometry_pick_payloads import ( + SetGeometryHighlightPayload, +) from ansys.visor.viewer.models.runtime.requests.sync_camera_payload import SyncCameraPayload from ansys.visor.viewer.models.runtime.requests.variable_range_payload import SetVariableRangePayload from ansys.visor.viewer.models.runtime.requests.widget_state_payloads import ( @@ -88,6 +92,8 @@ def set_panel_top_right_legend_collapsed(self, collapsed: bool) -> None: ... def set_panel_top_right_tab_index(self, tab_index: int) -> None: ... + def set_geometry_highlight(self, pick: GeometryPick) -> None: ... + # ---------------------------------------------------------------------- # Trigger payload models @@ -564,6 +570,19 @@ def set_panel_top_right_tab_index(self, payload) -> None: return api.set_panel_top_right_tab_index(payload.tab_index) + @trigger("set_geometry_highlight") + @parse_payload(SetGeometryHighlightPayload) + def set_geometry_highlight(self, payload) -> None: + """Frontend -> Backend: highlight the picked feature, or clear the highlight on a not-found pick. + + Transient: the scene stores nothing. The renderer pushes the + highlight to the client itself, without rebuilding the frontend. + """ + api = self._mutation_api("set_geometry_highlight", payload) + if api is None: + return + api.set_geometry_highlight(payload.pick) + def set_only_cookie(self, key: str, value: str): """ Sets a cookie on the server. NOTE: there is a limitation diff --git a/src/ansys/visor/viewer/app/trame/server_app.py b/src/ansys/visor/viewer/app/trame/server_app.py index 16ef05b3..c2945194 100644 --- a/src/ansys/visor/viewer/app/trame/server_app.py +++ b/src/ansys/visor/viewer/app/trame/server_app.py @@ -1,9 +1,9 @@ """ ServerApp ========= -Trame app for server-side rendering: the shared scene triggers, part picking, -the client theme report, and the trame-rca area the rendered frames stream -through. +Trame app for server-side rendering: the shared scene triggers, part and +geometry picking, the client theme report, and the trame-rca area the rendered +frames stream through. """ from __future__ import annotations @@ -23,6 +23,8 @@ ) from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge from ansys.visor.viewer.core.visor_enums import RenderingMode +from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick +from ansys.visor.viewer.models.runtime.requests.geometry_pick_payloads import PickGeometryAtPayload from ansys.visor.viewer.renderer.server_window import VisorRcaWindow RCA_AREA_NAME: str = "view" @@ -44,7 +46,7 @@ class SetDarkThemePayload(BaseModel): @TrameApp() class ServerApp(SceneTriggerApp): - """Serves the scene triggers, part picking and the streamed view for server-side rendering.""" + """Serves the scene triggers, part and geometry picking and the streamed view for server-side rendering.""" def __init__( self, @@ -54,12 +56,14 @@ def __init__( trame_logger: Logger | None, scene_mutation_api: SceneMutationApi, pick_part_at: Callable[[int, int], int | None], + pick_geometry_at: Callable[[int, int, str], GeometryPick], set_dark_mode: Callable[[bool], None], rca_window: VisorRcaWindow, bridge: ServerLoopBridge, ) -> None: """Create the area before start; the view handler is built when the server is ready.""" self._pick_part_at = pick_part_at + self._pick_geometry_at = pick_geometry_at self._set_dark_mode = set_dark_mode self._rca_window = rca_window self._bridge = bridge @@ -96,6 +100,7 @@ def _bind_stream(self) -> None: interactive_quality=60, still_quality=90, ) + self._rca_window.bind_pixel_scale(lambda: view_handler.scale_factor) self._bridge.bind(view_handler) @trigger("pick_part_at") @@ -104,6 +109,12 @@ def pick_part_at(self, payload) -> int | None: """Frontend -> Backend: the node under a render-window pixel, or None; selects nothing.""" return self._pick_part_at(payload.x, payload.y) + @trigger("pick_geometry_at") + @parse_payload(PickGeometryAtPayload) + def pick_geometry_at(self, payload) -> dict: + """Frontend -> Backend: the geometry feature under a render-window pixel, dumped by alias; highlights nothing.""" + return self._pick_geometry_at(payload.x, payload.y, payload.mode).model_dump(by_alias=True) + @trigger("set_dark_theme") @parse_payload(SetDarkThemePayload) def set_dark_theme(self, payload) -> None: diff --git a/src/ansys/visor/viewer/app/visor_vtk_server.py b/src/ansys/visor/viewer/app/visor_vtk_server.py index 2e0cf065..db646a4c 100644 --- a/src/ansys/visor/viewer/app/visor_vtk_server.py +++ b/src/ansys/visor/viewer/app/visor_vtk_server.py @@ -50,6 +50,7 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> trame_logger=get_trame_logger(trame_log_dir), scene_mutation_api=self._scene, pick_part_at=self._scene.pick_part_at, + pick_geometry_at=self._scene.pick_geometry_at, set_dark_mode=self._scene.set_dark_mode, rca_window=self._scene.rca_window, bridge=bridge, diff --git a/src/ansys/visor/viewer/models/runtime/picking/__init__.py b/src/ansys/visor/viewer/models/runtime/picking/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ansys/visor/viewer/models/runtime/picking/geometry_pick.py b/src/ansys/visor/viewer/models/runtime/picking/geometry_pick.py new file mode 100644 index 00000000..78e232c7 --- /dev/null +++ b/src/ansys/visor/viewer/models/runtime/picking/geometry_pick.py @@ -0,0 +1,79 @@ +""" +Geometry pick results +===================== +The four results a geometry pick can report. Each dumps by alias to the +wire shape the client's result panel reads. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from ansys.visor.viewer.vtk.widgets.geometry_highlight import GeometryHighlight + +GeometryPickMode = Literal["vertex", "edge", "face"] + + +class NoGeometryPick(BaseModel): + """No part cell under the pick.""" + + model_config = ConfigDict(populate_by_name=True) + + found: Literal[False] = False + + def highlight_with(self, highlight: "GeometryHighlight") -> None: + """Hide the highlight.""" + highlight.hide() + + +class VertexPick(BaseModel): + """The picked cell's vertex nearest the pick position.""" + + model_config = ConfigDict(populate_by_name=True) + + found: Literal[True] = True + mode: Literal["vertex"] = "vertex" + position: List[float] + + def highlight_with(self, highlight: "GeometryHighlight") -> None: + """Draw a dot at the vertex.""" + highlight.show_vertex(self.position) + + +class EdgePick(BaseModel): + """The picked cell's edge nearest the pick position, with its length.""" + + model_config = ConfigDict(populate_by_name=True) + + found: Literal[True] = True + mode: Literal["edge"] = "edge" + point_a: List[float] = Field(alias="pointA") + point_b: List[float] = Field(alias="pointB") + # Not "length" on the wire: trame's trigger decorators read an object with a + # numeric length as a file list and send it as an array. + length: float = Field(alias="edgeLength") + + def highlight_with(self, highlight: "GeometryHighlight") -> None: + """Draw a line along the edge.""" + highlight.show_edge(self.point_a, self.point_b) + + +class FacePick(BaseModel): + """The picked cell as a whole, with its area.""" + + model_config = ConfigDict(populate_by_name=True) + + found: Literal[True] = True + mode: Literal["face"] = "face" + area: float + points: List[List[float]] + + def highlight_with(self, highlight: "GeometryHighlight") -> None: + """Draw the cell as a filled outline.""" + highlight.show_face(self.points) + + +GeometryPick = Union[VertexPick, EdgePick, FacePick, NoGeometryPick] diff --git a/src/ansys/visor/viewer/models/runtime/requests/geometry_pick_payloads.py b/src/ansys/visor/viewer/models/runtime/requests/geometry_pick_payloads.py new file mode 100644 index 00000000..673a47df --- /dev/null +++ b/src/ansys/visor/viewer/models/runtime/requests/geometry_pick_payloads.py @@ -0,0 +1,19 @@ +"""Models for the geometry-pick trigger payloads.""" + +from pydantic import BaseModel + +from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick, GeometryPickMode + + +class PickGeometryAtPayload(BaseModel): + """pick_geometry_at payload: a render-window pixel, origin bottom-left, and the mode.""" + + x: int + y: int + mode: GeometryPickMode + + +class SetGeometryHighlightPayload(BaseModel): + """set_geometry_highlight payload: the pick whose feature to highlight; a not-found pick clears it.""" + + pick: GeometryPick diff --git a/src/ansys/visor/viewer/renderer/base.py b/src/ansys/visor/viewer/renderer/base.py index 499f4334..9b4c5cad 100644 --- a/src/ansys/visor/viewer/renderer/base.py +++ b/src/ansys/visor/viewer/renderer/base.py @@ -26,6 +26,7 @@ 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.runtime.picking.geometry_pick import GeometryPick from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import RendererAnnotation from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode @@ -316,6 +317,18 @@ def update_bounds(self, bounds: list[float]) -> None: def update_actor_count(self, count: int) -> None: """Propagate the current part-node count to widgets.""" + # ------------------------------------------------------------------------ + # Geometry highlight + # ------------------------------------------------------------------------ + + @abstractmethod + def show_geometry_highlight(self, pick: "GeometryPick") -> None: + """Draw pick on the scene's one highlight, or hide it when not found. + + On return, the client has been sent what it needs to show the + highlight, without rebuilding its frontend. + """ + # ------------------------------------------------------------------------ # Render diff --git a/src/ansys/visor/viewer/renderer/local_renderer.py b/src/ansys/visor/viewer/renderer/local_renderer.py index caafe04c..ae83a5d7 100644 --- a/src/ansys/visor/viewer/renderer/local_renderer.py +++ b/src/ansys/visor/viewer/renderer/local_renderer.py @@ -23,18 +23,23 @@ from ansys.visor.viewer.core.perf_timer import PerfTimer from ansys.visor.viewer.core.visor_colors import VisorColors from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger +from ansys.visor.viewer.models.runtime.picking.geometry_pick import NoGeometryPick from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import ( WasmNodeHandles, WasmRendererAnnotation, WasmWidgetHandles, ) from ansys.visor.viewer.renderer.pipeline_renderer import VtkPipelineRenderer +from ansys.visor.viewer.vtk.picking.cell_feature_finder import CellFeatureFinder from ansys.visor.viewer.vtk.widgets.visor_bounding_box import VisorBoundingBox from ansys.visor.viewer.vtk.widgets.visor_cross_section import VisorCrossSectionWidget from ansys.visor.viewer.vtk.widgets.visor_orientation import VisorOrientationWidget logger = VisorDefaultLogger(__name__) +DATA_ONLY_UPDATE_OPTION: str = "dataOnly" +"""``LocalView.update`` option the client reads to update its objects without rebuilding its frontend.""" + class VisorLocalRenderer(VtkPipelineRenderer): """IRenderer backend that owns the local wasm / LocalView VTK pipeline.""" @@ -190,6 +195,14 @@ def _publish_cross_section(self) -> None: self._object_manager.GetId(self._cross_section_widget.plane_representation) ) + def _present_geometry_highlight(self) -> None: + """Push the whole render window as data only; see :meth:`push_without_rebuild`.""" + self.push_without_rebuild() + + def _initial_highlight_pixel_scale(self) -> float: + """1.0: the client draws its own overlay here, so the actor's sizes are not shown.""" + return 1.0 + # ------------------------------------------------------------------ # IRenderer: widget visibility # @@ -239,17 +252,15 @@ def pick_geometry( dict Backend-neutral pick result. Always contains ``"found": bool``. When ``found`` is ``True``, contains ``"mode"`` and mode-specific - fields (``position`` / ``pointA``+``pointB``+``length`` / + fields (``position`` / ``pointA``+``pointB``+``edgeLength`` / ``points``+``area``). """ - import math - actor_wasm_id = int(actor_wasm_id) cell_id = int(cell_id) - pick_pos = [float(world_pos[0]), float(world_pos[1]), float(world_pos[2])] + pick_pos = (float(world_pos[0]), float(world_pos[1]), float(world_pos[2])) if actor_wasm_id < 0 or cell_id < 0: - return {"found": False} + return NoGeometryPick().model_dump(by_alias=True) # Locate the actor by iterating the renderer's actors and matching # WASM IDs (avoids depending on a GetObjectAtId reverse-lookup API). @@ -264,79 +275,13 @@ def pick_geometry( a = actors.GetNextActor() if actor is None: - return {"found": False} - - dataset = actor.GetMapper().GetInputDataObject(0, 0) - if dataset is None or cell_id >= dataset.GetNumberOfCells(): - return {"found": False} - - cell = dataset.GetCell(cell_id) - if cell is None: - return {"found": False} - - n_pts = cell.GetNumberOfPoints() - pts = cell.GetPoints() - - if mode == "vertex": - best_i = 0 - min_dist2 = float("inf") - for i in range(n_pts): - p = pts.GetPoint(i) - d2 = sum((pick_pos[k] - p[k]) ** 2 for k in range(3)) - if d2 < min_dist2: - min_dist2 = d2 - best_i = i - position = list(pts.GetPoint(best_i)) - return {"found": True, "mode": "vertex", "position": position} - - if mode == "edge": - if n_pts < 2: - return {"found": False} - best_i, best_j = 0, 1 - min_dist2 = float("inf") - for i in range(n_pts): - j = (i + 1) % n_pts - p0 = pts.GetPoint(i) - p1 = pts.GetPoint(j) - seg = [p1[k] - p0[k] for k in range(3)] - seg_len2 = sum(s * s for s in seg) - if seg_len2 > 0: - t = sum((pick_pos[k] - p0[k]) * seg[k] for k in range(3)) / seg_len2 - t = max(0.0, min(1.0, t)) - else: - t = 0.0 - closest = [p0[k] + t * seg[k] for k in range(3)] - d2 = sum((pick_pos[k] - closest[k]) ** 2 for k in range(3)) - if d2 < min_dist2: - min_dist2 = d2 - best_i, best_j = i, j - pt_a = list(pts.GetPoint(best_i)) - pt_b = list(pts.GetPoint(best_j)) - length = math.sqrt(sum((a - b) ** 2 for a, b in zip(pt_a, pt_b))) - return { - "found": True, - "mode": "edge", - "pointA": pt_a, - "pointB": pt_b, - "length": length, - } - - if mode == "face": - p0 = pts.GetPoint(0) - cx, cy, cz = 0.0, 0.0, 0.0 - for i in range(1, n_pts - 1): - a = pts.GetPoint(i) - b = pts.GetPoint(i + 1) - ax, ay, az = a[0] - p0[0], a[1] - p0[1], a[2] - p0[2] - bx, by, bz = b[0] - p0[0], b[1] - p0[1], b[2] - p0[2] - cx += ay * bz - az * by - cy += az * bx - ax * bz - cz += ax * by - ay * bx - area = 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz) - points = [list(pts.GetPoint(i)) for i in range(n_pts)] - return {"found": True, "mode": "face", "area": area, "points": points} - - return {"found": False} + return NoGeometryPick().model_dump(by_alias=True) + + finder = CellFeatureFinder.from_dataset( + actor.GetMapper().GetInputDataObject(0, 0), cell_id, pick_pos + ) + pick = finder.find(mode) if finder is not None else NoGeometryPick() + return pick.model_dump(by_alias=True) # ------------------------------------------------------------------ # IRenderer: render @@ -363,6 +308,15 @@ def flush_wasm_state(self) -> None: """Push current VTK object state to the wasm client via ``LocalView.update()`` without re-rendering.""" self._local_view.update() + def push_without_rebuild(self) -> None: + """Render, then push the whole render window to the wasm client as data only, so its frontend is not rebuilt. + + No object is named, so the window and everything it holds is sent, + including an actor that has just become visible. + """ + self._render_window.Render() + self._local_view.update(**{DATA_ONLY_UPDATE_OPTION: True}) + @property def frontend_ref_name(self) -> str: """Name by which the LocalView is addressed in ``server.js_call``.""" diff --git a/src/ansys/visor/viewer/renderer/null_renderer.py b/src/ansys/visor/viewer/renderer/null_renderer.py index ae20fab9..ad6dce89 100644 --- a/src/ansys/visor/viewer/renderer/null_renderer.py +++ b/src/ansys/visor/viewer/renderer/null_renderer.py @@ -23,6 +23,7 @@ from vtkmodules.vtkCommonDataModel import vtkDataObject from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType + from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode @@ -124,6 +125,13 @@ def update_bounds(self, bounds: list[float]) -> None: def update_actor_count(self, count: int) -> None: pass + # ------------------------------------------------------------------ + # Geometry highlight + # ------------------------------------------------------------------ + + def show_geometry_highlight(self, pick: "GeometryPick") -> None: + """No pipeline to draw in; nothing to do.""" + # ------------------------------------------------------------------ # Render diff --git a/src/ansys/visor/viewer/renderer/pipeline_renderer.py b/src/ansys/visor/viewer/renderer/pipeline_renderer.py index 186aa6de..eef73f8b 100644 --- a/src/ansys/visor/viewer/renderer/pipeline_renderer.py +++ b/src/ansys/visor/viewer/renderer/pipeline_renderer.py @@ -18,6 +18,7 @@ from vtkmodules.vtkCommonDataModel import vtkDataObject from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera from vtkmodules.vtkRenderingCore import ( + vtkCamera, vtkCellPicker, vtkRenderer, vtkRenderWindow, @@ -27,8 +28,11 @@ from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger 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.picking.geometry_pick import GeometryPick, NoGeometryPick from ansys.visor.viewer.renderer.base import IRenderer from ansys.visor.viewer.vtk.node_pipeline import VtkNodePipeline +from ansys.visor.viewer.vtk.picking.part_cell_hit import PartCellHit, PickView +from ansys.visor.viewer.vtk.widgets.geometry_highlight import GeometryHighlight from ansys.visor.viewer.vtk.widgets.visor_bounding_box import VisorBoundingBox from ansys.visor.viewer.vtk.widgets.visor_cross_section import VisorCrossSectionWidget from ansys.visor.viewer.vtk.widgets.visor_orientation import VisorOrientationWidget @@ -52,12 +56,13 @@ class VtkPipelineRenderer(IRenderer): _orientation_widget: VisorOrientationWidget _cross_section_widget: VisorCrossSectionWidget _bounding_box_widget: VisorBoundingBox + _geometry_highlight: GeometryHighlight # Registered pipelines keyed by scene-graph node id. Sole owner; # mutated exclusively via register_node / deregister_node. _pipelines: dict[int, VtkNodePipeline] def __init__(self) -> None: - """Build renderer, window, interactor, the widgets' host, then the three widgets, in that order.""" + """Build renderer, window, interactor, the widgets' host, the three widgets, then the geometry highlight, in that order.""" super().__init__() self._pipelines = {} @@ -68,6 +73,7 @@ def __init__(self) -> None: self._orientation_widget = self._initialize_orientation_widget() self._cross_section_widget = self._initialize_cross_section_widget() self._bounding_box_widget = self._initialize_bounding_box_widget() + self._geometry_highlight = self._initialize_geometry_highlight() # ------------------------------------------------------------------ # Backend hooks @@ -102,6 +108,14 @@ def _publish_camera(self) -> None: def _publish_cross_section(self) -> None: """Make the written cross-section plane visible to the client.""" + @abstractmethod + def _present_geometry_highlight(self) -> None: + """Send the written geometry highlight to the client without rebuilding its frontend.""" + + @abstractmethod + def _initial_highlight_pixel_scale(self) -> float: + """The device pixels per CSS pixel the geometry highlight is first drawn at.""" + # ------------------------------------------------------------------ # IRenderer: node lifecycle # ------------------------------------------------------------------ @@ -303,7 +317,7 @@ def _write_camera(self, camera_state: "VisorCameraState") -> None: record holding what the caller asked for. """ super().sync_camera(camera_state) - self._apply_to_pipeline_camera(camera_state) + self._write_camera_fields(self._vtk_renderer.GetActiveCamera(), camera_state) def set_projection(self, parallel: bool) -> None: """See :meth:`IRenderer.set_projection`; then apply it to the pipeline camera. @@ -342,9 +356,9 @@ def _read_pipeline_camera(self) -> VisorCameraState: parallel_scale=camera.GetParallelScale(), ) - def _apply_to_pipeline_camera(self, camera_state: "VisorCameraState") -> None: - """Write *camera_state*'s onto the active pipeline camera.""" - camera = self._vtk_renderer.GetActiveCamera() + @staticmethod + def _write_camera_fields(camera: vtkCamera, camera_state: "VisorCameraState") -> None: + """Write the seven record fields onto a VTK camera.""" camera.SetPosition(camera_state.position) camera.SetFocalPoint(camera_state.focal_point) camera.SetViewUp(camera_state.view_up) @@ -443,17 +457,19 @@ def update_actor_count(self, count: int) -> None: self._bounding_box_widget.update_node_count(count) # ------------------------------------------------------------------ - # Part picking + # Part and geometry picking + # + # None takes a lock: the caller holds the scene lock, as for every + # other method. # ------------------------------------------------------------------ - def pick_part_at(self, x: int, y: int) -> int | None: - """Return the node id of the part under a render-window pixel, origin bottom-left, or None. + def _hit_at(self, renderer: vtkRenderer, x: int, y: int) -> PartCellHit | None: + """The part cell under pixel (x, y) of renderer's window, origin bottom-left, part actors only; renders nothing. - *x* and *y* are render-window device pixels. Only part actors are - pickable: the pick list holds every registered pipeline's actor and - nothing else, so the bounding-box outline and the widgets never hide a - part behind them. A software ray cast; it does not render. Takes no - lock: the caller holds the scene lock, as for every other method. + The pick list holds every registered pipeline's actor and nothing + else, so the bounding-box outline and the widgets never hide a part + behind them. A software ray cast through *renderer*'s camera and + window size; the actors need not be in *renderer*. """ picker = vtkCellPicker() picker.SetTolerance(PART_PICK_TOLERANCE) @@ -461,14 +477,68 @@ def pick_part_at(self, x: int, y: int) -> int | None: for pipe in self._pipelines.values(): picker.AddPickList(pipe.actor) picker.PickFromListOn() - if not picker.Pick(x, y, 0, self._vtk_renderer): + if not picker.Pick(x, y, 0, renderer): return None picked_actor = picker.GetActor() for node_id, pipe in self._pipelines.items(): if pipe.actor is picked_actor: - return node_id + return PartCellHit( + node_id=node_id, + cell_id=picker.GetCellId(), + position=tuple(picker.GetPickPosition()), + dataset=picked_actor.GetMapper().GetInputDataObject(0, 0), + ) return None + def pick_part_at(self, x: int, y: int) -> int | None: + """Return the node id of the part under a render-window pixel, origin bottom-left, or None. + + *x* and *y* are render-window device pixels. Only part actors are + pickable; see :meth:`_hit_at`. + """ + hit = self._hit_at(self._vtk_renderer, x, y) + return hit.node_id if hit is not None else None + + def pick_geometry_at(self, x: int, y: int, mode: str) -> GeometryPick: + """Find the feature of the part cell under a pixel of this window; not found on a miss.""" + hit = self._hit_at(self._vtk_renderer, x, y) + return hit.find_feature(mode) if hit is not None else NoGeometryPick() + + def pick_geometry_in_view(self, view: PickView, x: int, y: int, mode: str) -> GeometryPick: + """Find the feature of the part cell under a pixel of a client's window, seen through the client's camera; this window is untouched. + + *x* and *y* are device pixels of the client's window, origin + bottom-left. The picker's tolerance is a fraction of the window + diagonal, so the view's size matters as much as its camera. The + window is held for the whole pick: a renderer does not keep its + window alive, and a renderer whose window is gone no longer resolves + the tolerance through the view's size. + """ + window = self._view_window(view) + hit = self._hit_at(window.GetRenderers().GetFirstRenderer(), x, y) + return hit.find_feature(mode) if hit is not None else NoGeometryPick() + + def _view_window(self, view: PickView) -> vtkRenderWindow: + """A never-rendered off-screen window of the view's size, holding one renderer whose camera is written from the record.""" + renderer = vtkRenderer() + window = vtkRenderWindow() + window.SetOffScreenRendering(1) + window.SetSize(view.width, view.height) + window.AddRenderer(renderer) + self._write_camera_fields(renderer.GetActiveCamera(), view.camera_state) + return window + + # ------------------------------------------------------------------ + # IRenderer: geometry highlight + # ------------------------------------------------------------------ + + def show_geometry_highlight(self, pick: GeometryPick) -> None: + """Draw pick on the highlight, or hide it when not found; a not-found pick on a hidden highlight sends nothing.""" + if not pick.found and not self._geometry_highlight.visible: + return + pick.highlight_with(self._geometry_highlight) + self._present_geometry_highlight() + # ------------------------------------------------------------------ # Internal init helpers # ------------------------------------------------------------------ @@ -499,3 +569,9 @@ def _initialize_bounding_box_widget(self) -> VisorBoundingBox: widget.attach_to_renderer(self._vtk_renderer) return widget + def _initialize_geometry_highlight(self) -> GeometryHighlight: + # Added to the renderer only: it is never in a pick list, so no pick can hit it. + highlight = GeometryHighlight(self._initial_highlight_pixel_scale()) + self._vtk_renderer.AddActor(highlight.actor) + return highlight + diff --git a/src/ansys/visor/viewer/renderer/server_renderer.py b/src/ansys/visor/viewer/renderer/server_renderer.py index 296e0fa4..bdffb5fc 100644 --- a/src/ansys/visor/viewer/renderer/server_renderer.py +++ b/src/ansys/visor/viewer/renderer/server_renderer.py @@ -88,6 +88,13 @@ def set_viewport_size(self, width: int, height: int) -> None: self._render_window_interactor.UpdateSize(width, height) self._orientation_widget.fit_to_window(width, height) + def set_pixel_scale(self, pixel_scale: float) -> None: + """Rescale the geometry highlight to this many device pixels per CSS pixel. + + Requests no frame: the resize path that calls this schedules its own. + """ + self._geometry_highlight.set_pixel_scale(pixel_scale) + def set_background_rgb(self, background_rgb: tuple[float, float, float]) -> None: """Clear to this colour from the next frame. @@ -211,6 +218,14 @@ 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()) + def _present_geometry_highlight(self) -> None: + """Request a frame showing the written highlight.""" + self._frame_requester.request_frame() + + def _initial_highlight_pixel_scale(self) -> float: + """1.0 until the first resize; the window then sets the client's ratio.""" + return 1.0 + # ------------------------------------------------------------------ # VtkPipelineRenderer: backend hooks # ------------------------------------------------------------------ diff --git a/src/ansys/visor/viewer/renderer/server_window.py b/src/ansys/visor/viewer/renderer/server_window.py index ab8859f9..6278f7d4 100644 --- a/src/ansys/visor/viewer/renderer/server_window.py +++ b/src/ansys/visor/viewer/renderer/server_window.py @@ -55,6 +55,7 @@ class VisorRcaWindow: _reschedule: Callable[[], None] _pending_size: tuple[int, int] | None _last_size: tuple[int, int] + _pixel_scale_source: Callable[[], float] | None _pending_events: list[dict] _last_frame: tuple[NDArray, int, int] | None @@ -72,9 +73,22 @@ def __init__( self._reschedule = reschedule self._pending_size = None self._last_size = INITIAL_WINDOW_SIZE + self._pixel_scale_source = None self._pending_events = [] self._last_frame = None + def bind_pixel_scale(self, source: Callable[[], float]) -> None: + """Bind, once, the read of the client's device pixels per CSS pixel; a second bind raises.""" + if self._pixel_scale_source is not None: + raise RuntimeError("the pixel scale source is already bound") + self._pixel_scale_source = source + + def _read_pixel_scale(self) -> float: + """The bound source's current value; raises when nothing is bound.""" + if self._pixel_scale_source is None: + raise RuntimeError("the pixel scale is read before bind_pixel_scale") + return self._pixel_scale_source() + @property def img_cols_rows(self) -> tuple[NDArray, int, int]: """Render and capture under the lock; when another thread holds it, return the last frame and reschedule.""" @@ -104,6 +118,7 @@ def _apply_deferred(self) -> None: """Apply the last size, forward queued events in order, then record a gesture end once if any forwarded event ends one; caller holds the lock.""" if self._pending_size is not None: self._renderer.set_viewport_size(*self._pending_size) + self._renderer.set_pixel_scale(self._read_pixel_scale()) self._pending_size = None events, self._pending_events = self._pending_events, [] for event in events: diff --git a/src/ansys/visor/viewer/vtk/picking/__init__.py b/src/ansys/visor/viewer/vtk/picking/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ansys/visor/viewer/vtk/picking/cell_feature_finder.py b/src/ansys/visor/viewer/vtk/picking/cell_feature_finder.py new file mode 100644 index 00000000..5e9a9332 --- /dev/null +++ b/src/ansys/visor/viewer/vtk/picking/cell_feature_finder.py @@ -0,0 +1,114 @@ +""" +CellFeatureFinder +================= +Finds the vertex, edge or face of one picked cell that a geometry pick +reports. +""" + +from __future__ import annotations + +import math + +from vtkmodules.vtkCommonDataModel import vtkCell + +from ansys.visor.viewer.models.runtime.picking.geometry_pick import ( + EdgePick, + FacePick, + GeometryPick, + NoGeometryPick, + VertexPick, +) + + +class CellFeatureFinder: + """Finds the feature of one picked cell that a geometry pick reports: the vertex or edge nearest the pick point, or the whole cell.""" + + def __init__(self, cell: vtkCell, pick_position: tuple[float, float, float]) -> None: + """Hold the cell and the world-space point the pick met it at.""" + self._cell = cell + self._pick_position = pick_position + + @classmethod + def from_dataset( + cls, dataset, cell_id: int, pick_position: tuple[float, float, float] + ) -> CellFeatureFinder | None: + """None when dataset is None, cell_id is outside [0, cell count), or the dataset has no cell there.""" + if dataset is None or cell_id < 0 or cell_id >= dataset.GetNumberOfCells(): + return None + cell = dataset.GetCell(cell_id) + if cell is None: + return None + return cls(cell, pick_position) + + def nearest_vertex(self) -> VertexPick: + """The cell point closest to the pick position.""" + pick_pos = self._pick_position + n_pts = self._cell.GetNumberOfPoints() + pts = self._cell.GetPoints() + best_i = 0 + min_dist2 = float("inf") + for i in range(n_pts): + p = pts.GetPoint(i) + d2 = sum((pick_pos[k] - p[k]) ** 2 for k in range(3)) + if d2 < min_dist2: + min_dist2 = d2 + best_i = i + return VertexPick(position=list(pts.GetPoint(best_i))) + + def nearest_edge(self) -> EdgePick | NoGeometryPick: + """The cell edge closest to the pick position; not found for fewer than two points.""" + pick_pos = self._pick_position + n_pts = self._cell.GetNumberOfPoints() + if n_pts < 2: + return NoGeometryPick() + pts = self._cell.GetPoints() + best_i, best_j = 0, 1 + min_dist2 = float("inf") + for i in range(n_pts): + j = (i + 1) % n_pts + p0 = pts.GetPoint(i) + p1 = pts.GetPoint(j) + seg = [p1[k] - p0[k] for k in range(3)] + seg_len2 = sum(s * s for s in seg) + if seg_len2 > 0: + t = sum((pick_pos[k] - p0[k]) * seg[k] for k in range(3)) / seg_len2 + t = max(0.0, min(1.0, t)) + else: + t = 0.0 + closest = [p0[k] + t * seg[k] for k in range(3)] + d2 = sum((pick_pos[k] - closest[k]) ** 2 for k in range(3)) + if d2 < min_dist2: + min_dist2 = d2 + best_i, best_j = i, j + pt_a = list(pts.GetPoint(best_i)) + pt_b = list(pts.GetPoint(best_j)) + length = math.sqrt(sum((a - b) ** 2 for a, b in zip(pt_a, pt_b))) + return EdgePick(point_a=pt_a, point_b=pt_b, length=length) + + def whole_face(self) -> FacePick: + """Every cell point and the fan-triangulated area.""" + n_pts = self._cell.GetNumberOfPoints() + pts = self._cell.GetPoints() + p0 = pts.GetPoint(0) + cx, cy, cz = 0.0, 0.0, 0.0 + for i in range(1, n_pts - 1): + a = pts.GetPoint(i) + b = pts.GetPoint(i + 1) + ax, ay, az = a[0] - p0[0], a[1] - p0[1], a[2] - p0[2] + bx, by, bz = b[0] - p0[0], b[1] - p0[1], b[2] - p0[2] + cx += ay * bz - az * by + cy += az * bx - ax * bz + cz += ax * by - ay * bx + area = 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz) + points = [list(pts.GetPoint(i)) for i in range(n_pts)] + return FacePick(area=area, points=points) + + def find(self, mode: str) -> GeometryPick: + """The feature for mode; an unknown mode is not found.""" + if mode == "vertex": + return self.nearest_vertex() + if mode == "edge": + return self.nearest_edge() + if mode == "face": + return self.whole_face() + return NoGeometryPick() diff --git a/src/ansys/visor/viewer/vtk/picking/part_cell_hit.py b/src/ansys/visor/viewer/vtk/picking/part_cell_hit.py new file mode 100644 index 00000000..4038167b --- /dev/null +++ b/src/ansys/visor/viewer/vtk/picking/part_cell_hit.py @@ -0,0 +1,40 @@ +""" +PartCellHit +=========== +One part cell found under a pick, and the client view a pick is made +through. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from vtkmodules.vtkCommonDataModel import vtkDataObject + +from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState +from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick, NoGeometryPick +from ansys.visor.viewer.vtk.picking.cell_feature_finder import CellFeatureFinder + + +@dataclass(frozen=True) +class PartCellHit: + """One cell of one registered part under a pick, and where the ray met it.""" + + node_id: int + cell_id: int + position: tuple[float, float, float] + dataset: vtkDataObject + + def find_feature(self, mode: str) -> GeometryPick: + """The feature of this cell for mode; a cell id outside the dataset is not found.""" + finder = CellFeatureFinder.from_dataset(self.dataset, self.cell_id, self.position) + return finder.find(mode) if finder is not None else NoGeometryPick() + + +@dataclass(frozen=True) +class PickView: + """A client's camera record and window size, to pick through without touching this window.""" + + camera_state: VisorCameraState + width: int + height: int diff --git a/src/ansys/visor/viewer/vtk/scene/base.py b/src/ansys/visor/viewer/vtk/scene/base.py index eb3ea190..73037bbe 100644 --- a/src/ansys/visor/viewer/vtk/scene/base.py +++ b/src/ansys/visor/viewer/vtk/scene/base.py @@ -22,6 +22,7 @@ from ansys.visor.viewer.models.common.visor_variable_state import VisorVariableState from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1 from ansys.visor.viewer.models.runtime.dataset.runtime_dataset_state import RuntimePartProperties +from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState @@ -1009,6 +1010,16 @@ def set_projection(self, parallel: bool) -> None: with self._vtk_lock: self._renderer.set_projection(parallel) + def set_geometry_highlight(self, pick: GeometryPick) -> None: + """Highlight pick's feature in the scene, or clear the highlight when not found. + + No store field: the highlight is transient view state, never saved or + delivered. The renderer's call does its own client push, without + rebuilding the frontend, so there is no notify here. + """ + with self._vtk_lock: + self._renderer.show_geometry_highlight(pick) + # ========================================================================= # UI panel layout — coordinator surface # diff --git a/src/ansys/visor/viewer/vtk/scene/local_scene.py b/src/ansys/visor/viewer/vtk/scene/local_scene.py index c9b30146..3abc2f74 100644 --- a/src/ansys/visor/viewer/vtk/scene/local_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/local_scene.py @@ -4,8 +4,10 @@ from ansys.visor.viewer.vtk.scene.base import VisorSceneBase if TYPE_CHECKING: + from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer + from ansys.visor.viewer.vtk.picking.part_cell_hit import PickView logger = VisorDefaultLogger(__name__) @@ -76,6 +78,11 @@ def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) actor_wasm_id, cell_id, mode, (world_x, world_y, world_z) ) + def pick_geometry_in_view(self, view: "PickView", x: int, y: int, mode: str) -> "GeometryPick": + """Find the geometry feature under a pixel of a client's view, under the scene lock.""" + with self._vtk_lock: + return self._renderer.pick_geometry_in_view(view, x, y, mode) + # ------------------------------------------------------------------------- # Wasm-specific helpers (not part of VisorSceneBase) # ------------------------------------------------------------------------- diff --git a/src/ansys/visor/viewer/vtk/scene/server_scene.py b/src/ansys/visor/viewer/vtk/scene/server_scene.py index 3775f47f..2554ad1a 100644 --- a/src/ansys/visor/viewer/vtk/scene/server_scene.py +++ b/src/ansys/visor/viewer/vtk/scene/server_scene.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from ansys.visor.viewer.app.trame.server_loop_bridge import ServerLoopBridge + from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.renderer.server_renderer import ViewRecordPublisher @@ -69,6 +70,11 @@ def pick_part_at(self, x: int, y: int) -> int | None: with self._vtk_lock: return self._renderer.pick_part_at(x, y) + def pick_geometry_at(self, x: int, y: int, mode: str) -> GeometryPick: + """Find the geometry feature under a render-window pixel, under the scene lock.""" + with self._vtk_lock: + return self._renderer.pick_geometry_at(x, y, mode) + def set_dark_mode(self, dark_mode: bool) -> None: """Set the theme on the UI record and re-colour the background, under the scene lock; requests one frame.""" with self._vtk_lock: diff --git a/src/ansys/visor/viewer/vtk/widgets/geometry_highlight.py b/src/ansys/visor/viewer/vtk/widgets/geometry_highlight.py new file mode 100644 index 00000000..cd872283 --- /dev/null +++ b/src/ansys/visor/viewer/vtk/widgets/geometry_highlight.py @@ -0,0 +1,166 @@ +""" +GeometryHighlight +================= +One server-side actor that draws a geometry pick: a dot at a vertex, a line +along an edge, or a cell as a translucent polygon with an opaque outline. Point +sizes and line widths are set in CSS pixels and drawn in device pixels. + +It is a scene actor like any other, so the parts in front of it hide it. It is +never pickable: a part pick must pass through it. +""" + +from __future__ import annotations + +from typing import Sequence + +from vtkmodules.vtkCommonCore import vtkPoints, vtkUnsignedCharArray +from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkPolyData +from vtkmodules.vtkRenderingCore import vtkActor, vtkPolyDataMapper + +HIGHLIGHT_RGB: tuple[int, int, int] = (255, 165, 0) +"""Orange, as 0-255 components.""" + +OUTLINE_ALPHA: int = 255 +"""Dots and lines are opaque.""" + +FILL_ALPHA: int = 89 +"""A face's fill opacity, 0.35 of 255.""" + +EDGE_DOT_ALPHA: int = 178 +"""An edge's end dot opacity, 0.70 of 255.""" + +VERTEX_DIAMETER_CSS: float = 21.0 +"""A vertex dot's diameter in CSS pixels.""" + +EDGE_DOT_DIAMETER_CSS: float = 10.0 +"""An edge end dot's diameter in CSS pixels.""" + +EDGE_LINE_WIDTH_CSS: float = 5.0 +"""An edge's line width in CSS pixels.""" + +FACE_LINE_WIDTH_CSS: float = 3.0 +"""A face outline's line width in CSS pixels.""" + +# Negative offsets pull the highlight towards the camera, so it does not fight +# the part surface it lies on. +COINCIDENT_POLYGON_OFFSET: tuple[float, float] = (-2.0, -2.0) +COINCIDENT_LINE_OFFSET: tuple[float, float] = (-2.0, -2.0) +COINCIDENT_POINT_OFFSET: float = -2.0 + + +class GeometryHighlight: + """A non-pickable actor drawing one geometry pick: a dot, a line, or a filled outline.""" + + _polydata: vtkPolyData + _actor: vtkActor + _pixel_scale: float + _point_diameter_css: float + _line_width_css: float + + def __init__(self, pixel_scale: float) -> None: + """Hidden and non-pickable, drawn in orange cell colours with lighting off; pixel_scale is device pixels per CSS pixel.""" + self._pixel_scale = pixel_scale + self._point_diameter_css = VERTEX_DIAMETER_CSS + self._line_width_css = FACE_LINE_WIDTH_CSS + self._polydata = vtkPolyData() + + mapper = vtkPolyDataMapper() + mapper.SetInputData(self._polydata) + mapper.ScalarVisibilityOn() + mapper.SetScalarModeToUseCellData() + mapper.SetColorModeToDirectScalars() + mapper.SetRelativeCoincidentTopologyPolygonOffsetParameters(*COINCIDENT_POLYGON_OFFSET) + mapper.SetRelativeCoincidentTopologyLineOffsetParameters(*COINCIDENT_LINE_OFFSET) + mapper.SetRelativeCoincidentTopologyPointOffsetParameter(COINCIDENT_POINT_OFFSET) + + self._actor = vtkActor() + self._actor.SetMapper(mapper) + self._actor.PickableOff() + self._actor.VisibilityOff() + prop = self._actor.GetProperty() + prop.LightingOff() + prop.RenderPointsAsSpheresOn() + self._apply_sizes() + + @property + def actor(self) -> vtkActor: + """The actor to add to a renderer.""" + return self._actor + + @property + def visible(self) -> bool: + """Whether the actor is shown.""" + return bool(self._actor.GetVisibility()) + + def set_pixel_scale(self, pixel_scale: float) -> None: + """Set the device pixels per CSS pixel and rescale the drawn point size and line width; the geometry is kept.""" + self._pixel_scale = pixel_scale + self._apply_sizes() + + def show_vertex(self, position: Sequence[float]) -> None: + """Draw one dot at position and show the actor.""" + self._set_sizes_css(VERTEX_DIAMETER_CSS, FACE_LINE_WIDTH_CSS) + self._draw([position], verts=[[0]], lines=[], polys=[], vert_alpha=OUTLINE_ALPHA) + + def show_edge(self, point_a: Sequence[float], point_b: Sequence[float]) -> None: + """Draw a line between the two points, with a translucent dot at each end, and show the actor.""" + self._set_sizes_css(EDGE_DOT_DIAMETER_CSS, EDGE_LINE_WIDTH_CSS) + self._draw([point_a, point_b], verts=[[0], [1]], lines=[[0, 1]], polys=[], vert_alpha=EDGE_DOT_ALPHA) + + def show_face(self, points: Sequence[Sequence[float]]) -> None: + """Draw the points as a translucent polygon with an opaque closed outline, and show the actor.""" + self._set_sizes_css(VERTEX_DIAMETER_CSS, FACE_LINE_WIDTH_CSS) + ids = list(range(len(points))) + self._draw(points, verts=[], lines=[ids + [0]], polys=[ids], vert_alpha=OUTLINE_ALPHA) + + def hide(self) -> None: + """Hide the actor; the drawn geometry is kept.""" + self._actor.VisibilityOff() + + def _set_sizes_css(self, point_diameter_css: float, line_width_css: float) -> None: + """Record the point diameter and line width in CSS pixels and apply them at the current pixel scale.""" + self._point_diameter_css = point_diameter_css + self._line_width_css = line_width_css + self._apply_sizes() + + def _apply_sizes(self) -> None: + """Write the recorded CSS sizes to the actor in device pixels.""" + prop = self._actor.GetProperty() + prop.SetPointSize(self._point_diameter_css * self._pixel_scale) + prop.SetLineWidth(self._line_width_css * self._pixel_scale) + + def _draw( + self, + points: Sequence[Sequence[float]], + verts: list[list[int]], + lines: list[list[int]], + polys: list[list[int]], + vert_alpha: int, + ) -> None: + """Replace the drawn geometry: point coordinates, then vertex, line and polygon cells as point-index lists. + + Cell colours follow VTK's cell order for polydata: verts, then lines, + then polys. + """ + vtk_points = vtkPoints() + for point in points: + vtk_points.InsertNextPoint(float(point[0]), float(point[1]), float(point[2])) + + colors = vtkUnsignedCharArray() + colors.SetNumberOfComponents(4) + colors.SetName("HighlightColors") + cell_arrays = [] + for cells, alpha in ((verts, vert_alpha), (lines, OUTLINE_ALPHA), (polys, FILL_ALPHA)): + cell_array = vtkCellArray() + for cell in cells: + cell_array.InsertNextCell(len(cell), cell) + colors.InsertNextTypedTuple((*HIGHLIGHT_RGB, alpha)) + cell_arrays.append(cell_array) + + self._polydata.Initialize() + self._polydata.SetPoints(vtk_points) + self._polydata.SetVerts(cell_arrays[0]) + self._polydata.SetLines(cell_arrays[1]) + self._polydata.SetPolys(cell_arrays[2]) + self._polydata.GetCellData().SetScalars(colors) + self._actor.VisibilityOn() diff --git a/src/ansys/visor/visor-client/src/VisorFrontend.tsx b/src/ansys/visor/visor-client/src/VisorFrontend.tsx index bff015cf..b48f217f 100644 --- a/src/ansys/visor/visor-client/src/VisorFrontend.tsx +++ b/src/ansys/visor/visor-client/src/VisorFrontend.tsx @@ -137,7 +137,7 @@ export class VisorFrontend { this.getSelectionMode = () => selectionMode; this.setSelectionMode = (mode: SelectionMode) => { selectionMode = mode; - // Clear the client-side highlight overlay whenever mode changes + // Clear the geometry highlight whenever mode changes renderer.clearGeometryHighlight(); for (const cb of selectionModeChangedListeners.values()) cb(mode); }; @@ -155,7 +155,7 @@ export class VisorFrontend { const mode = selectionMode; if (mode === 'part') return null; const result = await renderer.pickGeometryAsync(normX, normY, mode); - // Draw client-side highlight overlay (no server sync needed) + // Highlight the picked feature; the renderer decides where it is drawn renderer.setGeometryHighlightAsync(result).catch(() => {}); for (const cb of geometryPickedListeners.values()) cb(result); return result; diff --git a/src/ansys/visor/visor-client/src/buildFrontendAsync.ts b/src/ansys/visor/visor-client/src/buildFrontendAsync.ts index cc3f072b..963d3209 100644 --- a/src/ansys/visor/visor-client/src/buildFrontendAsync.ts +++ b/src/ansys/visor/visor-client/src/buildFrontendAsync.ts @@ -3,7 +3,7 @@ import VisorSceneDetails from './state/appstate/VisorSceneDetails.tsx'; import type { StateInput } from './state/appstate/VisorStateCommon.tsx'; import type { IRenderer, TrameTriggerSender } from './renderer/IRenderer'; -/** Build and seed a frontend from delivered scene details; throws SchemaVersionMismatchError before any renderer exists. */ +/** Build and seed a frontend from delivered scene details, clearing the renderer's geometry highlight once; throws SchemaVersionMismatchError before any renderer exists. */ export async function buildFrontendAsync( sceneDetailsJson: StateInput, createRenderer: (sceneDetails: VisorSceneDetails) => Promise, @@ -12,6 +12,8 @@ export async function buildFrontendAsync( ): Promise { const sceneDetails = new VisorSceneDetails(sceneDetailsJson); const renderer = await createRenderer(sceneDetails); + // A new frontend starts in part mode, so no highlight from before the build may remain. + renderer.clearGeometryHighlight(); const frontend = new VisorFrontend( renderer, sceneDetails.vtkInfo.sceneGraph, diff --git a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomRight.tsx b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomRight.tsx index c393de9f..ab3701e8 100644 --- a/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomRight.tsx +++ b/src/ansys/visor/visor-client/src/components/ui-panels/Panel_BottomRight.tsx @@ -44,7 +44,7 @@ export const Panel_BottomRight: FC<{ } else if (result.mode === 'edge') { const [ax, ay, az] = result.pointA as number[]; const [bx, by, bz] = result.pointB as number[]; - const len: number = result.length; + const len: number = result.edgeLength; const text = `Point A: (${fmtCoord(ax)}, ${fmtCoord(ay)}, ${fmtCoord(az)}) ` + `Point B: (${fmtCoord(bx)}, ${fmtCoord(by)}, ${fmtCoord(bz)}) ` + diff --git a/src/ansys/visor/visor-client/src/input/ViewportPixel.ts b/src/ansys/visor/visor-client/src/input/ViewportPixel.ts new file mode 100644 index 00000000..f680777a --- /dev/null +++ b/src/ansys/visor/visor-client/src/input/ViewportPixel.ts @@ -0,0 +1,7 @@ +/** Converts a viewport-normalised coordinate to a render-window device pixel. */ +export class ViewportPixel { + /** The pixel under a normalised coordinate on an axis of the given size, clamped to [0, size - 1]. */ + static of(norm: number, size: number): number { + return Math.min(Math.max(Math.floor(norm * size), 0), size - 1); + } +} diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx index 91c6c1d4..fbc6de5f 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx @@ -81,6 +81,7 @@ function mockMakeWasmView() { function mockMakeRenderer() { return { domElement: document.createElement('div'), + clearGeometryHighlight: () => {}, addCameraSettledListener: () => () => {}, addViewerClickedListener: () => () => {}, resizeAsync: async () => undefined, diff --git a/src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts b/src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts index e89599d7..33372690 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/BuildFrontend.test.ts @@ -1,13 +1,38 @@ import { buildFrontendAsync } from '../buildFrontendAsync'; +import { NullRenderer } from '../renderer/NullRenderer'; import { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; /** * Scene details at the wrong schema version are rejected before any renderer - * is created for them. + * is created for them. A build clears the new renderer's geometry highlight + * once. * * Expected values are hand-written literals. */ +/** A minimal v4 scene-details payload with no renderer annotation. */ +function sceneDetailsPayload() { + return { + schemaVersion: 4, + ui: {}, + scene: { unit: '', orthographicEnabled: null }, + vtkInfo: { + sceneGraph: { + id: 0, + dataArrays: [], + name: '', + isGroupNode: true, + isActorNode: false, + nodeType: 'root', + diffuseColor: [1, 1, 1], + bounds: [], + children: [], + }, + rendererAnnotation: null, + }, + }; +} + describe('buildFrontendAsync', () => { test('scene details at schema version 3 reject with SchemaVersionMismatchError before any renderer is created', async () => { const createRenderer = jest.fn(); @@ -26,4 +51,14 @@ describe('buildFrontendAsync', () => { expect(error.expectedVersion).toBe(4); expect(createRenderer).not.toHaveBeenCalled(); }); + + test("a build clears the renderer's geometry highlight once", async () => { + const renderer = new NullRenderer(); + const clear = jest.spyOn(renderer, 'clearGeometryHighlight'); + const sender = jest.fn(async () => undefined); + + await buildFrontendAsync(sceneDetailsPayload(), async () => renderer, sender, undefined); + + expect(clear).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/ansys/visor/visor-client/src/jest-tests/GeometryPickReader.test.ts b/src/ansys/visor/visor-client/src/jest-tests/GeometryPickReader.test.ts new file mode 100644 index 00000000..9dd7a256 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/GeometryPickReader.test.ts @@ -0,0 +1,60 @@ +import { GeometryPickReader } from '../renderer/GeometryPickReader'; + +/** + * A pick_geometry_at result is read into one of the four pick shapes, keeping + * only that shape's keys; anything else is refused. + * + * Expected values are hand-written literals. + */ + +describe('GeometryPickReader', () => { + test('a face result reads as its area and corners', () => { + const value = { + found: true, + mode: 'face', + area: 0.5, + points: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + ], + extra: 1, + }; + + expect(GeometryPickReader.parse(value)).toStrictEqual({ + found: true, + mode: 'face', + area: 0.5, + points: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + ], + }); + }); + + test('an edge result reads as its endpoints and edgeLength, without a length key', () => { + const value = { + found: true, + mode: 'edge', + pointA: [0, 0, 0], + pointB: [3, 4, 0], + edgeLength: 5, + length: 5, + }; + + expect(GeometryPickReader.parse(value)).toStrictEqual({ + found: true, + mode: 'edge', + pointA: [0, 0, 0], + pointB: [3, 4, 0], + edgeLength: 5, + }); + }); + + test('an edge result without pointB throws TypeError', () => { + const value = { found: true, mode: 'edge', pointA: [0, 0, 0], edgeLength: 1 }; + + expect(() => GeometryPickReader.parse(value)).toThrow(TypeError); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts index 6853a92b..3e853e24 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts +++ b/src/ansys/visor/visor-client/src/jest-tests/ServerRendererPick.test.ts @@ -9,6 +9,9 @@ import type { CanvasClick, IServerCanvas } from '../rca/ServerCanvas'; * * The page is 300 x 200 CSS px at device pixel ratio 2, so the frame is * 600 x 400. Expected values are hand-written literals. + * + * A geometry pick asks the server in the same pixels, and a highlight or a + * clear asks the server to draw or drop it. */ let originalDevicePixelRatio: number; @@ -114,3 +117,41 @@ describe('ServerRenderer pick', () => { expect(sender).toHaveBeenCalledTimes(1); }); }); + +describe('ServerRenderer geometry pick', () => { + test('pickGeometryAsync sends one pick_geometry_at in render-window pixels from the frame', async () => { + const { canvas } = stubCanvas(); + const sender = jest.fn( + async (_name: string, _payload: unknown) => ({ found: false }) as unknown + ); + const renderer = new ServerRenderer(canvas, emptyState, sender); + + await renderer.pickGeometryAsync(0.25, 0.25, 'vertex'); + + expect(sender.mock.calls).toStrictEqual([ + ['pick_geometry_at', { x: 150, y: 100, mode: 'vertex' }], + ]); + }); + + test('a highlight then a clear each send set_geometry_highlight with the pick', async () => { + const { canvas } = stubCanvas(); + const sender = jest.fn(async (_name: string, _payload: unknown) => undefined as unknown); + const renderer = new ServerRenderer(canvas, emptyState, sender); + + await renderer.setGeometryHighlightAsync({ + found: true, + mode: 'vertex', + position: [1, 2, 3], + }); + renderer.clearGeometryHighlight(); + await settle(); + + expect(sender.mock.calls).toStrictEqual([ + [ + 'set_geometry_highlight', + { pick: { found: true, mode: 'vertex', position: [1, 2, 3] } }, + ], + ['set_geometry_highlight', { pick: { found: false } }], + ]); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx index ad59a385..910d06a2 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx @@ -115,6 +115,7 @@ function mockMakeRenderer() { const index = mockScene.renderersBuilt++; return { domElement: document.createElement('div'), + clearGeometryHighlight: () => {}, addCameraSettledListener: (callback: (origin: CameraOrigin) => void) => { const remover = () => { mockScene.settledListeners.delete(remover); diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx index 01508bd5..725db67d 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkRebuild.test.tsx @@ -86,6 +86,7 @@ function mockMakeRenderer() { mockScene.renderers.push(record); return { domElement: document.createElement('div'), + clearGeometryHighlight: () => {}, addCameraSettledListener: () => () => {}, addViewerClickedListener: () => { const remover = () => { diff --git a/src/ansys/visor/visor-client/src/renderer/GeometryPickReader.ts b/src/ansys/visor/visor-client/src/renderer/GeometryPickReader.ts new file mode 100644 index 00000000..51db43d7 --- /dev/null +++ b/src/ansys/visor/visor-client/src/renderer/GeometryPickReader.ts @@ -0,0 +1,66 @@ +import type { PickGeometryResult } from './IRenderer'; + +/** Reads a geometry pick result from the server into one of the four PickGeometryResult shapes. */ +export class GeometryPickReader { + /** The result as not found, or a vertex, edge or face; throws TypeError for any other value. */ + static parse(value: unknown): PickGeometryResult { + if (value == null || typeof value !== 'object') { + throw new TypeError(`geometry pick result is not an object: ${String(value)}`); + } + const obj = value as Record; + if (obj.found === false) { + return { found: false }; + } + if (obj.found !== true) { + throw new TypeError(`geometry pick result found is not a boolean`); + } + switch (obj.mode) { + case 'vertex': + return { + found: true, + mode: 'vertex', + position: GeometryPickReader.#point(obj.position, 'position'), + }; + case 'edge': + return { + found: true, + mode: 'edge', + pointA: GeometryPickReader.#point(obj.pointA, 'pointA'), + pointB: GeometryPickReader.#point(obj.pointB, 'pointB'), + edgeLength: GeometryPickReader.#number(obj.edgeLength, 'edgeLength'), + }; + case 'face': + if (!Array.isArray(obj.points)) { + throw new TypeError(`geometry pick result points is not a list`); + } + return { + found: true, + mode: 'face', + area: GeometryPickReader.#number(obj.area, 'area'), + points: obj.points.map((p, i) => GeometryPickReader.#point(p, `points[${i}]`)), + }; + default: + throw new TypeError(`geometry pick result mode is unknown: ${String(obj.mode)}`); + } + } + + /** A list of three numbers; throws TypeError naming the field otherwise. */ + static #point(value: unknown, name: string): readonly number[] { + if ( + !Array.isArray(value) || + value.length !== 3 || + !value.every((v) => typeof v === 'number') + ) { + throw new TypeError(`geometry pick result ${name} is not 3 numbers`); + } + return [...value]; + } + + /** A number; throws TypeError naming the field otherwise. */ + static #number(value: unknown, name: string): number { + if (typeof value !== 'number') { + throw new TypeError(`geometry pick result ${name} is not a number`); + } + return value; + } +} diff --git a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts index 66ee6ce3..7fe1c5ae 100644 --- a/src/ansys/visor/visor-client/src/renderer/IRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/IRenderer.ts @@ -66,7 +66,7 @@ export type PickGeometryResult = mode: 'edge'; pointA: readonly number[]; pointB: readonly number[]; - length: number; + edgeLength: number; } | { found: true; mode: 'face'; area: number; points: ReadonlyArray }; @@ -239,8 +239,8 @@ export interface IRenderer { normY: number, mode: GeometryPickMode ): Promise; - /** Draw the overlay for the last pick (vertex dot, edge line, face poly). */ + /** Highlight the picked feature (vertex dot, edge line, face polygon); a not-found result clears it. */ setGeometryHighlightAsync(result: PickGeometryResult): Promise; - /** Remove the overlay. */ + /** Remove the geometry highlight. */ clearGeometryHighlight(): void; } diff --git a/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts b/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts index 95302e9b..c98c5347 100644 --- a/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts +++ b/src/ansys/visor/visor-client/src/renderer/SceneTriggerSends.ts @@ -1,4 +1,9 @@ -import type { ColorVariableDescriptor, NodeId, TrameTriggerSender } from './IRenderer'; +import type { + ColorVariableDescriptor, + NodeId, + PickGeometryResult, + TrameTriggerSender, +} from './IRenderer'; /** The VISOR scene-mutation triggers: each method builds one payload and sends it; a failed send is logged, never thrown; a null sender sends nothing. */ export class SceneTriggerSends { @@ -152,4 +157,9 @@ export class SceneTriggerSends { ): Promise { await this.#sendWidgetTriggerAsync('sync_cross_section_plane', { origin, normal }); } + + /** Send the pick whose feature the server should highlight; a not-found pick clears it. */ + async sendGeometryHighlightAsync(pick: PickGeometryResult): Promise { + await this.#sendWidgetTriggerAsync('set_geometry_highlight', { pick }); + } } diff --git a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts index 94e73aa3..3b3a6b1b 100644 --- a/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts +++ b/src/ansys/visor/visor-client/src/renderer/ServerRenderer.ts @@ -9,6 +9,8 @@ import type { } from './IRenderer'; import type { CanvasClick, IServerCanvas } from '../rca/ServerCanvas'; import { ViewerClickDispatcher } from '../input/ViewerClickDispatcher'; +import { ViewportPixel } from '../input/ViewportPixel'; +import { GeometryPickReader } from './GeometryPickReader'; import { SceneTriggerSends } from './SceneTriggerSends'; /** The trame state key the server writes the camera record to. */ @@ -38,14 +40,6 @@ export class CrossSectionStateUnavailableError extends Error { } } -/** Raised by the geometry-pick members: geometry picking is not available when the server renders. */ -export class GeometryPickingUnavailableError extends Error { - constructor() { - super(`Geometry picking is not available when the server renders`); - this.name = `GeometryPickingUnavailableError`; - } -} - /** The seven camera fields the server publishes. */ type CameraRecord = Readonly<{ position: readonly number[]; @@ -257,8 +251,8 @@ export class ServerRenderer implements IRenderer { return; } // The frame is the render window as last drawn, in device pixels. - const x = ServerRenderer.#framePixel(click.normX, frameSize.width); - const y = ServerRenderer.#framePixel(click.normY, frameSize.height); + const x = ViewportPixel.of(click.normX, frameSize.width); + const y = ViewportPixel.of(click.normY, frameSize.height); this.#viewerClicks .dispatchAsync(click, async () => ServerRenderer.#parsePickResult(await this.#triggerSender('pick_part_at', { x, y })) @@ -271,11 +265,6 @@ export class ServerRenderer implements IRenderer { this.#fireCameraChanged(); }; - /** The render-window pixel under a normalised coordinate on an axis of the given size, clamped to the window. */ - static #framePixel(norm: number, size: number): number { - return Math.min(Math.max(Math.floor(norm * size), 0), size - 1); - } - /** Read a pick_part_at result: the node id, or null for no part; throws TypeError otherwise. */ static #parsePickResult(value: unknown): NodeId | null { if (value === null || Number.isInteger(value)) { @@ -577,19 +566,30 @@ export class ServerRenderer implements IRenderer { } // ---- Picking ------------------------------------------------------------ + /** Ask the server for the feature under a point of the last frame, in render-window pixels; rejects before the first frame. */ async pickGeometryAsync( - _normX: number, - _normY: number, - _mode: GeometryPickMode + normX: number, + normY: number, + mode: GeometryPickMode ): Promise { - throw new GeometryPickingUnavailableError(); + const frameSize = this.#canvas.frameSize; + if (frameSize == null) { + throw new Error('no frame drawn yet'); + } + const x = ViewportPixel.of(normX, frameSize.width); + const y = ViewportPixel.of(normY, frameSize.height); + return GeometryPickReader.parse( + await this.#triggerSender('pick_geometry_at', { x, y, mode }) + ); } - /** Rejects rather than doing nothing, so a caller that skips the pick fails loudly. */ - async setGeometryHighlightAsync(_result: PickGeometryResult): Promise { - throw new GeometryPickingUnavailableError(); + /** Ask the server to highlight the picked feature. */ + async setGeometryHighlightAsync(result: PickGeometryResult): Promise { + await this.#sends.sendGeometryHighlightAsync(result); } - /** There is never a highlight to clear. */ - clearGeometryHighlight(): void {} + /** Ask the server to clear the highlight; the send is not awaited. */ + clearGeometryHighlight(): void { + void this.#sends.sendGeometryHighlightAsync({ found: false }); + } } diff --git a/src/ansys/visor/visor-client/src/wasm/VtkScene.js b/src/ansys/visor/visor-client/src/wasm/VtkScene.js index 6adf453c..dc1aa7a0 100644 --- a/src/ansys/visor/visor-client/src/wasm/VtkScene.js +++ b/src/ansys/visor/visor-client/src/wasm/VtkScene.js @@ -189,7 +189,7 @@ export default class VtkScene { * @param {number} normX - Normalized x [0..1] (kept for API compatibility) * @param {number} normY - Normalized y [0..1] bottom→top (VTK convention) * @param {string} mode - 'vertex' | 'edge' | 'face' - * @return {Promise<{found:boolean, mode?:string, position?:number[], pointA?:number[], pointB?:number[], length?:number, area?:number, points?:number[][]}>} + * @return {Promise<{found:boolean, mode?:string, position?:number[], pointA?:number[], pointB?:number[], edgeLength?:number, area?:number, points?:number[][]}>} */ pickGeometryAsync = async (normX, normY, mode) => { const displayPos = this.#lastPickDisplayPos; diff --git a/tests/integration/test_visor_local_scene.py b/tests/integration/test_visor_local_scene.py index 85bdc21b..3c481488 100644 --- a/tests/integration/test_visor_local_scene.py +++ b/tests/integration/test_visor_local_scene.py @@ -250,7 +250,7 @@ def test_pick_geometry(pipeline_instance): assert res_edge["mode"] == "edge" assert tuple(res_edge["pointA"]) == tuple(p0) assert tuple(res_edge["pointB"]) == tuple(p1) - assert math.isclose(res_edge["length"], math.dist(p0, p1)) + assert math.isclose(res_edge["edgeLength"], math.dist(p0, p1)) else: pytest.skip("not enough points for edge pick test") diff --git a/tests/unit/app/test_scene_trigger_app.py b/tests/unit/app/test_scene_trigger_app.py index 201a5d8d..78d6d22e 100644 --- a/tests/unit/app/test_scene_trigger_app.py +++ b/tests/unit/app/test_scene_trigger_app.py @@ -2,16 +2,16 @@ Coverage targets ---------------- -1. ``LocalApp`` registers exactly the 21 trigger names it registered before - the move: the 18 shared ones defined on ``SceneTriggerApp`` plus its own - three local-only ones. Neither a missing nor a doubled registration - passes this. +1. ``LocalApp`` registers exactly these 22 trigger names: the 19 shared ones + defined on ``SceneTriggerApp`` plus its own three local-only ones. + Neither a missing nor a doubled registration passes this. 2. ``LocalApp`` seeds ``rendering_mode`` into server state as ``"client"`` before the server starts. +3. ``set_geometry_highlight`` hands the coordinator the parsed pick. -The coordinator and payload behaviour of the 18 shared triggers is already -pinned, trigger by trigger, in ``test_local_app.py`` and its sibling modules; -this module does not restate that. +The coordinator and payload behaviour of the other 18 shared triggers is +already pinned, trigger by trigger, in ``test_local_app.py`` and its sibling +modules; this module does not restate that. """ from unittest.mock import MagicMock @@ -19,8 +19,9 @@ import pytest from ansys.visor.viewer.app.trame.local_app import LocalApp +from ansys.visor.viewer.models.runtime.picking.geometry_pick import EdgePick -# The exact 21 trigger names LocalApp registers: the 18 names shared with +# The exact 22 trigger names LocalApp registers: the 19 names shared with # every rendering mode, plus LocalApp's own three local-only ones. Written # out by hand, not derived from the production trigger list, so a missing or # a doubled registration fails this rather than following along. @@ -44,6 +45,7 @@ "set_panel_top_right_panel_collapsed", "set_panel_top_right_legend_collapsed", "set_panel_top_right_tab_index", + "set_geometry_highlight", "pick_geometry", "perf_report_wasm", "perf_report_server_update", @@ -81,7 +83,7 @@ def mock_server(): def test_local_app_registers_exactly_these_triggers(mock_server): - """The registered trigger names are exactly the hand-written 21, sorted. + """The registered trigger names are exactly the hand-written 22, sorted. A missing registration shortens the list; a doubled one (``@TrameApp()`` on both the base and the leaf) duplicates a name in @@ -109,3 +111,30 @@ def test_local_app_seeds_rendering_mode_client(mock_server): assert mock_server.state["rendering_mode"] == "client" + +def test_set_geometry_highlight_forwards_the_parsed_pick(mock_server): + """The wire payload, keyed by alias, reaches the coordinator as the one pick model it validates as.""" + api = MagicMock(name="scene_mutation_api") + app = LocalApp( + server=mock_server, + get_scene_details_json=MagicMock(), + standalone=True, + scene_mutation_api=api, + ) + + result = app.set_geometry_highlight( + { + "pick": { + "found": True, + "mode": "edge", + "pointA": [0.0, 0.0, 0.0], + "pointB": [3.0, 4.0, 0.0], + "edgeLength": 5.0, + } + } + ) + + assert result is None + api.set_geometry_highlight.assert_called_once_with( + EdgePick(point_a=[0.0, 0.0, 0.0], point_b=[3.0, 4.0, 0.0], length=5.0) + ) diff --git a/tests/unit/app/test_server_app.py b/tests/unit/app/test_server_app.py index 7b312834..5dcf9e8a 100644 --- a/tests/unit/app/test_server_app.py +++ b/tests/unit/app/test_server_app.py @@ -17,10 +17,11 @@ from trame_rca.view_adapter import RcaViewAdapter from ansys.visor.viewer.app.trame.server_app import ServerApp +from ansys.visor.viewer.models.runtime.picking.geometry_pick import EdgePick from ansys.visor.viewer.renderer.server_window import VisorRcaWindow -# The exact 20 trigger names ServerApp registers: the 18 shared with every -# rendering mode, plus its own two. Written out by hand, not derived from the +# The exact 22 trigger names ServerApp registers: the 19 shared with every +# rendering mode, plus its own three. Written out by hand, not derived from the # production trigger list. SERVER_APP_TRIGGER_NAMES = sorted( [ @@ -42,7 +43,9 @@ "set_panel_top_right_panel_collapsed", "set_panel_top_right_legend_collapsed", "set_panel_top_right_tab_index", + "set_geometry_highlight", "pick_part_at", + "pick_geometry_at", "set_dark_theme", ] ) @@ -93,7 +96,9 @@ def __call__(self, *args): return self._result -def _build(mock_server, pick_part_at=None, set_dark_mode=None, rca_window=None, bridge=None): +def _build( + mock_server, pick_part_at=None, pick_geometry_at=None, set_dark_mode=None, rca_window=None, bridge=None +): return ServerApp( server=mock_server, get_scene_details_json=MagicMock(), @@ -101,6 +106,7 @@ def _build(mock_server, pick_part_at=None, set_dark_mode=None, rca_window=None, trame_logger=None, scene_mutation_api=MagicMock(), pick_part_at=pick_part_at if pick_part_at is not None else _Recorder(), + pick_geometry_at=pick_geometry_at if pick_geometry_at is not None else _Recorder(), set_dark_mode=set_dark_mode if set_dark_mode is not None else _Recorder(), rca_window=rca_window if rca_window is not None else MagicMock(name="rca_window"), bridge=bridge if bridge is not None else MagicMock(name="bridge"), @@ -108,7 +114,7 @@ def _build(mock_server, pick_part_at=None, set_dark_mode=None, rca_window=None, def test_server_app_registers_exactly_these_triggers(mock_server, area_cls): - """The registered trigger names are exactly the hand-written 20, sorted.""" + """The registered trigger names are exactly the hand-written 22, sorted.""" _build(mock_server) names = sorted(call.args[0] for call in mock_server.trigger.call_args_list) @@ -145,6 +151,34 @@ def test_pick_part_at_rejects_bad_payload(mock_server, area_cls): assert pick.calls == [] +def test_pick_geometry_at_forwards_and_dumps_by_alias(mock_server, area_cls): + """The pixel and mode reach the injected pick, and its result is returned in the wire's alias form.""" + pick = _Recorder(result=EdgePick(point_a=[0.0, 1.0, 2.0], point_b=[3.0, 4.0, 5.0], length=6.0)) + app = _build(mock_server, pick_geometry_at=pick) + + result = app.pick_geometry_at({"x": 3, "y": 4, "mode": "edge"}) + + assert result == { + "found": True, + "mode": "edge", + "pointA": [0.0, 1.0, 2.0], + "pointB": [3.0, 4.0, 5.0], + "edgeLength": 6.0, + } + assert pick.calls == [(3, 4, "edge")] + + +def test_pick_geometry_at_rejects_unknown_mode(mock_server, area_cls): + """A mode outside vertex, edge and face is a logged no-op: nothing is picked.""" + pick = _Recorder(result=EdgePick(point_a=[0.0, 1.0, 2.0], point_b=[3.0, 4.0, 5.0], length=6.0)) + app = _build(mock_server, pick_geometry_at=pick) + + result = app.pick_geometry_at({"x": 3, "y": 4, "mode": "bogus"}) + + assert result is None + assert pick.calls == [] + + def test_set_dark_theme_calls_set_dark_mode(mock_server, area_cls): """The client's theme reaches the injected set_dark_mode.""" set_dark_mode = _Recorder() @@ -207,10 +241,14 @@ class _SizeRecordingRenderer: def __init__(self) -> None: self.sizes: list[tuple[int, int]] = [] + self.pixel_scales: list[float] = [] def set_viewport_size(self, width: int, height: int) -> None: self.sizes.append((width, height)) + def set_pixel_scale(self, pixel_scale: float) -> None: + self.pixel_scales.append(pixel_scale) + class _StillCapture: """A capture that returns one literal frame and accepts events.""" @@ -240,4 +278,5 @@ def test_client_resize_sizes_render_window(mock_server, area_cls): rca_window.img_cols_rows assert renderer.sizes == [(601, 451)] + assert renderer.pixel_scales == [1.5] diff --git a/tests/unit/app/test_visor_vtk_server.py b/tests/unit/app/test_visor_vtk_server.py index f87087bd..b965cce2 100644 --- a/tests/unit/app/test_visor_vtk_server.py +++ b/tests/unit/app/test_visor_vtk_server.py @@ -55,6 +55,7 @@ def test_app_receives_the_scene_members(wiring): assert kwargs["scene_mutation_api"] is scene assert kwargs["rca_window"] is scene.rca_window assert kwargs["pick_part_at"] is scene.pick_part_at + assert kwargs["pick_geometry_at"] is scene.pick_geometry_at assert kwargs["set_dark_mode"] is scene.set_dark_mode diff --git a/tests/unit/renderer/test_local_renderer.py b/tests/unit/renderer/test_local_renderer.py index a2c0dd9d..a0d97616 100644 --- a/tests/unit/renderer/test_local_renderer.py +++ b/tests/unit/renderer/test_local_renderer.py @@ -31,6 +31,7 @@ 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.runtime.picking.geometry_pick import NoGeometryPick, VertexPick from ansys.visor.viewer.renderer.base import IRenderer from ansys.visor.viewer.renderer.local_renderer import VisorLocalRenderer from ansys.visor.viewer.renderer.null_renderer import NullRenderer @@ -131,7 +132,7 @@ class _CameraDouble: Getters return the literals above, so ``_read_pipeline_camera`` produces a valid VisorCameraState. Setters append to ``calls`` so - ``_apply_to_pipeline_camera`` can be asserted on by value *and* by order. + ``_write_camera_fields`` can be asserted on by value *and* by order. """ def __init__(self): @@ -160,7 +161,7 @@ def GetViewAngle(self): # noqa: N802 def GetParallelScale(self): # noqa: N802 return CAMERA_DOUBLE_PARALLEL_SCALE - # -- setters, written by _apply_to_pipeline_camera -- + # -- setters, written by _write_camera_fields -- def SetPosition(self, value): # noqa: N802 self.calls.append(("SetPosition", value)) @@ -285,6 +286,8 @@ def renderer(): ): r = VisorLocalRenderer(mock_server) + # Init adds the geometry highlight's actor; tests count part actors only. + vtk_renderer.AddActor.reset_mock() return r @@ -1202,6 +1205,26 @@ def test_flush_wasm_state_calls_local_view_update(self, renderer): renderer._local_view.update.assert_called_once() +class TestGeometryHighlightPush: + """The highlight reaches the client as one data-only push of the whole window.""" + + def test_set_pushes_once_with_data_only(self, renderer): + renderer.show_geometry_highlight(VertexPick(position=[1.0, 2.0, 3.0])) + renderer._local_view.update.assert_called_once_with(dataOnly=True) + + def test_clear_of_a_visible_highlight_pushes_once_with_data_only(self, renderer): + renderer.show_geometry_highlight(VertexPick(position=[1.0, 2.0, 3.0])) + renderer._local_view.update.reset_mock() + + renderer.show_geometry_highlight(NoGeometryPick()) + + renderer._local_view.update.assert_called_once_with(dataOnly=True) + + def test_clear_of_a_hidden_highlight_pushes_nothing(self, renderer): + renderer.show_geometry_highlight(NoGeometryPick()) + renderer._local_view.update.assert_not_called() + + class TestPickGeometry: @@ -1229,7 +1252,7 @@ def test_pick_edge_returns_closest_edge_endpoints_and_length(self, renderer): assert res["mode"] == "edge" assert tuple(res["pointA"]) == (2.0, 0.0, 0.0) assert tuple(res["pointB"]) == (2.0, 2.0, 0.0) - assert pytest.approx(res["length"]) == 2.0 + assert pytest.approx(res["edgeLength"]) == 2.0 def test_pick_face_returns_area_and_all_points(self, renderer): """Face mode returns the polygon area and every cell point.""" diff --git a/tests/unit/renderer/test_server_renderer.py b/tests/unit/renderer/test_server_renderer.py index 471a8ccd..dc2cb491 100644 --- a/tests/unit/renderer/test_server_renderer.py +++ b/tests/unit/renderer/test_server_renderer.py @@ -12,12 +12,19 @@ import pytest from vtkmodules.vtkCommonCore import vtkCommand -from vtkmodules.vtkFiltersSources import vtkSphereSource +from vtkmodules.vtkFiltersSources import vtkLineSource, vtkPlaneSource, vtkSphereSource from vtkmodules.vtkRenderingCore import vtkRenderWindow 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.runtime.picking.geometry_pick import ( + EdgePick, + FacePick, + NoGeometryPick, + VertexPick, +) from ansys.visor.viewer.renderer.server_renderer import ServerRenderer +from ansys.visor.viewer.vtk.picking.part_cell_hit import PickView PART_NODE_ID = 7 PART_BOUNDS = [-1.0, 1.0, -1.0, 1.0, -1.0, 1.0] @@ -81,13 +88,8 @@ def renderer(requester, publisher) -> ServerRenderer: def _outline_actor(renderer: ServerRenderer): - """The bounding-box outline: the one renderer actor that is not a part.""" - part_actors = [pipe.actor for pipe in renderer._pipelines.values()] - actors = renderer._vtk_renderer.GetActors() - others = [actors.GetItemAsObject(i) for i in range(actors.GetNumberOfItems())] - others = [a for a in others if not any(a is p for p in part_actors)] - assert len(others) == 1 - return others[0] + """The bounding-box outline actor, read from the widget that owns it.""" + return renderer._bounding_box_widget._VisorBoundingBox__outline_actor # --------------------------------------------------------------------------- @@ -122,6 +124,9 @@ def _outline_actor(renderer: ServerRenderer): ), "set_cross_section_visibility": lambda r: r.set_cross_section_visibility(True), "set_bounding_box_visibility": lambda r: r.set_bounding_box_visibility(True), + "show_geometry_highlight": lambda r: r.show_geometry_highlight( + VertexPick(position=[0.0, 0.0, 1.0]) + ), } @@ -136,6 +141,11 @@ def test_render_requests_a_frame(renderer, requester): assert requester.count == 1 +def test_not_found_on_a_hidden_highlight_requests_no_frame(renderer, requester): + renderer.show_geometry_highlight(NoGeometryPick()) + assert requester.count == 0 + + # --------------------------------------------------------------------------- # Camera writes publish the camera record # --------------------------------------------------------------------------- @@ -303,3 +313,76 @@ def test_pick_part_at_skips_the_outline(renderer, outline_bounds, x, y, expected assert _outline_actor(renderer).GetVisibility() == 1 assert renderer.pick_part_at(x, y) == expected + +def _renderer_with_part(requester, publisher, dataset, bounds) -> ServerRenderer: + """A ServerRenderer with one part, the camera reset to it, and the cross-section plane behind everything.""" + r = ServerRenderer((0.2, 0.4, 0.6), requester, publisher) + r.register_node(SimpleNamespace(id=PART_NODE_ID), dataset) + r.reset_camera(bounds) + r.sync_cross_section_plane([0.0, 0.0, -5.0], [0.0, 0.0, 1.0]) + return r + + +def test_pick_geometry_at_finds_the_face_of_a_plane_part(requester, publisher): + source = vtkPlaneSource() + source.Update() + r = _renderer_with_part(requester, publisher, source.GetOutput(), [-0.5, 0.5, -0.5, 0.5, 0.0, 0.0]) + + assert r.pick_geometry_at(150, 150, "face") == FacePick( + area=1.0, + points=[[-0.5, -0.5, 0.0], [0.5, -0.5, 0.0], [0.5, 0.5, 0.0], [-0.5, 0.5, 0.0]], + ) + + +def test_pick_geometry_at_miss_is_not_found(renderer): + assert renderer.pick_geometry_at(2, 2, "vertex") == NoGeometryPick() + + +_VIEW_CAMERA = VisorCameraState( + position=[0.0, 0.0, 9.0], + focal_point=[0.0, 0.0, 0.0], + view_up=[0.0, 1.0, 0.0], + clipping_range=[1.0, 20.0], + parallel_projection=False, + view_angle=30.0, + parallel_scale=1.0, +) + + +def test_pick_geometry_in_view_uses_the_view_camera_and_size(renderer): + """The pipeline camera looks away from the part; the view's camera looks at it, in a window wider than this one.""" + renderer.sync_camera( + VisorCameraState( + position=[0.0, 0.0, 9.0], + focal_point=[0.0, 0.0, 20.0], + view_up=[0.0, 1.0, 0.0], + clipping_range=[1.0, 20.0], + parallel_projection=False, + view_angle=30.0, + parallel_scale=1.0, + ) + ) + view = PickView(camera_state=_VIEW_CAMERA, width=600, height=400) + + assert renderer.pick_geometry_in_view(view, 300, 200, "vertex") == VertexPick(position=[0.0, 0.0, 1.0]) + + +@pytest.mark.parametrize( + ("offset", "expected"), + [ + (2, EdgePick(point_a=[-1.0, 0.0, 0.0], point_b=[1.0, 0.0, 0.0], length=2.0)), + (6, NoGeometryPick()), + ], + ids=["2px_hit", "6px_miss"], +) +def test_pick_geometry_in_view_resolves_tolerance_through_the_view(requester, publisher, offset, expected): + """The picker tolerance resolves through the view's 600x400 window: 2 px off a line part is a hit, 6 px is not.""" + source = vtkLineSource() + source.SetPoint1(-1.0, 0.0, 0.0) + source.SetPoint2(1.0, 0.0, 0.0) + source.Update() + r = _renderer_with_part(requester, publisher, source.GetOutput(), [-1.0, 1.0, 0.0, 0.0, 0.0, 0.0]) + view = PickView(camera_state=_VIEW_CAMERA, width=600, height=400) + + assert r.pick_geometry_in_view(view, 300, 200 + offset, "edge") == expected + diff --git a/tests/unit/renderer/test_server_window.py b/tests/unit/renderer/test_server_window.py index d0d8e8ea..7de656ea 100644 --- a/tests/unit/renderer/test_server_window.py +++ b/tests/unit/renderer/test_server_window.py @@ -113,7 +113,9 @@ def reschedule() -> _RecordingReschedule: @pytest.fixture def window(capture, renderer, lock, reschedule) -> VisorRcaWindow: - return VisorRcaWindow(capture, renderer, lock, reschedule) + bound = VisorRcaWindow(capture, renderer, lock, reschedule) + bound.bind_pixel_scale(lambda: 1.0) + return bound def test_held_lock_does_not_capture(window, capture, lock): @@ -178,6 +180,31 @@ def test_resize_coalesces_to_last_size(window, renderer, lock): assert renderer.render_window.GetSize() == (80, 60) +def test_resize_applies_the_bound_pixel_scale(capture, renderer, lock, reschedule): + scale = {"value": 2.0} + bound = VisorRcaWindow(capture, renderer, lock, reschedule) + bound.bind_pixel_scale(lambda: scale["value"]) + with patch.object(renderer, "set_pixel_scale") as spy: + bound.process_resize_event(64, 48) + bound.img_cols_rows + scale["value"] = 3.0 + bound.process_resize_event(64, 48) + bound.img_cols_rows + assert spy.call_args_list == [call(2.0), call(3.0)] + + +def test_resize_before_the_pixel_scale_is_bound_raises(capture, renderer, lock, reschedule): + unbound = VisorRcaWindow(capture, renderer, lock, reschedule) + unbound.process_resize_event(64, 48) + with pytest.raises(RuntimeError): + unbound.img_cols_rows + + +def test_pixel_scale_binds_once(window): + with pytest.raises(RuntimeError): + window.bind_pixel_scale(lambda: 2.0) + + def test_deferred_events_in_order_none_dropped(window, capture, lock): with lock_held_by_another_thread(lock): window.process_interaction_event(EVENT_1) diff --git a/tests/unit/vtk/scene/test_base.py b/tests/unit/vtk/scene/test_base.py index 5bd93c79..02b02ef2 100644 --- a/tests/unit/vtk/scene/test_base.py +++ b/tests/unit/vtk/scene/test_base.py @@ -37,6 +37,7 @@ RuntimePartProperties, VariableReference, ) +from ansys.visor.viewer.models.runtime.picking.geometry_pick import VertexPick from ansys.visor.viewer.models.runtime.scene.runtime_app_state import RuntimeAppState from ansys.visor.viewer.models.runtime.scene.runtime_scene_state import RuntimeSceneState from ansys.visor.viewer.models.runtime.scene.runtime_ui_state import RuntimeUIState @@ -2689,6 +2690,46 @@ def test_set_projection_does_not_notify_the_client(scene): assert notifications == [] +# --------------------------------------------------------------------------- +# set_geometry_highlight -- transient, locked, and silent +# --------------------------------------------------------------------------- + +def test_set_geometry_highlight_holds_the_lock_at_the_renderer_call(scene): + """The renderer's draw and push run inside the scene's critical section.""" + scene._vtk_lock = _LockSpy() + depths = [] + scene._renderer.show_geometry_highlight = lambda pick: depths.append(scene._vtk_lock.depth) + + scene.set_geometry_highlight(VertexPick(position=[1.0, 2.0, 3.0])) + + assert len(depths) == 1 + assert depths[0] >= 1 + assert scene._vtk_lock.depth == 0 + assert scene._vtk_lock.enter_count == scene._vtk_lock.exit_count + + +def test_set_geometry_highlight_does_not_notify_the_client(scene): + """No render, no flush, no delegated push: the renderer's own data-only push is the only one.""" + notifications = [] + scene._renderer.render = lambda: notifications.append("render") + scene._renderer.flush_wasm_state = lambda: notifications.append("flush") + scene._push_runtime_state = lambda state: notifications.append("bridge") + + scene.set_geometry_highlight(VertexPick(position=[1.0, 2.0, 3.0])) + + assert notifications == [] + + +def test_set_geometry_highlight_is_not_saved(scene): + """The highlight is transient: a save after it equals a save before it.""" + _toggle_save_scene(scene) + before = scene.get_state() + + scene.set_geometry_highlight(VertexPick(position=[1.0, 2.0, 3.0])) + + assert scene.get_state() == before + + # --------------------------------------------------------------------------- # get_state -- orthographic_enabled is derived from the record # --------------------------------------------------------------------------- diff --git a/tests/unit/vtk/scene/test_local_scene.py b/tests/unit/vtk/scene/test_local_scene.py index 59441b73..5d07425b 100644 --- a/tests/unit/vtk/scene/test_local_scene.py +++ b/tests/unit/vtk/scene/test_local_scene.py @@ -12,6 +12,7 @@ ) from ansys.visor.viewer.models.runtime.vtk.scene_graph_node_info import SceneGraphNodeInfo from ansys.visor.viewer.vtk.scene.local_scene import VisorLocalScene +from tests.helpers.utils.locks import LockSpy class _DummyPoints: @@ -718,6 +719,20 @@ def test_reset_camera_no_scene_graph_noop(pipeline_instance): pipeline_instance._renderer.reset_camera.assert_not_called() +def test_pick_geometry_in_view_holds_lock(pipeline_instance): + """The renderer's view pick runs with the scene lock held.""" + pipeline_instance._vtk_lock = LockSpy() + depths = [] + + def recording_pick_geometry_in_view(view, x, y, mode): + depths.append(pipeline_instance._vtk_lock.depth) + return None + + pipeline_instance._renderer.pick_geometry_in_view = recording_pick_geometry_in_view + pipeline_instance.pick_geometry_in_view(MagicMock(name="view"), 31, 17, "vertex") + assert depths == [1] + + # --------------------------------------------------------------------------- # Tests moved from tests/unit/app/test_finalize_scene.py # --------------------------------------------------------------------------- diff --git a/tests/unit/vtk/scene/test_server_scene.py b/tests/unit/vtk/scene/test_server_scene.py index f559ffb7..8be50389 100644 --- a/tests/unit/vtk/scene/test_server_scene.py +++ b/tests/unit/vtk/scene/test_server_scene.py @@ -141,6 +141,19 @@ def test_pick_part_at_returns_the_renderer_pick(scene): assert scene.pick_part_at(31, 17) == 42 +def test_pick_geometry_at_holds_lock(scene): + scene._vtk_lock = LockSpy() + depths = [] + + def recording_pick_geometry_at(x, y, mode): + depths.append(scene._vtk_lock.depth) + return None + + scene._renderer.pick_geometry_at = recording_pick_geometry_at + scene.pick_geometry_at(31, 17, "vertex") + assert depths == [1] + + # --------------------------------------------------------------------------- # RCA window # --------------------------------------------------------------------------- diff --git a/tests/unit/vtk/widgets/test_geometry_highlight.py b/tests/unit/vtk/widgets/test_geometry_highlight.py new file mode 100644 index 00000000..918831c9 --- /dev/null +++ b/tests/unit/vtk/widgets/test_geometry_highlight.py @@ -0,0 +1,141 @@ +""" +Unit tests for GeometryHighlight, driven through each pick's highlight_with. + +Every case builds a real GeometryHighlight and reads the polydata its actor's +mapper draws. Expected values are hand-written literals. +""" + +from __future__ import annotations + +from ansys.visor.viewer.models.runtime.picking.geometry_pick import ( + EdgePick, + FacePick, + NoGeometryPick, + VertexPick, +) +from ansys.visor.viewer.vtk.widgets.geometry_highlight import GeometryHighlight + + +def _drawn(highlight: GeometryHighlight): + return highlight.actor.GetMapper().GetInput() + + +def _points(polydata) -> list[tuple[float, float, float]]: + return [polydata.GetPoint(i) for i in range(polydata.GetNumberOfPoints())] + + +def _cells(cell_array) -> list[list[int]]: + cells = [] + offsets = cell_array.GetOffsetsArray() + connectivity = cell_array.GetConnectivityArray() + for c in range(cell_array.GetNumberOfCells()): + start, end = int(offsets.GetValue(c)), int(offsets.GetValue(c + 1)) + cells.append([int(connectivity.GetValue(i)) for i in range(start, end)]) + return cells + + +def test_vertex_pick_draws_one_dot_at_the_position(): + highlight = GeometryHighlight(1.0) + + VertexPick(position=[1.0, 2.0, 3.0]).highlight_with(highlight) + + drawn = _drawn(highlight) + assert _points(drawn) == [(1.0, 2.0, 3.0)] + assert _cells(drawn.GetVerts()) == [[0]] + + +def test_edge_pick_draws_a_line_and_its_end_dots(): + highlight = GeometryHighlight(1.0) + + EdgePick(point_a=[0.0, 0.0, 0.0], point_b=[3.0, 4.0, 0.0], length=5.0).highlight_with(highlight) + + drawn = _drawn(highlight) + assert _points(drawn) == [(0.0, 0.0, 0.0), (3.0, 4.0, 0.0)] + assert _cells(drawn.GetLines()) == [[0, 1]] + assert _cells(drawn.GetVerts()) == [[0], [1]] + + +def test_face_pick_draws_the_polygon_and_its_closed_outline(): + highlight = GeometryHighlight(1.0) + pentagon = [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [3.0, 1.0, 0.0], + [1.0, 2.0, 0.0], + [-1.0, 1.0, 0.0], + ] + + FacePick(area=5.0, points=pentagon).highlight_with(highlight) + + drawn = _drawn(highlight) + assert _points(drawn) == [ + (0.0, 0.0, 0.0), + (2.0, 0.0, 0.0), + (3.0, 1.0, 0.0), + (1.0, 2.0, 0.0), + (-1.0, 1.0, 0.0), + ] + assert _cells(drawn.GetPolys()) == [[0, 1, 2, 3, 4]] + assert _cells(drawn.GetLines()) == [[0, 1, 2, 3, 4, 0]] + + +def _rgba(polydata, cell: int) -> tuple[int, int, int, int]: + return tuple(int(v) for v in polydata.GetCellData().GetScalars().GetTuple4(cell)) + + +def test_vertex_point_size_is_the_css_diameter_times_the_pixel_scale(): + highlight = GeometryHighlight(2.0) + + VertexPick(position=[1.0, 2.0, 3.0]).highlight_with(highlight) + + assert highlight.actor.GetProperty().GetPointSize() == 42.0 + assert _rgba(_drawn(highlight), 0) == (255, 165, 0, 255) + + +def test_edge_sizes_follow_the_pixel_scale_when_it_changes(): + highlight = GeometryHighlight(1.0) + EdgePick(point_a=[0.0, 0.0, 0.0], point_b=[3.0, 4.0, 0.0], length=5.0).highlight_with(highlight) + prop = highlight.actor.GetProperty() + assert (prop.GetPointSize(), prop.GetLineWidth()) == (10.0, 5.0) + + highlight.set_pixel_scale(1.5) + + assert (prop.GetPointSize(), prop.GetLineWidth()) == (15.0, 7.5) + + +def test_edge_dots_are_translucent_and_the_line_is_opaque(): + highlight = GeometryHighlight(1.0) + + EdgePick(point_a=[0.0, 0.0, 0.0], point_b=[3.0, 4.0, 0.0], length=5.0).highlight_with(highlight) + + drawn = _drawn(highlight) + assert [_rgba(drawn, c) for c in range(3)] == [ + (255, 165, 0, 178), + (255, 165, 0, 178), + (255, 165, 0, 255), + ] + + +def test_face_outline_width_is_three_css_pixels_and_the_fill_is_translucent(): + highlight = GeometryHighlight(2.0) + triangle = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + FacePick(area=0.5, points=triangle).highlight_with(highlight) + + assert highlight.actor.GetProperty().GetLineWidth() == 6.0 + drawn = _drawn(highlight) + assert [_rgba(drawn, c) for c in range(2)] == [(255, 165, 0, 255), (255, 165, 0, 89)] + + +def test_not_found_pick_hides_a_shown_highlight(): + highlight = GeometryHighlight(1.0) + VertexPick(position=[1.0, 2.0, 3.0]).highlight_with(highlight) + assert highlight.visible is True + + NoGeometryPick().highlight_with(highlight) + + assert highlight.visible is False + + +def test_highlight_actor_is_not_pickable(): + assert GeometryHighlight(1.0).actor.GetPickable() == 0