diff --git a/opengeodeweb_viewer_schemas.json b/opengeodeweb_viewer_schemas.json index ffc673e0..1c17f132 100644 --- a/opengeodeweb_viewer_schemas.json +++ b/opengeodeweb_viewer_schemas.json @@ -2121,6 +2121,30 @@ ], "additionalProperties": false }, + "ruler": { + "$id": "opengeodeweb_viewer.viewer.ruler", + "rpc": "ruler", + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "minItems": 1, + "maxItems": 2 + } + }, + "required": [ + "points" + ], + "additionalProperties": false + }, "reset_visualization": { "$id": "opengeodeweb_viewer.viewer.reset_visualization", "rpc": "reset_visualization", @@ -2129,6 +2153,14 @@ "required": [], "additionalProperties": false }, + "reset_ruler": { + "$id": "opengeodeweb_viewer.viewer.reset_ruler", + "rpc": "reset_ruler", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, "reset_camera": { "$id": "opengeodeweb_viewer.viewer.reset_camera", "rpc": "reset_camera", diff --git a/requirements.txt b/requirements.txt index dc966e83..86373ac9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -63,4 +63,3 @@ wslink==1.12.4 yarl>=1 # via aiohttp -opengeodeweb-microservice==1.*,>=1.2.1rc1 diff --git a/src/opengeodeweb_viewer/rpc/viewer/schemas/__init__.py b/src/opengeodeweb_viewer/rpc/viewer/schemas/__init__.py index b3cfc453..9571601a 100644 --- a/src/opengeodeweb_viewer/rpc/viewer/schemas/__init__.py +++ b/src/opengeodeweb_viewer/rpc/viewer/schemas/__init__.py @@ -4,7 +4,9 @@ from .shrink import * from .set_z_scaling import * from .set_background_color import * +from .ruler import * from .reset_visualization import * +from .reset_ruler import * from .reset_camera import * from .render import * from .preview_points import * diff --git a/src/opengeodeweb_viewer/rpc/viewer/schemas/reset_ruler.json b/src/opengeodeweb_viewer/rpc/viewer/schemas/reset_ruler.json new file mode 100644 index 00000000..63c66eae --- /dev/null +++ b/src/opengeodeweb_viewer/rpc/viewer/schemas/reset_ruler.json @@ -0,0 +1,7 @@ +{ + "rpc": "reset_ruler", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false +} diff --git a/src/opengeodeweb_viewer/rpc/viewer/schemas/reset_ruler.py b/src/opengeodeweb_viewer/rpc/viewer/schemas/reset_ruler.py new file mode 100644 index 00000000..f672bbfb --- /dev/null +++ b/src/opengeodeweb_viewer/rpc/viewer/schemas/reset_ruler.py @@ -0,0 +1,10 @@ +from dataclasses_json import DataClassJsonMixin +from dataclasses import dataclass + + +@dataclass +class ResetRuler(DataClassJsonMixin): + def __post_init__(self) -> None: + print(self, flush=True) + + pass diff --git a/src/opengeodeweb_viewer/rpc/viewer/schemas/ruler.json b/src/opengeodeweb_viewer/rpc/viewer/schemas/ruler.json new file mode 100644 index 00000000..17c609fb --- /dev/null +++ b/src/opengeodeweb_viewer/rpc/viewer/schemas/ruler.json @@ -0,0 +1,23 @@ +{ + "rpc": "ruler", + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 3, + "maxItems": 3 + }, + "minItems": 1, + "maxItems": 2 + } + }, + "required": [ + "points" + ], + "additionalProperties": false +} diff --git a/src/opengeodeweb_viewer/rpc/viewer/schemas/ruler.py b/src/opengeodeweb_viewer/rpc/viewer/schemas/ruler.py new file mode 100644 index 00000000..54784b1c --- /dev/null +++ b/src/opengeodeweb_viewer/rpc/viewer/schemas/ruler.py @@ -0,0 +1,11 @@ +from dataclasses_json import DataClassJsonMixin +from dataclasses import dataclass +from typing import List + + +@dataclass +class Ruler(DataClassJsonMixin): + def __post_init__(self) -> None: + print(self, flush=True) + + points: List[List[float]] diff --git a/src/opengeodeweb_viewer/rpc/viewer/viewer_protocols.py b/src/opengeodeweb_viewer/rpc/viewer/viewer_protocols.py index 2bb02c5f..d74bc7fe 100644 --- a/src/opengeodeweb_viewer/rpc/viewer/viewer_protocols.py +++ b/src/opengeodeweb_viewer/rpc/viewer/viewer_protocols.py @@ -30,6 +30,7 @@ validate_schema, RpcParams, ) +from opengeodeweb_viewer.vtk_pipeline import RulerPipeline from opengeodeweb_viewer.vtk_protocol import VtkView from opengeodeweb_viewer.rpc.viewer import schemas @@ -98,6 +99,9 @@ def resetVisualization(self, rpc_params: RpcParams) -> None: self.set_axes(axes) self.set_widget(widget) + ruler = RulerPipeline() + ruler.add_to_renderer(renderer) + self.set_ruler(ruler) renderer.SetBackground([180 / 255, 180 / 255, 180 / 255]) @@ -319,6 +323,9 @@ def updateCamera(self, rpc_params: RpcParams) -> None: camera.SetPosition(camera_options.position) camera.SetViewAngle(camera_options.view_angle) camera.SetClippingRange(camera_options.clipping_range) + ruler = self.get_ruler() + if ruler is not None: + ruler.update_scale(self.get_renderer()) @exportRpc(viewer_prefix + viewer_schemas_dict["render"]["rpc"]) def renderNow(self, rpc_params: RpcParams) -> None: @@ -465,3 +472,29 @@ def previewPoints(self, rpc_params: RpcParams) -> None: self._preview_polydata.SetPolys(polys) self._preview_polydata.Modified() self.render(-1) + + @exportRpc(viewer_prefix + viewer_schemas_dict["ruler"]["rpc"]) + def setRuler(self, rpc_params: RpcParams) -> dict[str, float | list[float] | None]: + validate_schema( + rpc_params, self.viewer_schemas_dict["ruler"], self.viewer_prefix + ) + params = schemas.Ruler.from_dict(rpc_params) + ruler = self.get_ruler() + assert ruler is not None + point1 = params.points[0] + point2 = params.points[1] if len(params.points) > 1 else None + distance = ruler.set_endpoints(point1, point2, renderer=self.get_renderer()) + return { + "distance": distance, + "point1": point1, + "point2": point2, + } + + @exportRpc(viewer_prefix + viewer_schemas_dict["reset_ruler"]["rpc"]) + def resetRuler(self, rpc_params: RpcParams) -> None: + validate_schema( + rpc_params, self.viewer_schemas_dict["reset_ruler"], self.viewer_prefix + ) + ruler = self.get_ruler() + assert ruler is not None + ruler.reset() diff --git a/src/opengeodeweb_viewer/vtk_pipeline.py b/src/opengeodeweb_viewer/vtk_pipeline.py index af9a4a23..5fad5e1f 100644 --- a/src/opengeodeweb_viewer/vtk_pipeline.py +++ b/src/opengeodeweb_viewer/vtk_pipeline.py @@ -1,19 +1,25 @@ # Standard library imports from dataclasses import dataclass, field +import math from typing import cast, Literal, TypedDict # Third party imports from vtkmodules.vtkRenderingCore import ( vtkActor, vtkDataSetMapper, + vtkFollower, vtkMapper, + vtkPolyDataMapper, vtkCompositePolyDataMapper, vtkCompositeDataDisplayAttributes, vtkColorTransferFunction, + vtkRenderer, ) from vtkmodules.vtkRenderingAnnotation import ( vtkScalarBarActor, ) +from vtkmodules.vtkRenderingFreeType import vtkVectorText +from vtkmodules.vtkFiltersSources import vtkLineSource, vtkSphereSource from vtkmodules.vtkCommonDataModel import ( vtkDataObject, vtkDataSet, @@ -55,6 +61,133 @@ class HighlightPipeline: extractSelection: vtkExtractSelection = field(default_factory=vtkExtractSelection) +@dataclass +class RulerPipeline: + _SPHERE_RESOLUTION = 32 + _PRIMARY_COLOR = (60 / 255, 153 / 255, 131 / 255) + _TEXT_COLOR = (0.05, 0.05, 0.05) + + _point1: list[float] | None = field(default=None, init=False) + _point2: list[float] | None = field(default=None, init=False) + _line_source: vtkLineSource = field(default_factory=vtkLineSource) + line_actor: vtkActor = field(init=False) + _point1_source: vtkSphereSource = field(init=False) + point1_actor: vtkActor = field(init=False) + _point2_source: vtkSphereSource = field(init=False) + point2_actor: vtkActor = field(init=False) + _text_source: vtkVectorText = field(default_factory=vtkVectorText) + text_follower: vtkFollower = field(init=False) + + def __post_init__(self) -> None: + self.line_actor = vtkActor() + self._setup_actor( + self.line_actor, self._line_source, self._PRIMARY_COLOR, line_width=3.0 + ) + self._point1_source, self.point1_actor = self._make_sphere() + self._point2_source, self.point2_actor = self._make_sphere() + self.text_follower = vtkFollower() + self._setup_actor( + self.text_follower, self._text_source, self._TEXT_COLOR, offset=-50000.0 + ) + + def add_to_renderer(self, renderer: vtkRenderer) -> None: + self.text_follower.SetCamera(renderer.GetActiveCamera()) + for actor in ( + self.line_actor, + self.point1_actor, + self.point2_actor, + self.text_follower, + ): + actor.VisibilityOff() + renderer.AddActor(actor) + + def _setup_actor( + self, + actor: vtkActor, + source: vtkAlgorithm, + color: tuple[float, float, float], + line_width: float | None = None, + offset: float = -10000.0, + ) -> None: + mapper = vtkPolyDataMapper() + mapper.SetInputConnection(source.GetOutputPort()) + mapper.SetRelativeCoincidentTopologyPolygonOffsetParameters(offset, offset) + mapper.SetRelativeCoincidentTopologyLineOffsetParameters(offset, offset) + mapper.SetRelativeCoincidentTopologyPointOffsetParameter(offset) + actor.SetMapper(mapper) + actor.SetPickable(False) + actor_property = actor.GetProperty() + actor_property.SetColor(*color) + actor_property.SetAmbient(1.0) + actor_property.SetDiffuse(0.0) + if line_width is not None: + actor_property.SetLineWidth(line_width) + + def _make_sphere(self) -> tuple[vtkSphereSource, vtkActor]: + source = vtkSphereSource() + source.SetPhiResolution(self._SPHERE_RESOLUTION) + source.SetThetaResolution(self._SPHERE_RESOLUTION) + actor = vtkActor() + self._setup_actor(actor, source, self._PRIMARY_COLOR) + return source, actor + + def update_scale(self, renderer: vtkRenderer | None = None) -> None: + if self._point1 is None or renderer is None: + return + camera_position = renderer.GetActiveCamera().GetPosition() + self._point1_source.SetRadius( + max(math.dist(self._point1, camera_position) * 0.003, 0.0001) + ) + if self._point2 is None: + return + self._point2_source.SetRadius( + max(math.dist(self._point2, camera_position) * 0.003, 0.0001) + ) + midpoint = tuple( + (coord1 + coord2) / 2 for coord1, coord2 in zip(self._point1, self._point2) + ) + text_scale = max(math.dist(midpoint, camera_position) * 0.008, 0.001) + self.text_follower.SetPosition( + midpoint[0], midpoint[1] + text_scale * 1.2, midpoint[2] + ) + self.text_follower.SetScale(text_scale, text_scale, text_scale) + + def reset(self) -> None: + self._point1 = None + self._point2 = None + self.point1_actor.VisibilityOff() + self.point2_actor.VisibilityOff() + self.line_actor.VisibilityOff() + self.text_follower.VisibilityOff() + + def set_endpoints( + self, + point1: list[float], + point2: list[float] | None, + renderer: vtkRenderer | None = None, + ) -> float: + self._point1 = point1 + self._point2 = point2 + self.point1_actor.VisibilityOn() + self._point1_source.SetCenter(*point1) + if point2 is None: + self.point2_actor.VisibilityOff() + self.line_actor.VisibilityOff() + self.text_follower.VisibilityOff() + self.update_scale(renderer) + return 0.0 + self.point2_actor.VisibilityOn() + self.line_actor.VisibilityOn() + self.text_follower.VisibilityOn() + self._line_source.SetPoint1(*point1) + self._line_source.SetPoint2(*point2) + self._point2_source.SetCenter(*point2) + distance = math.dist(point1, point2) + self._text_source.SetText(f"{distance:.2f}") + self.update_scale(renderer) + return distance + + class BlockStyle(TypedDict): name: str attribute_location: Literal["point", "cell"] diff --git a/src/opengeodeweb_viewer/vtk_protocol.py b/src/opengeodeweb_viewer/vtk_protocol.py index 183d30b5..af4e142a 100644 --- a/src/opengeodeweb_viewer/vtk_protocol.py +++ b/src/opengeodeweb_viewer/vtk_protocol.py @@ -38,6 +38,7 @@ from opengeodeweb_microservice.database.data import Data from opengeodeweb_viewer.rpc.viewer.schemas.clipping_planes import Plane from opengeodeweb_viewer.vtk_pipeline import ( + RulerPipeline, ViewerData, VtkPipeline, ) @@ -83,6 +84,12 @@ def get_widget(self) -> vtkOrientationMarkerWidget | None: def set_widget(self, widget: vtkOrientationMarkerWidget) -> None: self.coreServer.setSharedObject("widget", widget) + def get_ruler(self) -> RulerPipeline | None: + return cast(RulerPipeline | None, self.getSharedObject("ruler")) + + def set_ruler(self, ruler: RulerPipeline) -> None: + self.coreServer.setSharedObject("ruler", ruler) + def get_viewer_data(self, data_id: str) -> ViewerData: if Data is None: raise Exception("Data model not available") @@ -309,7 +316,7 @@ def extract_picked_attributes( field_type: str, dataset: vtkDataObject | None, ) -> dict[str, list[float] | float]: - data_object = dataset or pipeline.reader.GetOutputDataObject(0) + data_object = dataset or pipeline.mapper.GetInputDataObject(0, 0) if not isinstance(data_object, vtkDataSet): return {} field_data = ( diff --git a/tests/data/images/viewer/ruler.jpeg b/tests/data/images/viewer/ruler.jpeg new file mode 100644 index 00000000..b922a2cf Binary files /dev/null and b/tests/data/images/viewer/ruler.jpeg differ diff --git a/tests/test_viewer_protocols.py b/tests/test_viewer_protocols.py index f99f2d23..fcf2fedc 100644 --- a/tests/test_viewer_protocols.py +++ b/tests/test_viewer_protocols.py @@ -464,3 +464,19 @@ def test_shrink_then_clipping( ], ) assert server.compare_image("viewer/clipping_then_shrink.jpeg") == True + + +def test_ruler(server: ServerMonitor, dataset_factory: Callable[..., str]) -> None: + server.call( + VtkViewerView.viewer_prefix + + VtkViewerView.viewer_schemas_dict["reset_visualization"]["rpc"] + ) + server.call( + VtkViewerView.viewer_prefix + VtkViewerView.viewer_schemas_dict["ruler"]["rpc"], + [ + { + "points": [[-0.2, -0.2, 0.0], [0.2, 0.2, 0.0]], + } + ], + ) + assert server.compare_image("viewer/ruler.jpeg") == True