diff --git a/.releaserc b/.releaserc index bf47fdf5..30a901b9 100644 --- a/.releaserc +++ b/.releaserc @@ -6,6 +6,6 @@ plugins: [ '@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', - '@semantic-release/github' + ['@semantic-release/github', { successCommentCondition: false, failCommentCondition: false }] ] } diff --git a/opengeodeweb-back.spec b/opengeodeweb-back.spec index 45f7236d..1fd3e0f4 100644 --- a/opengeodeweb-back.spec +++ b/opengeodeweb-back.spec @@ -5,6 +5,8 @@ from PyInstaller.utils.hooks import copy_metadata datas = [] datas += collect_data_files('opengeodeweb_back') +# TEMPORARY until OpenGeode ships a PyInstaller hook: PROJ database (proj.db) for the CRS +datas += collect_data_files('opengeode', subdir='share') datas += copy_metadata('opengeodeweb-back', recursive=True) diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index 6f0d757d..b464a7d4 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -181,6 +181,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -362,6 +382,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -557,6 +597,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -572,6 +632,342 @@ } } }, + "crs": { + "geographic_list": { + "$id": "opengeodeweb_back/crs/geographic_list", + "route": "/geographic_list", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "geode_object_type": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "geode_object_type" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "crs_list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "code": { + "type": "string" + }, + "authority": { + "type": "string" + } + }, + "required": [ + "name", + "code", + "authority" + ], + "additionalProperties": false + } + } + }, + "required": [ + "crs_list" + ], + "additionalProperties": false + } + }, + "convert_geographic": { + "$id": "opengeodeweb_back/crs/convert_geographic", + "route": "/convert_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "assign_geographic": { + "$id": "opengeodeweb_back/crs/assign_geographic", + "route": "/assign_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "align": { + "$id": "opengeodeweb_back/crs/align", + "route": "/align", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "source_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "target_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "source_points", + "target_points", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "active": { + "$id": "opengeodeweb_back/crs/active", + "route": "/active", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "crs_name": { + "type": "string" + }, + "crs_info": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "crs_name" + ], + "additionalProperties": false + } + } + }, "vertex_attribute_range": { "$id": "opengeodeweb_back/vertex_attribute_range", "route": "/vertex_attribute_range", @@ -1008,6 +1404,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -1819,56 +2235,6 @@ "additionalProperties": false } }, - "geographic_coordinate_systems": { - "$id": "opengeodeweb_back/geographic_coordinate_systems", - "route": "/geographic_coordinate_systems", - "methods": [ - "POST" - ], - "type": "object", - "properties": { - "geode_object_type": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "geode_object_type" - ], - "additionalProperties": false, - "response": { - "type": "object", - "properties": { - "crs_list": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "code": { - "type": "string" - }, - "authority": { - "type": "string" - } - }, - "required": [ - "name", - "code", - "authority" - ], - "additionalProperties": false - } - } - }, - "required": [ - "crs_list" - ], - "additionalProperties": false - } - }, "geode_objects_and_output_extensions": { "$id": "opengeodeweb_back/geode_objects_and_output_extensions", "route": "/geode_objects_and_output_extensions", diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index 74900e53..7c8bb273 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -16,6 +16,7 @@ export interface PolygonalSurfacePoint { export interface CreatePolygonalSurfaceResponse { binary_light_viewable?: string; collection_components?: CreatePolygonalSurfaceResponseCollectionComponent[]; + crs?: CreatePolygonalSurfaceResponseCRS; geode_id: string; geode_object_type: string; id: string; @@ -36,6 +37,12 @@ export interface CreatePolygonalSurfaceResponseCollectionComponent { type: string; } +export interface CreatePolygonalSurfaceResponseCRS { + authority: string; + code: string; + name: string; +} + export interface CreatePolygonalSurfaceResponseMeshComponent { boundaries: string[]; geode_id: string; @@ -60,6 +67,7 @@ export interface Point { export interface CreatePointSetResponse { binary_light_viewable?: string; collection_components?: CreatePointSetResponseCollectionComponent[]; + crs?: CreatePointSetResponseCRS; geode_id: string; geode_object_type: string; id: string; @@ -80,6 +88,12 @@ export interface CreatePointSetResponseCollectionComponent { type: string; } +export interface CreatePointSetResponseCRS { + authority: string; + code: string; + name: string; +} + export interface CreatePointSetResponseMeshComponent { boundaries: string[]; geode_id: string; @@ -105,6 +119,7 @@ export interface EdgedCurvePoint { export interface CreateEdgedCurveResponse { binary_light_viewable?: string; collection_components?: CreateEdgedCurveResponseCollectionComponent[]; + crs?: CreateEdgedCurveResponseCRS; geode_id: string; geode_object_type: string; id: string; @@ -125,6 +140,12 @@ export interface CreateEdgedCurveResponseCollectionComponent { type: string; } +export interface CreateEdgedCurveResponseCRS { + authority: string; + code: string; + name: string; +} + export interface CreateEdgedCurveResponseMeshComponent { boundaries: string[]; geode_id: string; @@ -135,6 +156,100 @@ export interface CreateEdgedCurveResponseMeshComponent { viewer_id: number; } +export interface CRSGeographicListParams { + geode_object_type: string; +} + +export interface CRSGeographicListResponse { + crs_list: CRSList[]; +} + +export interface CRSList { + authority: string; + code: string; + name: string; +} + +export interface CRSConvertGeographicParams { + authority: string; + code: string; + id: string; + name: string; +} + +export interface CRSConvertGeographicResponse { + binary_light_viewable?: string; + crs?: CRSConvertGeographicResponseCRS; +} + +export interface CRSConvertGeographicResponseCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSAssignGeographicParams { + authority: string; + code: string; + id: string; + name: string; +} + +export interface CRSAssignGeographicResponse { + crs?: CRSAssignGeographicResponseCRS; +} + +export interface CRSAssignGeographicResponseCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSAlignParams { + authority: string; + code: string; + id: string; + name: string; + source_points: SourcePoint[]; + target_points: TargetPoint[]; +} + +export interface SourcePoint { + x: number; + y: number; +} + +export interface TargetPoint { + x: number; + y: number; +} + +export interface CRSAlignResponse { + binary_light_viewable?: string; + crs?: CRSAlignResponseCRS; +} + +export interface CRSAlignResponseCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSActiveParams { + id: string; +} + +export interface CRSActiveResponse { + crs_info?: CRSInfo; + crs_name: string; +} + +export interface CRSInfo { + authority: string; + code: string; + name: string; +} + export interface VertexAttributeRangeParams { attribute_name: string; id: string; @@ -213,6 +328,7 @@ export interface SaveViewableFileParams { export interface SaveViewableFileResponse { binary_light_viewable?: string; collection_components?: SaveViewableFileResponseCollectionComponent[]; + crs?: SaveViewableFileResponseCRS; geode_id: string; geode_object_type: string; id: string; @@ -233,6 +349,12 @@ export interface SaveViewableFileResponseCollectionComponent { type: string; } +export interface SaveViewableFileResponseCRS { + authority: string; + code: string; + name: string; +} + export interface SaveViewableFileResponseMeshComponent { boundaries: string[]; geode_id: string; @@ -428,20 +550,6 @@ export interface ImportProjectResponse { snapshot: { [key: string]: any }; } -export interface GeographicCoordinateSystemsParams { - geode_object_type: string; -} - -export interface GeographicCoordinateSystemsResponse { - crs_list: CRSList[]; -} - -export interface CRSList { - authority: string; - code: string; - name: string; -} - export interface GeodeObjectsAndOutputExtensionsParams { filename: string; geode_object_type: string; @@ -577,6 +685,13 @@ export interface Schemas { readonly point_set: (typeof json)["opengeodeweb_back"]["create"]["point_set"] & TypedSchema; readonly edged_curve: (typeof json)["opengeodeweb_back"]["create"]["edged_curve"] & TypedSchema; }; + readonly crs: { + readonly geographic_list: (typeof json)["opengeodeweb_back"]["crs"]["geographic_list"] & TypedSchema; + readonly convert_geographic: (typeof json)["opengeodeweb_back"]["crs"]["convert_geographic"] & TypedSchema; + readonly assign_geographic: (typeof json)["opengeodeweb_back"]["crs"]["assign_geographic"] & TypedSchema; + readonly align: (typeof json)["opengeodeweb_back"]["crs"]["align"] & TypedSchema; + readonly active: (typeof json)["opengeodeweb_back"]["crs"]["active"] & TypedSchema; + }; readonly vertex_attribute_range: (typeof json)["opengeodeweb_back"]["vertex_attribute_range"] & TypedSchema; readonly vertex_attribute_names: (typeof json)["opengeodeweb_back"]["vertex_attribute_names"] & TypedSchema; readonly validate: (typeof json)["opengeodeweb_back"]["validate"] & TypedSchema; @@ -600,7 +715,6 @@ export interface Schemas { readonly model_component_edge_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_edge_attribute_names"] & TypedSchema; readonly kill: (typeof json)["opengeodeweb_back"]["kill"] & TypedSchema; readonly import_project: (typeof json)["opengeodeweb_back"]["import_project"] & TypedSchema; - readonly geographic_coordinate_systems: (typeof json)["opengeodeweb_back"]["geographic_coordinate_systems"] & TypedSchema; readonly geode_objects_and_output_extensions: (typeof json)["opengeodeweb_back"]["geode_objects_and_output_extensions"] & TypedSchema; readonly geode_object_inheritance: (typeof json)["opengeodeweb_back"]["geode_object_inheritance"] & TypedSchema; readonly export_project: (typeof json)["opengeodeweb_back"]["export_project"] & TypedSchema; diff --git a/pyproject.toml b/pyproject.toml index ecc27d62..bd314c5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,4 +33,5 @@ where = ["src"] "opengeodeweb_back.routes.schemas" = ["*.json"] "opengeodeweb_back.routes.models.schemas" = ["*.json"] "opengeodeweb_back.routes.create.schemas" = ["*.json"] +"opengeodeweb_back.routes.crs.schemas" = ["*.json"] "opengeodeweb_back" = ["py.typed"] diff --git a/requirements.txt b/requirements.txt index fa4f895c..88997f0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,4 +70,3 @@ werkzeug==3.1.8 # flask # flask-cors -opengeodeweb-microservice==1.*,>=1.3.2 diff --git a/src/opengeodeweb_back/app.py b/src/opengeodeweb_back/app.py index 3ed639ef..3e9bb58f 100644 --- a/src/opengeodeweb_back/app.py +++ b/src/opengeodeweb_back/app.py @@ -20,6 +20,7 @@ from opengeodeweb_back import app_config, utils_functions from opengeodeweb_back.routes import blueprint_routes from opengeodeweb_back.routes.create import blueprint_create +from opengeodeweb_back.routes.crs import blueprint_crs if TYPE_CHECKING: from collections.abc import Generator @@ -171,6 +172,11 @@ def register_ogw_back_blueprints(app: flask.Flask) -> None: url_prefix="/opengeodeweb_back/create", name="opengeodeweb_create", ) + app.register_blueprint( + blueprint_crs.routes, + url_prefix="/opengeodeweb_back/crs", + name="opengeodeweb_crs", + ) def run_server(app: Flask) -> None: diff --git a/src/opengeodeweb_back/crs_functions.py b/src/opengeodeweb_back/crs_functions.py new file mode 100644 index 00000000..f9ad706d --- /dev/null +++ b/src/opengeodeweb_back/crs_functions.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +# Standard library imports +import functools +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING + +# Third party imports +import opengeode as og +import opengeode_geosciences as og_geosciences + +# Local application imports +from opengeodeweb_back.geode_objects.geode_brep import GeodeBRep +from opengeodeweb_back.geode_objects.geode_edged_curve2d import GeodeEdgedCurve2D +from opengeodeweb_back.geode_objects.geode_edged_curve3d import GeodeEdgedCurve3D +from opengeodeweb_back.geode_objects.geode_point_set2d import GeodePointSet2D +from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D +from opengeodeweb_back.geode_objects.geode_section import GeodeSection +from opengeodeweb_back.geode_objects.geode_solid_mesh3d import GeodeSolidMesh3D +from opengeodeweb_back.geode_objects.geode_surface_mesh2d import GeodeSurfaceMesh2D +from opengeodeweb_back.geode_objects.geode_surface_mesh3d import GeodeSurfaceMesh3D + +if TYPE_CHECKING: + from opengeodeweb_back.geode_objects.geode_object import GeodeObject + +logger = logging.getLogger(__name__) + +# TEMPORARY workaround, to remove once OpenGeode sets it itself (and ships a PyInstaller hook): +# PROJ looks for its database next to the loaded libproj, which packaged apps (PyInstaller) +# Load from elsewhere, so point it to the one opengeode ships +os.environ.setdefault("PROJ_DATA", str(Path(og.__file__).parent / "share" / "proj")) + +CRS_GEODE_OBJECTS = ( + GeodeBRep, + GeodeSection, + GeodePointSet2D, + GeodePointSet3D, + GeodeEdgedCurve2D, + GeodeEdgedCurve3D, + GeodeSurfaceMesh2D, + GeodeSurfaceMesh3D, + GeodeSolidMesh3D, +) + +CrsGeodeObject = ( + GeodeBRep + | GeodeSection + | GeodePointSet2D + | GeodePointSet3D + | GeodeEdgedCurve2D + | GeodeEdgedCurve3D + | GeodeSurfaceMesh2D + | GeodeSurfaceMesh3D + | GeodeSolidMesh3D +) + + +@functools.cache +def geographic_coordinate_systems( + is_3d: bool, # noqa: FBT001 positional so the cache key stays simple +) -> dict[str, og_geosciences.GeographicCoordinateSystemInfo]: + """Known geographic CRS by "AUTHORITY:CODE", built once: the list has thousands of entries.""" + infos = ( + og_geosciences.GeographicCoordinateSystem3D.geographic_coordinate_systems() + if is_3d + else og_geosciences.GeographicCoordinateSystem2D.geographic_coordinate_systems() + ) + return {info.authority_code(): info for info in infos} + + +def known_geographic_info( + geode_object: CrsGeodeObject, authority: str, code: str +) -> og_geosciences.GeographicCoordinateSystemInfo | None: + """The listed CRS for authority:code, None when PROJ does not know it.""" + return geographic_coordinate_systems(geode_object.is_3d()).get(f"{authority}:{code}") + + +def geographic_info( + crs_manager: og.CoordinateReferenceSystemManager2D | og.CoordinateReferenceSystemManager3D, +) -> og_geosciences.GeographicCoordinateSystemInfo | None: + crs = crs_manager.active_coordinate_reference_system() + if isinstance( + crs, + og_geosciences.GeographicCoordinateSystem2D | og_geosciences.GeographicCoordinateSystem3D, + ): + return crs.info() + return None + + +def active_crs(geode_object: GeodeObject) -> dict[str, str] | None: + """{authority, code, name} of the active geographic CRS, None otherwise.""" + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + return None + try: + info = geographic_info(geode_object.crs_manager()) + except (ValueError, RuntimeError) as error: + # The CRS is extra information: a model without component mesh, or a CRS the + # geosciences library cannot read, must not fail the whole data response + logger.warning("Cannot read the active CRS: %s", error) + return None + if info is None: + return None + return {"authority": info.authority, "code": info.code, "name": info.name} diff --git a/src/opengeodeweb_back/geode_objects/crs_coordinates.py b/src/opengeodeweb_back/geode_objects/crs_coordinates.py new file mode 100644 index 00000000..0a4de673 --- /dev/null +++ b/src/opengeodeweb_back/geode_objects/crs_coordinates.py @@ -0,0 +1,45 @@ +# Standard library imports +from __future__ import annotations + +# Third party imports +import opengeode as og +import opengeode_geosciences as og_geosciences + +Mesh = ( + og.PointSet2D + | og.PointSet3D + | og.EdgedCurve2D + | og.EdgedCurve3D + | og.SurfaceMesh2D + | og.SurfaceMesh3D + | og.SolidMesh3D +) +CrsBuilder = ( + og.CoordinateReferenceSystemManagerBuilder2D | og.CoordinateReferenceSystemManagerBuilder3D +) + + +def _coordinates_name( + crs: og.CoordinateReferenceSystem2D | og.CoordinateReferenceSystem3D, +) -> str | None: + # A geographic CRS stores its coordinates in a vertex attribute named after the CRS + if isinstance( + crs, + og_geosciences.GeographicCoordinateSystem2D | og_geosciences.GeographicCoordinateSystem3D, + ): + return crs.info().name + return None + + +def delete_crs_with_coordinates(mesh: Mesh, crs_builder: CrsBuilder, crs_name: str) -> None: + """Delete an inactive CRS and its coordinates: each conversion stores new ones.""" + crs_manager = mesh.main_coordinate_reference_system_manager() + coordinates_name = _coordinates_name(crs_manager.find_coordinate_reference_system(crs_name)) + active_name = _coordinates_name(crs_manager.active_coordinate_reference_system()) + crs_builder.delete_coordinate_reference_system(crs_name) + # Attributes are only found by name: keep them when the active CRS shares it + if coordinates_name is None or coordinates_name == active_name: + return + attribute_manager = mesh.vertex_attribute_manager() + for attribute_id in attribute_manager.attribute_ids_matching_name(coordinates_name) or []: + attribute_manager.delete_attribute(attribute_id) diff --git a/src/opengeodeweb_back/geode_objects/geode_brep.py b/src/opengeodeweb_back/geode_objects/geode_brep.py index 21d5e172..db51e786 100644 --- a/src/opengeodeweb_back/geode_objects/geode_brep.py +++ b/src/opengeodeweb_back/geode_objects/geode_brep.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_model import ComponentRegistry, GeodeModel if TYPE_CHECKING: @@ -136,6 +137,43 @@ def inspect(self) -> og_inspector.BRepInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_brep_valid(self.brep) + def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: + # Models have no CRS manager of their own: CRS are assigned to every component mesh + for components in ( + self.brep.corners(), + self.brep.lines(), + self.brep.surfaces(), + self.brep.blocks(), + ): + for component in components: + return component.mesh().main_coordinate_reference_system_manager() + msg = "BRep has no mesh component" + raise ValueError(msg) + + def delete_crs(self, crs_name: str) -> None: + # Models have no CRS manager of their own: the CRS lives on every component mesh + builder = self.builder() + for corner in self.brep.corners(): + crs_builder = builder.corner_mesh_builder( + corner + ).main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(corner.mesh(), crs_builder, crs_name) + for line in self.brep.lines(): + crs_builder = builder.line_mesh_builder( + line + ).main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(line.mesh(), crs_builder, crs_name) + for surface in self.brep.surfaces(): + crs_builder = builder.surface_mesh_builder( + surface + ).main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(surface.mesh(), crs_builder, crs_name) + for block in self.brep.blocks(): + crs_builder = builder.block_mesh_builder( + block + ).main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(block.mesh(), crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py index 6a6cd622..8ecb1af1 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_graph import GeodeGraph if TYPE_CHECKING: @@ -103,6 +104,13 @@ def inspect(self) -> og_inspector.EdgedCurveInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_edged_curve_valid2D(self.edged_curve) + def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: + return self.edged_curve.main_coordinate_reference_system_manager() + + def delete_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(self.edged_curve, crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py index 24ee183d..9e929ef4 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_graph import GeodeGraph if TYPE_CHECKING: @@ -103,6 +104,13 @@ def inspect(self) -> og_inspector.EdgedCurveInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_edged_curve_valid3D(self.edged_curve) + def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: + return self.edged_curve.main_coordinate_reference_system_manager() + + def delete_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(self.edged_curve, crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py index 7a423348..3f32d80c 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -101,6 +102,13 @@ def inspect(self) -> og_inspector.PointSetInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_pointset_valid2D(self.point_set) + def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: + return self.point_set.main_coordinate_reference_system_manager() + + def delete_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(self.point_set, crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py index fbfe030d..004261e0 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -101,6 +102,13 @@ def inspect(self) -> og_inspector.PointSetInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_pointset_valid3D(self.point_set) + def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: + return self.point_set.main_coordinate_reference_system_manager() + + def delete_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(self.point_set, crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_section.py b/src/opengeodeweb_back/geode_objects/geode_section.py index c6b7a403..c684030b 100644 --- a/src/opengeodeweb_back/geode_objects/geode_section.py +++ b/src/opengeodeweb_back/geode_objects/geode_section.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_model import ComponentRegistry, GeodeModel if TYPE_CHECKING: @@ -125,6 +126,37 @@ def inspect(self) -> og_inspector.SectionInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_section_valid(self.section) + def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: + # Models have no CRS manager of their own: CRS are assigned to every component mesh + for components in ( + self.section.corners(), + self.section.lines(), + self.section.surfaces(), + ): + for component in components: + return component.mesh().main_coordinate_reference_system_manager() + msg = "Section has no mesh component" + raise ValueError(msg) + + def delete_crs(self, crs_name: str) -> None: + # Models have no CRS manager of their own: the CRS lives on every component mesh + builder = self.builder() + for corner in self.section.corners(): + crs_builder = builder.corner_mesh_builder( + corner + ).main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(corner.mesh(), crs_builder, crs_name) + for line in self.section.lines(): + crs_builder = builder.line_mesh_builder( + line + ).main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(line.mesh(), crs_builder, crs_name) + for surface in self.section.surfaces(): + crs_builder = builder.surface_mesh_builder( + surface + ).main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(surface.mesh(), crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py index 35ab866f..d0bcba01 100644 --- a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py @@ -9,6 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -49,6 +50,13 @@ def inspect(self) -> og_inspector.SolidInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_solid_valid3D(self.solid_mesh) + def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: + return self.solid_mesh.main_coordinate_reference_system_manager() + + def delete_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(self.solid_mesh, crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py index ceea9ed1..5c41d103 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py @@ -9,6 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -49,6 +50,13 @@ def inspect(self) -> og_inspector.SurfaceInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_surface_valid2D(self.surface_mesh) + def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: + return self.surface_mesh.main_coordinate_reference_system_manager() + + def delete_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(self.surface_mesh, crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py index 0679f494..95711b51 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py @@ -9,6 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -49,6 +50,13 @@ def inspect(self) -> og_inspector.SurfaceInspectionResult: def validate(self) -> og_inspector.ObjectValidity: return og_inspector.is_surface_valid3D(self.surface_mesh) + def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: + return self.surface_mesh.main_coordinate_reference_system_manager() + + def delete_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + delete_crs_with_coordinates(self.surface_mesh, crs_builder, crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/routes/blueprint_routes.py b/src/opengeodeweb_back/routes/blueprint_routes.py index 99346430..76f4f0af 100644 --- a/src/opengeodeweb_back/routes/blueprint_routes.py +++ b/src/opengeodeweb_back/routes/blueprint_routes.py @@ -15,7 +15,6 @@ # Third party imports import flask import opengeode as og -import opengeode_geosciences as og_geosciences import opengeode_geosciencesio as og_geosciencesio # noqa: F401 (registers IO plugins) import opengeode_io as og_io # noqa: F401 (registers IO plugins) import werkzeug @@ -233,25 +232,6 @@ def time_series_missing_files( ) -@typed_route(routes, schemas.geographic_coordinate_systems_route) -def crs_converter_geographic_coordinate_systems( - params: schemas.GeographicCoordinateSystems, -) -> schemas.GeographicCoordinateSystemsResponse: - geode_object = geode_functions.geode_object_from_string(params.geode_object_type) - infos = ( - og_geosciences.GeographicCoordinateSystem3D.geographic_coordinate_systems() - if geode_object.is_3d() - else og_geosciences.GeographicCoordinateSystem2D.geographic_coordinate_systems() - ) - crs_list = [ - schemas.geographic_coordinate_systems.CRSList( - name=info.name, code=info.code, authority=info.authority - ) - for info in infos - ] - return schemas.GeographicCoordinateSystemsResponse(crs_list=crs_list) - - @typed_route(routes, schemas.validate_route) def validate_object(params: schemas.Validate) -> schemas.ValidateResponse: geode_object = geode_functions.load_geode_object(params.id) diff --git a/src/opengeodeweb_back/routes/create/schemas/edged_curve.json b/src/opengeodeweb_back/routes/create/schemas/edged_curve.json index 6e050553..3e4e4853 100644 --- a/src/opengeodeweb_back/routes/create/schemas/edged_curve.json +++ b/src/opengeodeweb_back/routes/create/schemas/edged_curve.json @@ -178,6 +178,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -191,4 +211,4 @@ ], "additionalProperties": false } -} \ No newline at end of file +} diff --git a/src/opengeodeweb_back/routes/create/schemas/edged_curve.py b/src/opengeodeweb_back/routes/create/schemas/edged_curve.py index 5376ba91..3716d448 100644 --- a/src/opengeodeweb_back/routes/create/schemas/edged_curve.py +++ b/src/opengeodeweb_back/routes/create/schemas/edged_curve.py @@ -39,6 +39,16 @@ def __post_init__(self) -> None: type: str +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + @dataclass class MeshComponent(DataClassJsonMixin): def __post_init__(self) -> None: @@ -67,6 +77,7 @@ def __post_init__(self) -> None: viewer_type: str binary_light_viewable: Optional[str] = None collection_components: Optional[List[CollectionComponent]] = None + crs: Optional[CRS] = None mesh_components: Optional[List[MeshComponent]] = None nb_vertices: Optional[int] = None viewable_file: Optional[str] = None diff --git a/src/opengeodeweb_back/routes/create/schemas/point_set.json b/src/opengeodeweb_back/routes/create/schemas/point_set.json index 04b66ba5..b339b2ad 100644 --- a/src/opengeodeweb_back/routes/create/schemas/point_set.json +++ b/src/opengeodeweb_back/routes/create/schemas/point_set.json @@ -164,6 +164,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -177,4 +197,4 @@ ], "additionalProperties": false } -} \ No newline at end of file +} diff --git a/src/opengeodeweb_back/routes/create/schemas/point_set.py b/src/opengeodeweb_back/routes/create/schemas/point_set.py index 1b18fa75..644cfb14 100644 --- a/src/opengeodeweb_back/routes/create/schemas/point_set.py +++ b/src/opengeodeweb_back/routes/create/schemas/point_set.py @@ -38,6 +38,16 @@ def __post_init__(self) -> None: type: str +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + @dataclass class MeshComponent(DataClassJsonMixin): def __post_init__(self) -> None: @@ -66,6 +76,7 @@ def __post_init__(self) -> None: viewer_type: str binary_light_viewable: Optional[str] = None collection_components: Optional[List[CollectionComponent]] = None + crs: Optional[CRS] = None mesh_components: Optional[List[MeshComponent]] = None nb_vertices: Optional[int] = None viewable_file: Optional[str] = None diff --git a/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.json b/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.json index 187cf17e..b91103af 100644 --- a/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.json +++ b/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.json @@ -177,6 +177,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -190,4 +210,4 @@ ], "additionalProperties": false } -} \ No newline at end of file +} diff --git a/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.py b/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.py index 937ba263..ffeb97b4 100644 --- a/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.py +++ b/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.py @@ -39,6 +39,16 @@ def __post_init__(self) -> None: type: str +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + @dataclass class MeshComponent(DataClassJsonMixin): def __post_init__(self) -> None: @@ -67,6 +77,7 @@ def __post_init__(self) -> None: viewer_type: str binary_light_viewable: Optional[str] = None collection_components: Optional[List[CollectionComponent]] = None + crs: Optional[CRS] = None mesh_components: Optional[List[MeshComponent]] = None nb_vertices: Optional[int] = None viewable_file: Optional[str] = None diff --git a/src/opengeodeweb_back/routes/crs/__init__.py b/src/opengeodeweb_back/routes/crs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/opengeodeweb_back/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py new file mode 100644 index 00000000..895618e1 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +# Standard library imports +from typing import TYPE_CHECKING, Any + +# Third party imports +import flask +import opengeode as og + +# Local application imports +from opengeodeweb_back import geode_functions, utils_functions +from opengeodeweb_back.crs_functions import ( + CRS_GEODE_OBJECTS, + CrsGeodeObject, + active_crs, + geographic_coordinate_systems, + geographic_info, + known_geographic_info, +) +from opengeodeweb_back.routes.crs import schemas +from opengeodeweb_back.typed_route import typed_route + +if TYPE_CHECKING: + import opengeode_geosciences as og_geosciences + +routes = flask.Blueprint("crs", __name__, url_prefix="/crs") + + +def _prepare_crs_name( + geode_object: CrsGeodeObject, + crs_name: str, +) -> None: + crs_manager = geode_object.crs_manager() + info = geographic_info(crs_manager) + if info is not None and info.authority_code() == crs_name: + flask.abort(400, f"{crs_name} is already the active CRS") + # A registered inactive CRS may hold coordinates derived from an outdated label: recompute it + if crs_manager.coordinate_reference_system_exists(crs_name): + geode_object.delete_crs(crs_name) + + +def _known_info( + geode_object: CrsGeodeObject, authority: str, code: str +) -> og_geosciences.GeographicCoordinateSystemInfo: + # PROJ crashes the process on an unknown CRS: only accept listed ones, with their own name + info = known_geographic_info(geode_object, authority, code) + if info is None: + flask.abort(400, f"Unknown CRS {authority}:{code}") + return info + + +def _crs_response(saved: dict[str, Any]) -> dict[str, Any]: + # Only the fields the front refreshes after a CRS change + return {key: saved[key] for key in ("crs", "binary_light_viewable") if key in saved} + + +@typed_route(routes, schemas.geographic_list_route) +def geographic_list(params: schemas.GeographicList) -> schemas.GeographicListResponse: + geode_object = geode_functions.geode_object_from_string(params.geode_object_type) + crs_list = [ + schemas.geographic_list.CRSList(name=info.name, code=info.code, authority=info.authority) + for info in geographic_coordinate_systems(geode_object.is_3d()).values() + ] + return schemas.GeographicListResponse(crs_list=crs_list) + + +@typed_route(routes, schemas.active_route) +def active(params: schemas.Active) -> schemas.ActiveResponse: + geode_object = geode_functions.load_geode_object(params.id) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + flask.abort(400, f"{params.id} does not support coordinate reference systems") + crs_manager = geode_object.crs_manager() + info = geographic_info(crs_manager) + crs_info = ( + None + if info is None + else schemas.active.CRSInfo(authority=info.authority, code=info.code, name=info.name) + ) + return schemas.ActiveResponse( + crs_name=crs_manager.active_coordinate_reference_system_name(), + crs_info=crs_info, + ) + + +@typed_route(routes, schemas.assign_geographic_route) +def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeographicResponse: + data = geode_functions.get_data_info(params.id) + geode_object = geode_functions.geode_object_from_string(data.geode_object).load( + geode_functions.data_file_path(params.id, data.native_file) + ) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + flask.abort(400, f"{params.id} does not support coordinate reference systems") + info = _known_info(geode_object, params.authority, params.code) + _prepare_crs_name(geode_object, info.authority_code()) + geode_object.assign_crs(info.authority_code(), info) + # Assigning only relabels the coordinates: the viewables are unchanged + geode_object.save(geode_functions.data_file_path(params.id, data.native_file)) + return schemas.AssignGeographicResponse.from_dict({"crs": active_crs(geode_object)}) + + +@typed_route(routes, schemas.convert_geographic_route) +def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeographicResponse: + data = geode_functions.get_data_info(params.id) + geode_object = geode_functions.geode_object_from_string(data.geode_object).load( + geode_functions.data_file_path(params.id, data.native_file) + ) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + flask.abort(400, f"{params.id} does not support coordinate reference systems") + crs_manager = geode_object.crs_manager() + if geographic_info(crs_manager) is None: + flask.abort(400, "Assign a geographic CRS before converting") + info = _known_info(geode_object, params.authority, params.code) + _prepare_crs_name(geode_object, info.authority_code()) + try: + geode_object.convert_crs(info.authority_code(), info) + except RuntimeError: # PROJ rejects coordinates outside the source or target CRS domain + flask.abort(400, f"Coordinates cannot be converted to {info.authority_code()}") + saved = utils_functions.save_all_viewables_and_return_info( + geode_object, data, geode_functions.data_file_path(params.id) + ) + # The front rebuilds its local actor from the new light viewable + return schemas.ConvertGeographicResponse.from_dict(_crs_response(saved)) + + +def _coordinate_system( + points: list[schemas.SourcePoint] | list[schemas.TargetPoint], +) -> og.CoordinateSystem2D: + origin, first, second = (og.Point2D([point.x, point.y]) for point in points) + return og.CoordinateSystem2D(origin, [first, second]) + + +@typed_route(routes, schemas.align_route) +def align(params: schemas.Align) -> schemas.AlignResponse: + data = geode_functions.get_data_info(params.id) + geode_object = geode_functions.geode_object_from_string(data.geode_object).load( + geode_functions.data_file_path(params.id, data.native_file) + ) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + flask.abort(400, f"{params.id} does not support coordinate reference systems") + if geographic_info(geode_object.crs_manager()) is not None: + flask.abort(400, "Data already has a geographic CRS, use convert") + info = _known_info(geode_object, params.authority, params.code) + try: + source = _coordinate_system(params.source_points) + target = _coordinate_system(params.target_points) + except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points + flask.abort(400, "Points must not be colinear") + geode_object.create_crs("aligned", source, target) + geode_object.assign_crs(info.authority_code(), info) + saved = utils_functions.save_all_viewables_and_return_info( + geode_object, data, geode_functions.data_file_path(params.id) + ) + # The front rebuilds its local actor from the new light viewable + return schemas.AlignResponse.from_dict(_crs_response(saved)) diff --git a/src/opengeodeweb_back/routes/crs/schemas/__init__.py b/src/opengeodeweb_back/routes/crs/schemas/__init__.py new file mode 100644 index 00000000..d5a0bc5d --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/__init__.py @@ -0,0 +1,5 @@ +from .geographic_list import * +from .convert_geographic import * +from .assign_geographic import * +from .align import * +from .active import * diff --git a/src/opengeodeweb_back/routes/crs/schemas/active.json b/src/opengeodeweb_back/routes/crs/schemas/active.json new file mode 100644 index 00000000..45992366 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/active.json @@ -0,0 +1,50 @@ +{ + "route": "/active", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "crs_name": { + "type": "string" + }, + "crs_info": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "crs_name" + ], + "additionalProperties": false + } +} diff --git a/src/opengeodeweb_back/routes/crs/schemas/active.py b/src/opengeodeweb_back/routes/crs/schemas/active.py new file mode 100644 index 00000000..ebb997b1 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/active.py @@ -0,0 +1,42 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass + + +@dataclass +class Active(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + id: str + + +@dataclass +class CRSInfo(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class ActiveResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + crs_name: str + crs_info: Optional[CRSInfo] = None + + +active_route = Route( + schema=load_schema(Path(__file__)), + params=Active, + response=ActiveResponse, +) + +__all__ = ["Active", "ActiveResponse", "active_route"] diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.json b/src/opengeodeweb_back/routes/crs/schemas/align.json new file mode 100644 index 00000000..1fc0113c --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/align.json @@ -0,0 +1,107 @@ +{ + "route": "/align", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "source_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "target_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "source_points", + "target_points", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } +} diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.py b/src/opengeodeweb_back/routes/crs/schemas/align.py new file mode 100644 index 00000000..9d5a47d1 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/align.py @@ -0,0 +1,66 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass +from typing import List + + +@dataclass +class SourcePoint(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + x: float + y: float + + +@dataclass +class TargetPoint(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + x: float + y: float + + +@dataclass +class Align(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + source_points: List[SourcePoint] + target_points: List[TargetPoint] + + +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class AlignResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + binary_light_viewable: Optional[str] = None + crs: Optional[CRS] = None + + +align_route = Route( + schema=load_schema(Path(__file__)), + params=Align, + response=AlignResponse, +) + +__all__ = ["SourcePoint", "TargetPoint", "Align", "AlignResponse", "align_route"] diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json new file mode 100644 index 00000000..1b3059e8 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json @@ -0,0 +1,60 @@ +{ + "route": "/assign_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } +} diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py new file mode 100644 index 00000000..c8ed1204 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py @@ -0,0 +1,44 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass + + +@dataclass +class AssignGeographic(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + + +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class AssignGeographicResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + crs: Optional[CRS] = None + + +assign_geographic_route = Route( + schema=load_schema(Path(__file__)), + params=AssignGeographic, + response=AssignGeographicResponse, +) + +__all__ = ["AssignGeographic", "AssignGeographicResponse", "assign_geographic_route"] diff --git a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json new file mode 100644 index 00000000..07c77e6e --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json @@ -0,0 +1,63 @@ +{ + "route": "/convert_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } +} diff --git a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py new file mode 100644 index 00000000..59311161 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py @@ -0,0 +1,45 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass + + +@dataclass +class ConvertGeographic(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + + +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class ConvertGeographicResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + binary_light_viewable: Optional[str] = None + crs: Optional[CRS] = None + + +convert_geographic_route = Route( + schema=load_schema(Path(__file__)), + params=ConvertGeographic, + response=ConvertGeographicResponse, +) + +__all__ = ["ConvertGeographic", "ConvertGeographicResponse", "convert_geographic_route"] diff --git a/src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.json b/src/opengeodeweb_back/routes/crs/schemas/geographic_list.json similarity index 95% rename from src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.json rename to src/opengeodeweb_back/routes/crs/schemas/geographic_list.json index 0355b540..21db718c 100644 --- a/src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.json +++ b/src/opengeodeweb_back/routes/crs/schemas/geographic_list.json @@ -1,5 +1,5 @@ { - "route": "/geographic_coordinate_systems", + "route": "/geographic_list", "methods": [ "POST" ], diff --git a/src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.py b/src/opengeodeweb_back/routes/crs/schemas/geographic_list.py similarity index 64% rename from src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.py rename to src/opengeodeweb_back/routes/crs/schemas/geographic_list.py index 4b5d8214..d359497c 100644 --- a/src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.py +++ b/src/opengeodeweb_back/routes/crs/schemas/geographic_list.py @@ -7,7 +7,7 @@ @dataclass -class GeographicCoordinateSystems(DataClassJsonMixin): +class GeographicList(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) @@ -25,17 +25,17 @@ def __post_init__(self) -> None: @dataclass -class GeographicCoordinateSystemsResponse(DataClassJsonMixin): +class GeographicListResponse(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) crs_list: List[CRSList] -geographic_coordinate_systems_route = Route( +geographic_list_route = Route( schema=load_schema(Path(__file__)), - params=GeographicCoordinateSystems, - response=GeographicCoordinateSystemsResponse, + params=GeographicList, + response=GeographicListResponse, ) -__all__ = ["GeographicCoordinateSystems", "GeographicCoordinateSystemsResponse", "geographic_coordinate_systems_route"] +__all__ = ["GeographicList", "GeographicListResponse", "geographic_list_route"] diff --git a/src/opengeodeweb_back/routes/schemas/__init__.py b/src/opengeodeweb_back/routes/schemas/__init__.py index 9ee4b5c7..a25e4a5b 100644 --- a/src/opengeodeweb_back/routes/schemas/__init__.py +++ b/src/opengeodeweb_back/routes/schemas/__init__.py @@ -21,7 +21,6 @@ from .model_component_edge_attribute_names import * from .kill import * from .import_project import * -from .geographic_coordinate_systems import * from .geode_objects_and_output_extensions import * from .geode_object_inheritance import * from .export_project import * diff --git a/src/opengeodeweb_back/routes/schemas/save_viewable_file.json b/src/opengeodeweb_back/routes/schemas/save_viewable_file.json index c3335d36..cacf6e8b 100644 --- a/src/opengeodeweb_back/routes/schemas/save_viewable_file.json +++ b/src/opengeodeweb_back/routes/schemas/save_viewable_file.json @@ -144,6 +144,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -157,4 +177,4 @@ ], "additionalProperties": false } -} \ No newline at end of file +} diff --git a/src/opengeodeweb_back/routes/schemas/save_viewable_file.py b/src/opengeodeweb_back/routes/schemas/save_viewable_file.py index 54189b6c..1ff3c353 100644 --- a/src/opengeodeweb_back/routes/schemas/save_viewable_file.py +++ b/src/opengeodeweb_back/routes/schemas/save_viewable_file.py @@ -27,6 +27,16 @@ def __post_init__(self) -> None: type: str +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + @dataclass class MeshComponent(DataClassJsonMixin): def __post_init__(self) -> None: @@ -55,6 +65,7 @@ def __post_init__(self) -> None: viewer_type: str binary_light_viewable: Optional[str] = None collection_components: Optional[List[CollectionComponent]] = None + crs: Optional[CRS] = None mesh_components: Optional[List[MeshComponent]] = None nb_vertices: Optional[int] = None viewable_file: Optional[str] = None diff --git a/src/opengeodeweb_back/test_utils.py b/src/opengeodeweb_back/test_utils.py index c6df3cb8..73c19a2b 100644 --- a/src/opengeodeweb_back/test_utils.py +++ b/src/opengeodeweb_back/test_utils.py @@ -2,14 +2,17 @@ # Standard library imports from http import HTTPStatus +from pathlib import Path from typing import TYPE_CHECKING, Any # Third party imports +from werkzeug.datastructures import FileStorage if TYPE_CHECKING: from collections.abc import Callable from flask.testing import FlaskClient + from werkzeug.test import TestResponse # Local application imports @@ -73,3 +76,41 @@ def get_json() -> tuple[JsonData, Any]: ) elif isinstance(data, list) and data: _check_wrong_params_at(client, route, get_full_data, [*path, 0]) + + +def check_upload_file(client: FlaskClient, filename: str) -> None: + # Test data lives in the "data" folder of the tests' project folder + file = Path(client.application.config["PROJECT_FOLDER_PATH"]) / "data" / filename + response = client.put( + "/opengeodeweb_back/upload_file", + data={"file": FileStorage(file.open("rb"))}, + ) + _check(response.status_code == HTTPStatus.CREATED, f"Upload of {filename} failed") + + +def check_save_viewable_file( + client: FlaskClient, + geode_object_type: str, + filename: str, +) -> TestResponse: + check_upload_file(client, filename) + route = "/opengeodeweb_back/save_viewable_file" + + def get_full_data() -> JsonData: + return { + "geode_object_type": geode_object_type, + "filename": filename, + } + + response = client.post(route, json=get_full_data()) + _check(response.status_code == HTTPStatus.OK, f"Saving {filename} failed") + json = response.get_json() + _check(type(json["native_file"]) is str, "native_file is not a string") + _check(type(json["viewable_file"]) is str, "viewable_file is not a string") + _check(type(json.get("id")) is str, "id is not a string") + _check(json["viewer_type"] in ["model", "mesh"], "viewer_type is not model or mesh") + _check(type(json["binary_light_viewable"]) is str, "binary_light_viewable is not a string") + + # Test all params + test_route_wrong_params(client, route, get_full_data) + return response diff --git a/src/opengeodeweb_back/utils_functions.py b/src/opengeodeweb_back/utils_functions.py index 5f4c1287..1870a3e5 100644 --- a/src/opengeodeweb_back/utils_functions.py +++ b/src/opengeodeweb_back/utils_functions.py @@ -25,6 +25,7 @@ # Local application imports from . import geode_functions +from .crs_functions import active_crs from .geode_objects import geode_objects from .geode_objects.geode_model import GeodeModel from .geode_objects.geode_vertex_set import GeodeVertexSet @@ -344,6 +345,9 @@ def save_all_viewables_and_return_info( response["nb_vertices"] = geode_object.vertex_set.nb_vertices() if isinstance(geode_object, GeodeModel): response |= model_components(data.id, geode_object, data.viewable_file) + crs = active_crs(geode_object) + if crs is not None: + response["crs"] = crs return response diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py new file mode 100644 index 00000000..98739682 --- /dev/null +++ b/tests/test_crs_routes.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +# Standard library imports +import os +import shutil +from pathlib import Path +from typing import TYPE_CHECKING + +# Third party imports +import pytest + +# Local application imports +from opengeodeweb_microservice.database.data import Data + +from opengeodeweb_back import crs_functions, geode_functions, test_utils +from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D + +if TYPE_CHECKING: + from flask.testing import FlaskClient + +LAMBERT = {"authority": "EPSG", "code": "2154", "name": "RGF93 v1 / Lambert-93"} +UTM = {"authority": "EPSG", "code": "32631", "name": "WGS 84 / UTM zone 31N"} + + +def test_geographic_list(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/geographic_list" + + def get_full_data() -> test_utils.JsonData: + return { + "geode_object_type": "BRep", + } + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + crs_list = response.get_json()["crs_list"] + assert type(crs_list) is list + for crs in crs_list: + assert type(crs) is dict + + # Test all params + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_active(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/active" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + + def get_full_data() -> test_utils.JsonData: + return {"id": point_set_id} + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + assert response.get_json()["crs_name"] == "points" + assert "crs_info" not in response.get_json() + + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_assign_geographic(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/assign_geographic" + active_route = "/opengeodeweb_back/crs/active" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + + def get_full_data() -> test_utils.JsonData: + return {"id": point_set_id, **LAMBERT} + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + assert response.get_json()["crs"] == LAMBERT + response = client.post(route, json={"id": point_set_id, **UTM}) + assert response.status_code == 200 + response = client.post(active_route, json={"id": point_set_id}) + assert response.get_json() == {"crs_name": "EPSG:32631", "crs_info": UTM} + + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + response = client.post(route, json={"id": brep_id, **LAMBERT}) + assert response.status_code == 200 + response = client.post(active_route, json={"id": brep_id}) + assert response.get_json() == {"crs_name": "EPSG:2154", "crs_info": LAMBERT} + + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_crs_errors(client: FlaskClient) -> None: + active_route = "/opengeodeweb_back/crs/active" + assign_route = "/opengeodeweb_back/crs/assign_geographic" + + response = client.post(active_route, json={"id": "0" * 32}) + assert response.status_code == 404 + response = client.post(assign_route, json={"id": "0" * 32, **LAMBERT}) + assert response.status_code == 404 + + grid_id = test_utils.check_save_viewable_file( + client, "LightRegularGrid3D", "test.og_lrgd3d" + ).get_json()["id"] + response = client.post(active_route, json={"id": grid_id}) + assert response.status_code == 400 + response = client.post(assign_route, json={"id": grid_id, **LAMBERT}) + assert response.status_code == 400 + + +def _first_point(client: FlaskClient, data_id: str) -> list[float]: + with client.application.app_context(): + geode_object = geode_functions.load_geode_object(data_id) + assert isinstance(geode_object, GeodePointSet3D) + point = geode_object.point_set.point(0) + return [point.value(0), point.value(1), point.value(2)] + + +def test_convert_geographic(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/convert_geographic" + assign_route = "/opengeodeweb_back/crs/assign_geographic" + active_route = "/opengeodeweb_back/crs/active" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + lambert_point = _first_point(client, point_set_id) + + def get_full_data() -> test_utils.JsonData: + return {"id": point_set_id, **UTM} + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + # The front rebuilds its local actor from the new light viewable + assert type(response.get_json()["binary_light_viewable"]) is str + assert response.get_json()["crs"] == UTM + response = client.post(active_route, json={"id": point_set_id}) + assert response.get_json() == {"crs_name": "EPSG:32631", "crs_info": UTM} + assert _first_point(client, point_set_id) != lambert_point + + response = client.post(route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 200 + response = client.post(active_route, json={"id": point_set_id}) + assert response.get_json()["crs_name"] == "EPSG:2154" + assert _first_point(client, point_set_id) == pytest.approx(lambert_point, abs=1e-6) + + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + client.post(assign_route, json={"id": brep_id, **LAMBERT}) + response = client.post(route, json={"id": brep_id, **UTM}) + assert response.status_code == 200 + response = client.post(active_route, json={"id": brep_id}) + assert response.get_json()["crs_name"] == "EPSG:32631" + + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_convert_geographic_errors(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/convert_geographic" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + + response = client.post(route, json={"id": point_set_id, **UTM}) + assert response.status_code == 400 + + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **UTM}) + response = client.post(route, json={"id": point_set_id, **UTM}) + assert response.status_code == 400 + + response = client.post(route, json={"id": "0" * 32, **UTM}) + assert response.status_code == 404 + + +SOURCE = [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 0, "y": 10}] +TARGET = [{"x": 1000, "y": 2000}, {"x": 1000, "y": 2020}, {"x": 980, "y": 2000}] + + +def _create_point_set(client: FlaskClient) -> str: + points = [ + {"x": 0, "y": 0, "z": 5}, + {"x": 10, "y": 0, "z": 5}, + {"x": 0, "y": 10, "z": 5}, + ] + response = client.post( + "/opengeodeweb_back/create/point_set", json={"name": "align", "points": points} + ) + assert response.status_code == 200 + data_id: str = response.get_json()["id"] + return data_id + + +def test_align(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/align" + point_set_id = _create_point_set(client) + + def get_full_data() -> test_utils.JsonData: + # Copies: test_route_wrong_params mutates the returned points + return { + "id": point_set_id, + "source_points": [dict(point) for point in SOURCE], + "target_points": [dict(point) for point in TARGET], + **LAMBERT, + } + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + assert type(response.get_json()["binary_light_viewable"]) is str + assert response.get_json()["crs"] == LAMBERT + response = client.post("/opengeodeweb_back/crs/active", json={"id": point_set_id}) + assert response.get_json() == {"crs_name": "EPSG:2154", "crs_info": LAMBERT} + assert _first_point(client, point_set_id) == [1000, 2000, 5] + + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + response = client.post(route, json={**get_full_data(), "id": brep_id}) + assert response.status_code == 200 + + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_align_errors(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/align" + point_set_id = _create_point_set(client) + colinear = [{"x": 0, "y": 0}, {"x": 1, "y": 1}, {"x": 2, "y": 2}] + + response = client.post( + route, + json={"id": point_set_id, "source_points": colinear, "target_points": TARGET, **LAMBERT}, + ) + assert response.status_code == 400 + + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) + response = client.post( + route, + json={"id": point_set_id, "source_points": SOURCE, "target_points": TARGET, **LAMBERT}, + ) + assert response.status_code == 400 + + +WGS84 = {"authority": "EPSG", "code": "4326", "name": "WGS 84"} +PSEUDO_MERCATOR = {"authority": "EPSG", "code": "3857", "name": "WGS 84 / Pseudo-Mercator"} + + +def test_reassign_after_convert(client: FlaskClient) -> None: + assign_route = "/opengeodeweb_back/crs/assign_geographic" + convert_route = "/opengeodeweb_back/crs/convert_geographic" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + client.post(convert_route, json={"id": point_set_id, **UTM}) + stale_utm_point = _first_point(client, point_set_id) + + # EPSG:2154 is still registered (inactive) after the conversion + response = client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 200 + response = client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 400 + + # Relabelling then converting must reproject, not reactivate the stale EPSG:32631 + client.post(assign_route, json={"id": point_set_id, **PSEUDO_MERCATOR}) + response = client.post(convert_route, json={"id": point_set_id, **UTM}) + assert response.status_code == 200 + assert _first_point(client, point_set_id) != pytest.approx(stale_utm_point) + + +def test_convert_out_of_domain(client: FlaskClient) -> None: + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + convert_route = "/opengeodeweb_back/crs/convert_geographic" + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) + client.post(convert_route, json={"id": point_set_id, **UTM}) + # UTM metres read as degrees give an invalid latitude + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **WGS84}) + response = client.post(convert_route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 400 + + +def test_saved_native_file_keeps_its_crs(client: FlaskClient) -> None: + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) + with client.application.app_context(): + data = Data.get(point_set_id) + assert data is not None + native_path = Path(geode_functions.data_file_path(point_set_id, data.native_file)) + upload_path = Path(geode_functions.upload_file_path("georeferenced.og_pts3d")) + upload_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(native_path, upload_path) + try: + response = client.post( + "/opengeodeweb_back/save_viewable_file", + json={"geode_object_type": "PointSet3D", "filename": "georeferenced.og_pts3d"}, + ) + finally: + upload_path.unlink() + assert response.status_code == 200 + assert response.get_json()["crs"] == LAMBERT + # The copy lives in the shared test data folder: it must not outlive the test + assert not upload_path.exists() + + +def _attribute_count(client: FlaskClient, data_id: str) -> int: + with client.application.app_context(): + geode_object = geode_functions.load_geode_object(data_id) + assert isinstance(geode_object, GeodePointSet3D) + return len(geode_object.point_set.vertex_attribute_manager().attribute_ids()) + + +def test_round_trips_do_not_grow_the_native_file(client: FlaskClient) -> None: + convert_route = "/opengeodeweb_back/crs/convert_geographic" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) + client.post(convert_route, json={"id": point_set_id, **UTM}) + client.post(convert_route, json={"id": point_set_id, **LAMBERT}) + attribute_count = _attribute_count(client, point_set_id) + + for _ in range(2): + client.post(convert_route, json={"id": point_set_id, **UTM}) + client.post(convert_route, json={"id": point_set_id, **LAMBERT}) + assert _attribute_count(client, point_set_id) == attribute_count + + +def test_unknown_crs(client: FlaskClient) -> None: + unknown = {"authority": "FOO", "code": "1", "name": "Unknown"} + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + + response = client.post( + "/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **unknown} + ) + assert response.status_code == 400 + response = client.post( + "/opengeodeweb_back/crs/align", + json={ + "id": _create_point_set(client), + "source_points": SOURCE, + "target_points": TARGET, + **unknown, + }, + ) + assert response.status_code == 400 + + # The name comes from the CRS list, not from the request + response = client.post( + "/opengeodeweb_back/crs/assign_geographic", + json={"id": point_set_id, **LAMBERT, "name": "Wrong name"}, + ) + assert response.get_json()["crs"] == LAMBERT + response = client.post( + "/opengeodeweb_back/crs/convert_geographic", json={"id": point_set_id, **unknown} + ) + assert response.status_code == 400 + + +def test_assign_only_rewrites_the_native_file(client: FlaskClient) -> None: + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + with client.application.app_context(): + data = Data.get(point_set_id) + assert data is not None + assert data.viewable_file is not None + viewable_path = Path(geode_functions.data_file_path(point_set_id, data.viewable_file)) + viewable_mtime = viewable_path.stat().st_mtime_ns + + response = client.post( + "/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT} + ) + assert response.status_code == 200 + assert viewable_path.stat().st_mtime_ns == viewable_mtime + + +def test_unreadable_crs_does_not_fail_the_data_response(monkeypatch: pytest.MonkeyPatch) -> None: + def unreadable(*_args: object) -> None: + msg = "unreadable CRS" + raise RuntimeError(msg) + + monkeypatch.setattr(crs_functions, "geographic_info", unreadable) + assert crs_functions.active_crs(GeodePointSet3D()) is None + + +def test_proj_finds_its_database() -> None: + # Packaged apps move libproj away from the opengeode share folder + assert (Path(os.environ["PROJ_DATA"]) / "proj.db").is_file() diff --git a/tests/test_routes.py b/tests/test_routes.py index 594a9343..46fa75a8 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -16,7 +16,6 @@ # Local application imports from opengeodeweb_microservice.database.data import Data -from werkzeug.datastructures import FileStorage from opengeodeweb_back import geode_functions, test_utils from opengeodeweb_back.geode_objects.geode_edged_curve3d import ( @@ -84,16 +83,7 @@ def get_full_data() -> test_utils.JsonData: def test_upload_file(client: FlaskClient) -> None: - check_upload_file(client, "test.og_brep") - - -def check_upload_file(client: FlaskClient, filename: str) -> None: - file = data_dir / filename - response = client.put( - "/opengeodeweb_back/upload_file", - data={"file": FileStorage(file.open("rb"))}, - ) - assert response.status_code == 201 + test_utils.check_upload_file(client, "test.og_brep") def test_upload_file_raw(client: FlaskClient) -> None: @@ -193,27 +183,8 @@ def get_full_data() -> test_utils.JsonData: test_utils.test_route_wrong_params(client, route, get_full_data) -def test_geographic_coordinate_systems(client: FlaskClient) -> None: - route = "/opengeodeweb_back/geographic_coordinate_systems" - - def get_full_data() -> test_utils.JsonData: - return { - "geode_object_type": "BRep", - } - - response = client.post(route, json=get_full_data()) - assert response.status_code == 200 - crs_list = response.get_json()["crs_list"] - assert type(crs_list) is list - for crs in crs_list: - assert type(crs) is dict - - # Test all params - test_utils.test_route_wrong_params(client, route, get_full_data) - - def test_validate_object(client: FlaskClient) -> None: - response_save = check_save_viewable_file(client, "BRep", "cube.og_brep") + response_save = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep") assert response_save.status_code == 200 model_id = response_save.get_json()["id"] response = client.post("/opengeodeweb_back/validate", json={"id": model_id}) @@ -225,7 +196,9 @@ def test_validate_object(client: FlaskClient) -> None: def test_validate_invalid_object(client: FlaskClient) -> None: - response_save = check_save_viewable_file(client, "BRep", "wrong_boundary_surface_model.og_brep") + response_save = test_utils.check_save_viewable_file( + client, "BRep", "wrong_boundary_surface_model.og_brep" + ) assert response_save.status_code == 200 model_id = response_save.get_json()["id"] response = client.post("/opengeodeweb_back/validate", json={"id": model_id}) @@ -259,42 +232,9 @@ def get_full_data() -> test_utils.JsonData: test_utils.test_route_wrong_params(client, route, get_full_data) -def test_save_viewable_file(client: FlaskClient) -> TestResponse: - check_save_viewable_file(client, "BRep", "corbi.og_brep") - - -def check_save_viewable_file( - client: FlaskClient, - geode_object_type: str, - filename: str, -) -> TestResponse: - check_upload_file(client, filename) - route = "/opengeodeweb_back/save_viewable_file" - - def get_full_data() -> test_utils.JsonData: - return { - "geode_object_type": geode_object_type, - "filename": filename, - } - - # Normal test with filename 'corbi.og_brep' - response = client.post(route, json=get_full_data()) - assert response.status_code == 200 - native_file = response.get_json()["native_file"] - assert type(native_file) is str - viewable_file = response.get_json()["viewable_file"] - assert type(viewable_file) is str - data_id = response.get_json().get("id") - assert type(data_id) is str - object_type = response.get_json()["viewer_type"] - assert type(object_type) is str - assert object_type in ["model", "mesh"] - binary_light_viewable = response.get_json()["binary_light_viewable"] - assert type(binary_light_viewable) is str - - # Test all params - test_utils.test_route_wrong_params(client, route, get_full_data) - return response +def test_save_viewable_file(client: FlaskClient) -> None: + response = test_utils.check_save_viewable_file(client, "BRep", "corbi.og_brep") + assert "crs" not in response.get_json() def test_texture_coordinates(client: FlaskClient) -> None: @@ -523,7 +463,7 @@ def get_full_data() -> test_utils.JsonData: def test_model_components(client: FlaskClient) -> None: geode_object_type = "BRep" filename = "cube.og_brep" - response = check_save_viewable_file(client, geode_object_type, filename) + response = test_utils.check_save_viewable_file(client, geode_object_type, filename) assert response.status_code == 200 assert "mesh_components" in response.get_json() mesh_components = response.get_json()["mesh_components"] @@ -679,7 +619,7 @@ def test_save_viewable_workflow_from_object(client: FlaskClient) -> None: def _load_brep_components(client: FlaskClient) -> tuple[str, dict[str, list[str]]]: """Load cube.og_brep and return (model_id, {component_type: [geode_id, ...]}).""" - response = check_save_viewable_file(client, "BRep", "cube.og_brep") + response = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep") assert response.status_code == 200 model_id: str = response.get_json()["id"] mesh_components: list[dict[str, object]] = response.get_json()["mesh_components"] @@ -1032,6 +972,8 @@ def test_apply_time_series_errors(client: FlaskClient) -> None: response = client.post(route, json={"id": "0" * 32, "filename": filename}) assert response.status_code == 404 - point_set_id = check_save_viewable_file(client, "PointSet3D", "test.og_pts3d").get_json()["id"] + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] response = client.post(route, json={"id": point_set_id, "filename": filename}) assert response.status_code == 400