Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/204.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pick and highlight geometry through the server in client mode
61 changes: 47 additions & 14 deletions src/ansys/visor/viewer/app/trame/local_app.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,50 @@
from logging import Logger
from typing import Callable, Protocol

from trame.app.core import Server
from trame.decorators import TrameApp, trigger
from vtk import vtkObject

from ansys.visor.viewer.app.trame.scene_trigger_app import SceneMutationApi, SceneTriggerApp
from ansys.visor.viewer.app.trame.scene_trigger_app import (
SceneMutationApi,
SceneTriggerApp,
parse_payload,
)
from ansys.visor.viewer.config import settings
from ansys.visor.viewer.core.visor_enums import RenderingMode
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick
from ansys.visor.viewer.models.runtime.requests.geometry_pick_payloads import PickGeometryInViewPayload
from ansys.visor.viewer.models.runtime.requests.pixel_scale_payload import SetPixelScalePayload
from ansys.visor.viewer.vtk.picking.part_cell_hit import PickView

logger = VisorDefaultLogger(__name__)

# Disable VTK warning display
vtkObject.GlobalWarningDisplayOff()


class LocalSceneMutationApi(SceneMutationApi, Protocol):
"""The scene surface LocalApp's triggers call: the shared one plus the client-only pixel scale."""

def set_pixel_scale(self, pixel_scale: float) -> None: ...


@TrameApp()
class LocalApp(SceneTriggerApp):
"""
LocalApp serves the scene triggers shared by every rendering mode, plus
the local-only triggers for sub-geometry picking and client performance
reporting, for client-side rendering via VTK-WASM in the browser.
the local-only triggers for sub-geometry picking through the client's
view and client performance reporting, for client-side rendering via
VTK-WASM in the browser.

Attributes:
server (Server): the Trame server instance
Methods:
pick_geometry: picks the geometry for rendering
pick_geometry_in_view: picks the geometry feature under a pixel of the client's window
perf_report_wasm: reports the performance of the wasm update cycle
perf_report_server_update: reports the performance of the server update cycle
set_pixel_scale: sets the device pixel ratio the geometry highlight is drawn at
set_only_cookie: sets a cookie on the server (note: Trame server only allows a single cookie header)
"""
def __init__(
Expand All @@ -36,11 +53,11 @@ def __init__(
get_scene_details_json: callable,
standalone: bool = True,
trame_logger: Logger | None = None,
pick_geometry=None,
scene_mutation_api: SceneMutationApi | None = None,
scene_mutation_api: LocalSceneMutationApi | None = None,
*,
pick_geometry_in_view: Callable[[PickView, int, int, str], GeometryPick],
):
# Callable for sub-geometry picking (optional), local-only.
self._pick_geometry = pick_geometry
self._pick_geometry_in_view = pick_geometry_in_view
super().__init__(
server=server,
get_scene_details_json=get_scene_details_json,
Expand All @@ -54,12 +71,28 @@ def rendering_mode(self) -> RenderingMode:
"""This app serves client-side rendering via VTK-WASM in the browser."""
return RenderingMode.CLIENT

@trigger("pick_geometry")
def pick_geometry(self, actor_wasm_id, cell_id, mode: str, world_x: float, world_y: float, world_z: float):
"""Frontend → Backend: perform sub-geometry picking given a WASM actor/cell ID and world-space point."""
if self._pick_geometry is None:
return {"found": False}
return self._pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z)
@trigger("pick_geometry_in_view")
@parse_payload(PickGeometryInViewPayload)
def pick_geometry_in_view(self, payload) -> dict:
"""Frontend -> Backend: the geometry feature under a pixel of the client's window, dumped by alias.

Picked through the client's camera and window size; highlights nothing.
"""
view = PickView(camera_state=payload.camera, width=payload.width, height=payload.height)
return self._pick_geometry_in_view(view, payload.x, payload.y, payload.mode).model_dump(by_alias=True)

@trigger("set_pixel_scale")
@parse_payload(SetPixelScalePayload)
def set_pixel_scale(self, payload) -> None:
"""Frontend -> Backend: the client reports its device pixel ratio for the geometry highlight.

Server mode learns the same ratio from the remote-canvas size report,
so only client mode registers this.
"""
api = self._mutation_api("set_pixel_scale", payload)
if api is None:
return
api.set_pixel_scale(payload.scale)

@trigger("perf_report_wasm")
def perf_report_wasm(self, payload: dict):
Expand Down
14 changes: 4 additions & 10 deletions src/ansys/visor/viewer/app/visor_vtk_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,11 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->
lambda: self._scene.get_scene_details_json(),
standalone,
trame_logger=get_trame_logger(trame_log_dir),
pick_geometry=lambda
actor_wasm_id,
cell_id,
mode,
world_x,
world_y,
world_z: self._scene.pick_geometry(actor_wasm_id, cell_id, mode, world_x, world_y, world_z),
# The scene satisfies LocalApp's SceneMutationApi protocol
# structurally: the six per-part coordinator methods carry exactly
# the names and signatures the triggers call.
# The scene satisfies LocalApp's LocalSceneMutationApi protocol
# structurally: the coordinator methods carry exactly the names
# and signatures the triggers call.
scene_mutation_api=self._scene,
pick_geometry_in_view=self._scene.pick_geometry_in_view,
)

def _post_scene_change_hook(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Models for the geometry-pick trigger payloads."""

from pydantic import BaseModel
from typing import Annotated

from pydantic import BaseModel, Field

from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.models.runtime.picking.geometry_pick import GeometryPick, GeometryPickMode


Expand All @@ -13,6 +16,17 @@ class PickGeometryAtPayload(BaseModel):
mode: GeometryPickMode


class PickGeometryInViewPayload(BaseModel):
"""pick_geometry_in_view payload: a pixel of the client's window, origin bottom-left, the window's size, its camera record, and the mode."""

x: int
y: int
width: Annotated[int, Field(gt=0)]
height: Annotated[int, Field(gt=0)]
mode: GeometryPickMode
camera: VisorCameraState


class SetGeometryHighlightPayload(BaseModel):
"""set_geometry_highlight payload: the pick whose feature to highlight; a not-found pick clears it."""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Model for the ``set_pixel_scale`` trigger payload."""

from typing import Annotated

from pydantic import BaseModel, Field


class SetPixelScalePayload(BaseModel):
"""set_pixel_scale payload: the client's device pixels per CSS pixel, finite and positive."""

scale: Annotated[float, Field(gt=0, allow_inf_nan=False)]
78 changes: 7 additions & 71 deletions src/ansys/visor/viewer/renderer/local_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,12 @@
from ansys.visor.viewer.core.perf_timer import PerfTimer
from ansys.visor.viewer.core.visor_colors import VisorColors
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.models.runtime.picking.geometry_pick import NoGeometryPick
from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import (
WasmNodeHandles,
WasmRendererAnnotation,
WasmWidgetHandles,
)
from ansys.visor.viewer.renderer.pipeline_renderer import VtkPipelineRenderer
from ansys.visor.viewer.vtk.picking.cell_feature_finder import CellFeatureFinder
from ansys.visor.viewer.vtk.widgets.visor_bounding_box import VisorBoundingBox
from ansys.visor.viewer.vtk.widgets.visor_cross_section import VisorCrossSectionWidget
from ansys.visor.viewer.vtk.widgets.visor_orientation import VisorOrientationWidget
Expand Down Expand Up @@ -200,7 +198,7 @@ def _present_geometry_highlight(self) -> None:
self.push_without_rebuild()

def _initial_highlight_pixel_scale(self) -> float:
"""1.0: the client draws its own overlay here, so the actor's sizes are not shown."""
"""1.0 until the client reports its device pixel ratio."""
return 1.0

# ------------------------------------------------------------------
Expand All @@ -217,72 +215,6 @@ def set_cross_section_visibility(self, visible: bool) -> None:
def set_bounding_box_visibility(self, visible: bool) -> None:
"""No-op: the widget's enablement is driven client-side."""

# ------------------------------------------------------------------
# Local-only: picking
# ------------------------------------------------------------------

def pick_geometry(
self,
actor_wasm_id: int,
cell_id: int,
mode: str,
world_pos: tuple[float, float, float],
) -> dict:
"""
Resolve a client-initiated pick event to a sub-geometry payload.

The actor is found by matching each renderer actor's object-manager
id against *actor_wasm_id*; the coordinator never performs that
resolution.

Parameters
----------
actor_wasm_id:
Client-supplied opaque actor identifier from the wasm-side picker.
cell_id:
Cell index within the picked actor's mapper dataset.
mode:
``"vertex" | "edge" | "face"``.
world_pos:
World-space pick position from the client picker, used to select
the nearest vertex/edge for multi-vertex/edge cells.

Returns
-------
dict
Backend-neutral pick result. Always contains ``"found": bool``.
When ``found`` is ``True``, contains ``"mode"`` and mode-specific
fields (``position`` / ``pointA``+``pointB``+``edgeLength`` /
``points``+``area``).
"""
actor_wasm_id = int(actor_wasm_id)
cell_id = int(cell_id)
pick_pos = (float(world_pos[0]), float(world_pos[1]), float(world_pos[2]))

if actor_wasm_id < 0 or cell_id < 0:
return NoGeometryPick().model_dump(by_alias=True)

# Locate the actor by iterating the renderer's actors and matching
# WASM IDs (avoids depending on a GetObjectAtId reverse-lookup API).
actor = None
actors = self._vtk_renderer.GetActors()
actors.InitTraversal()
a = actors.GetNextActor()
while a is not None:
if self._object_manager.GetId(a) == actor_wasm_id:
actor = a
break
a = actors.GetNextActor()

if actor is None:
return NoGeometryPick().model_dump(by_alias=True)

finder = CellFeatureFinder.from_dataset(
actor.GetMapper().GetInputDataObject(0, 0), cell_id, pick_pos
)
pick = finder.find(mode) if finder is not None else NoGeometryPick()
return pick.model_dump(by_alias=True)

# ------------------------------------------------------------------
# IRenderer: render
# ------------------------------------------------------------------
Expand All @@ -309,12 +241,16 @@ def flush_wasm_state(self) -> None:
self._local_view.update()

def push_without_rebuild(self) -> None:
"""Render, then push the whole render window to the wasm client as data only, so its frontend is not rebuilt.
"""Push the whole render window to the wasm client as data only, so its frontend is not rebuilt; renders nothing.

No object is named, so the window and everything it holds is sent,
including an actor that has just become visible.

The window is not rendered: this runs on the trigger thread, while the
window's GL context is current on the thread that rendered it first
(the API caller's), and a context is current on one thread at a time.
Only serialisation is needed: the wasm client draws the pushed state.
"""
self._render_window.Render()
self._local_view.update(**{DATA_ONLY_UPDATE_OPTION: True})

@property
Expand Down
8 changes: 8 additions & 0 deletions src/ansys/visor/viewer/renderer/pipeline_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,14 @@ def show_geometry_highlight(self, pick: GeometryPick) -> None:
pick.highlight_with(self._geometry_highlight)
self._present_geometry_highlight()

def set_pixel_scale(self, pixel_scale: float) -> None:
"""Rescale the geometry highlight to this many device pixels per CSS pixel; requests no frame and pushes nothing.

Server mode's resize path, which calls this, schedules its own frame.
In client mode the next highlight shown carries the new size.
"""
self._geometry_highlight.set_pixel_scale(pixel_scale)

# ------------------------------------------------------------------
# Internal init helpers
# ------------------------------------------------------------------
Expand Down
7 changes: 0 additions & 7 deletions src/ansys/visor/viewer/renderer/server_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,6 @@ def set_viewport_size(self, width: int, height: int) -> None:
self._render_window_interactor.UpdateSize(width, height)
self._orientation_widget.fit_to_window(width, height)

def set_pixel_scale(self, pixel_scale: float) -> None:
"""Rescale the geometry highlight to this many device pixels per CSS pixel.

Requests no frame: the resize path that calls this schedules its own.
"""
self._geometry_highlight.set_pixel_scale(pixel_scale)

def set_background_rgb(self, background_rgb: tuple[float, float, float]) -> None:
"""Clear to this colour from the next frame.

Expand Down
15 changes: 7 additions & 8 deletions src/ansys/visor/viewer/vtk/scene/local_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,16 @@ def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None:
self._frontend_bridge.set_state(runtime_app_state)

# -------------------------------------------------------------------------
# Picking — wasm: the client's picker reports a wasm actor id
# Picking — through the client's view, on the server picker
# -------------------------------------------------------------------------

def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) -> dict:
"""
Frontend-trigger entry point for cell picking. Packs the world-space
pick position into a tuple and delegates to the renderer backend.
def set_pixel_scale(self, pixel_scale: float) -> None:
"""Draw the geometry highlight at this many device pixels per CSS pixel; no push, no notify.

The next highlight shown carries the new size to the client.
"""
return self._renderer.pick_geometry(
actor_wasm_id, cell_id, mode, (world_x, world_y, world_z)
)
with self._vtk_lock:
self._renderer.set_pixel_scale(pixel_scale)

def pick_geometry_in_view(self, view: "PickView", x: int, y: int, mode: str) -> "GeometryPick":
"""Find the geometry feature under a pixel of a client's view, under the scene lock."""
Expand Down
Loading
Loading