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..411a95d8 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,506 @@ } } }, + "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 + }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, + "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 + }, + "linked": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + }, + "binary_light_viewable": { + "type": "string" + } + }, + "required": [ + "id" + ], + "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 + }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "only_plausible": { + "type": "boolean" + } + }, + "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 + }, + "implausible": { + "type": "boolean" + }, + "linked": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + }, + "implausible": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "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 + }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, + "mode": { + "type": "string", + "enum": [ + "similarity", + "affine" + ] + }, + "source_points": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "picked": { + "type": "boolean", + "default": false + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "target_points": { + "type": "array", + "minItems": 1, + "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 + }, + "scale": { + "type": "number" + }, + "rotation": { + "type": "number" + }, + "linked": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "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": [ + "id" + ], + "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 +1568,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 +2399,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..3749b7ec 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,150 @@ 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; + linked_ids?: string[]; + name: string; +} + +export interface CRSConvertGeographicResponse { + binary_light_viewable?: string; + crs?: CRSConvertGeographicResponseCRS; + linked?: CRSConvertGeographicResponseLinked[]; +} + +export interface CRSConvertGeographicResponseCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSConvertGeographicResponseLinked { + binary_light_viewable?: string; + crs?: PurpleCRS; + id: string; +} + +export interface PurpleCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSAssignGeographicParams { + authority: string; + code: string; + id: string; + linked_ids?: string[]; + name: string; + only_plausible?: boolean; +} + +export interface CRSAssignGeographicResponse { + crs?: CRSAssignGeographicResponseCRS; + implausible?: boolean; + linked?: CRSAssignGeographicResponseLinked[]; +} + +export interface CRSAssignGeographicResponseCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSAssignGeographicResponseLinked { + crs?: FluffyCRS; + id: string; + implausible?: boolean; +} + +export interface FluffyCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSAlignParams { + authority: string; + code: string; + id: string; + linked_ids?: string[]; + mode?: Mode; + name: string; + source_points: SourcePoint[]; + target_points: TargetPoint[]; +} + +export type Mode = "similarity" | "affine"; + +export interface SourcePoint { + picked?: boolean; + x: number; + y: number; +} + +export interface TargetPoint { + x: number; + y: number; +} + +export interface CRSAlignResponse { + binary_light_viewable?: string; + crs?: CRSAlignResponseCRS; + linked?: CRSAlignResponseLinked[]; + rotation?: number; + scale?: number; +} + +export interface CRSAlignResponseCRS { + authority: string; + code: string; + name: string; +} + +export interface CRSAlignResponseLinked { + binary_light_viewable?: string; + crs?: TentacledCRS; + id: string; +} + +export interface TentacledCRS { + 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 +378,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 +399,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 +600,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 +735,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 +765,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/__init__.py b/src/opengeodeweb_back/crs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/opengeodeweb_back/crs/crs_alignment.py b/src/opengeodeweb_back/crs/crs_alignment.py new file mode 100644 index 00000000..41c8bdc7 --- /dev/null +++ b/src/opengeodeweb_back/crs/crs_alignment.py @@ -0,0 +1,197 @@ +"""Georeferencing data from control points: similarity or affine alignment.""" + +from __future__ import annotations + +# Standard library imports +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +# Third party imports +import opengeode as og + +# Local application imports +from opengeodeweb_back.crs.crs_functions import ( + CRS_GEODE_OBJECTS_3D, + ORIGINAL_CRS, + CrsError, + coordinate_system, + geographic_info, + original_coordinates, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + import opengeode_geosciences as og_geosciences + + from opengeodeweb_back.crs.crs_functions import CrsGeodeObject + +SIMILARITY = "similarity" +AFFINE = "affine" +SIMILARITY_PAIRS = (1, 2) +AFFINE_PAIRS = 3 + + +class ControlPoint(Protocol): + x: float + y: float + + +class SourcePoint(ControlPoint, Protocol): + # Picked on the displayed data rather than typed + picked: bool | None + + +@dataclass(frozen=True) +class Alignment: + source: og.CoordinateSystem2D + target: og.CoordinateSystem2D + scale: float | None + info: og_geosciences.GeographicCoordinateSystemInfo + + +def points(control_points: Sequence[ControlPoint]) -> list[og.Point2D]: + return [og.Point2D([point.x, point.y]) for point in control_points] + + +def check_pair_count(mode: str, source_count: int, target_count: int) -> None: + if source_count != target_count: + msg = "Source and target points must come in pairs" + raise CrsError(msg) + if mode == AFFINE and source_count != AFFINE_PAIRS: + msg = "Affine alignment needs 3 pairs of points" + raise CrsError(msg) + if mode == SIMILARITY and source_count not in SIMILARITY_PAIRS: + msg = "Alignment without distortion needs 1 or 2 pairs of points" + raise CrsError(msg) + + +def _crs_names(geode_object: CrsGeodeObject) -> set[str]: + # Model components may hold different CRS: gather them all + managers = [ + mesh.main_coordinate_reference_system_manager() for mesh in geode_object.crs_meshes() + ] + return {name for manager in managers for name in manager.coordinate_reference_system_names()} + + +def has_original_coordinates(geode_object: CrsGeodeObject) -> bool: + # Assigning a geographic CRS renames the "points" CRS: the assigned coordinates are the base + return all( + mesh.main_coordinate_reference_system_manager().coordinate_reference_system_exists( + ORIGINAL_CRS + ) + for mesh in geode_object.crs_meshes() + ) + + +def _delete_other_crs(geode_object: CrsGeodeObject, *kept: str) -> None: + for crs_name in _crs_names(geode_object): + if crs_name not in kept: + geode_object.delete_crs(crs_name) + + +def source_points_in_original_coordinates( + geode_object: CrsGeodeObject, source_points: Sequence[SourcePoint] +) -> list[og.Point2D]: + """Source points in the loaded coordinates: alignments must not stack. + + Picked points lie on the displayed data: they are mapped back. + Typed points are already original coordinates. + """ + typed = points(source_points) + if geographic_info(geode_object.crs_manager()) is None or not has_original_coordinates( + geode_object + ): + return typed + original_points = original_coordinates(geode_object, typed) + if original_points is None: + if any(point.picked for point in source_points): + msg = "Data has no three non-colinear vertices to re-align" + raise CrsError(msg) + return typed + return [ + original if source.picked else point + for source, original, point in zip(source_points, original_points, typed, strict=True) + ] + + +def _restart_alignment(geode_object: CrsGeodeObject) -> bool: + """Go back to the loaded coordinates. True when only the active ones can be aligned.""" + if geographic_info(geode_object.crs_manager()) is None: + return False + if not has_original_coordinates(geode_object): + return True + geode_object.set_active_crs(ORIGINAL_CRS) + _delete_other_crs(geode_object, ORIGINAL_CRS) + return False + + +def _frame(origin: og.Point2D, x: float, y: float) -> og.CoordinateSystem2D: + # Perpendicular axes of equal length: going from one frame to another is a similarity + return og.CoordinateSystem2D([og.Vector2D([x, y]), og.Vector2D([-y, x])], origin) + + +def _similarity_coordinate_systems( + source: list[og.Point2D], target: list[og.Point2D] +) -> tuple[og.CoordinateSystem2D, og.CoordinateSystem2D, float, float]: + """Frames moving source onto target without distortion, with the scale and rotation.""" + if len(source) == 1: + return _frame(source[0], 1, 0), _frame(target[0], 1, 0), 1.0, 0.0 + source_x = source[1].value(0) - source[0].value(0) + source_y = source[1].value(1) - source[0].value(1) + target_x = target[1].value(0) - target[0].value(0) + target_y = target[1].value(1) - target[0].value(1) + source_length = math.hypot(source_x, source_y) + target_length = math.hypot(target_x, target_y) + if source_length == 0 or target_length == 0: + msg = "The two points of the source or of the target must differ" + raise CrsError(msg) + rotation = math.degrees( + math.atan2( + source_x * target_y - source_y * target_x, source_x * target_x + source_y * target_y + ) + ) + return ( + _frame(source[0], source_x, source_y), + _frame(target[0], target_x, target_y), + target_length / source_length, + rotation, + ) + + +def colinear(frame_points: list[og.Point2D]) -> bool: + origin, first, second = frame_points + first_x = first.value(0) - origin.value(0) + first_y = first.value(1) - origin.value(1) + second_x = second.value(0) - origin.value(0) + second_y = second.value(1) - origin.value(1) + # Relative to the size of the triangle: coordinates may be in metres or millions of metres + scale = max(math.hypot(first_x, first_y), math.hypot(second_x, second_y)) ** 2 + return scale == 0 or abs(first_x * second_y - first_y * second_x) <= 1e-12 * scale + + +def coordinate_systems( + mode: str, source_points: list[og.Point2D], target_points: list[og.Point2D] +) -> tuple[og.CoordinateSystem2D, og.CoordinateSystem2D, float | None, float | None]: + """Frames moving source onto target, with the scale and rotation without distortion.""" + if mode == SIMILARITY: + return _similarity_coordinate_systems(source_points, target_points) + # Checked here: any other error of OpenGeode is not the user's and is not hidden + if colinear(source_points) or colinear(target_points): + msg = "Points must not be colinear" + raise CrsError(msg) + return coordinate_system(source_points), coordinate_system(target_points), None, None + + +def apply_alignment(geode_object: CrsGeodeObject, alignment: Alignment) -> None: + from_active = _restart_alignment(geode_object) + geode_object.create_crs("aligned", alignment.source, alignment.target) + # The aligned CRS is active: scale its Z so the data keeps its proportions + scale = alignment.scale + if scale is not None and scale != 1 and isinstance(geode_object, CRS_GEODE_OBJECTS_3D): + geode_object.scale_z(scale) + if from_active: + # The previous geographic CRS and its coordinates would clash with the new assignment + _delete_other_crs(geode_object, "aligned", ORIGINAL_CRS) + geode_object.assign_crs(alignment.info.authority_code(), alignment.info) diff --git a/src/opengeodeweb_back/crs/crs_assignment.py b/src/opengeodeweb_back/crs/crs_assignment.py new file mode 100644 index 00000000..04b2365f --- /dev/null +++ b/src/opengeodeweb_back/crs/crs_assignment.py @@ -0,0 +1,117 @@ +"""Assigning a geographic CRS to data, and converting data to another one.""" + +from __future__ import annotations + +# Standard library imports +import logging +from typing import TYPE_CHECKING, Any + +# Third party imports +import opengeode as og + +# Local application imports +from opengeodeweb_back import geode_functions +from opengeodeweb_back.crs.crs_functions import ( + ORIGINAL_CRS, + CrsError, + active_crs, + coordinate_system, + coordinates_implausible, + geographic_info, + known_info, +) + +if TYPE_CHECKING: + import opengeode_geosciences as og_geosciences + from opengeodeweb_microservice.database.data import Data + + from opengeodeweb_back.crs.crs_functions import CrsGeodeObject + +logger = logging.getLogger(__name__) + + +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: + msg = f"{crs_name} is already the active CRS" + raise CrsError(msg) + # 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 _keep_original_coordinates(geode_object: CrsGeodeObject) -> None: + """Assigning renames the active CRS: assign a copy so "points" survives for re-align.""" + if not all( + mesh.main_coordinate_reference_system_manager().active_coordinate_reference_system_name() + == ORIGINAL_CRS + for mesh in geode_object.crs_meshes() + ): + return + identity = coordinate_system([og.Point2D([0, 0]), og.Point2D([1, 0]), og.Point2D([0, 1])]) + geode_object.create_crs("assigned", identity, identity) + + +def _assign( + geode_object: CrsGeodeObject, authority: str, code: str +) -> og_geosciences.GeographicCoordinateSystemInfo: + info = known_info(geode_object, authority, code) + prepare_crs_name(geode_object, info.authority_code()) + _keep_original_coordinates(geode_object) + geode_object.assign_crs(info.authority_code(), info) + return info + + +def assign_all( + targets: list[tuple[str, Data, CrsGeodeObject]], + authority: str, + code: str, + *, + only_plausible: bool, +) -> dict[str, dict[str, Any]]: + """Assign the CRS to every target and save them. Per id: implausible, and crs if assigned. + + Without only_plausible, the plausibility is a warning only: the label is kept. + """ + # Checked before any change: with only_plausible, an implausible data is left as it is + implausible = { + target_id: coordinates_implausible( + target_object, known_info(target_object, authority, code) + ) + for target_id, _, target_object in targets + } + assigned = { + target_id for target_id, _, _ in targets if not (only_plausible and implausible[target_id]) + } + # Every data is assigned in memory before any file is written + for target_id, _, target_object in targets: + if target_id in assigned: + _assign(target_object, authority, code) + # Assigning only relabels the coordinates: the viewables are unchanged + for target_id, target_data, target_object in targets: + if target_id in assigned: + target_object.save(geode_functions.data_file_path(target_id, target_data.native_file)) + entries: dict[str, dict[str, Any]] = {} + for target_id, _, target_object in targets: + entry: dict[str, Any] = {"implausible": implausible[target_id]} + if target_id in assigned: + entry["crs"] = active_crs(target_object) + entries[target_id] = entry + return entries + + +def convert(geode_object: CrsGeodeObject, authority: str, code: str) -> None: + if geographic_info(geode_object.crs_manager()) is None: + msg = "Assign a geographic CRS before converting" + raise CrsError(msg) + info = known_info(geode_object, authority, code) + prepare_crs_name(geode_object, info.authority_code()) + try: + geode_object.convert_crs(info.authority_code(), info) + except RuntimeError as error: + # Usually PROJ rejecting coordinates outside the source or target CRS domain: the + # Message tells, and the log keeps the trace for any other OpenGeode error + logger.exception("Conversion to %s failed", info.authority_code()) + msg = f"Coordinates cannot be converted to {info.authority_code()}: {error}" + raise CrsError(msg) from error diff --git a/src/opengeodeweb_back/crs/crs_functions.py b/src/opengeodeweb_back/crs/crs_functions.py new file mode 100644 index 00000000..ce81d204 --- /dev/null +++ b/src/opengeodeweb_back/crs/crs_functions.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +# Standard library imports +import contextlib +import functools +import logging +import math +import os +import sqlite3 +from pathlib import Path +from typing import TYPE_CHECKING, Any + +# Third party imports +import opengeode as og +import opengeode_geosciences as og_geosciences + +# Local application imports +from opengeodeweb_back import geode_functions +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_microservice.database.data import Data + + from opengeodeweb_back.geode_objects.crs_coordinates import Mesh + from opengeodeweb_back.geode_objects.geode_object import GeodeObject + +logger = logging.getLogger(__name__) + + +class CrsError(Exception): + """A CRS request the data cannot follow: reported to the user, the data is unchanged.""" + + +ORIGINAL_CRS = "points" +# OpenGeode orders EPSG:4326 coordinates as (latitude, longitude) +WGS84 = og_geosciences.GeographicCoordinateSystemInfo("EPSG", "4326", "WGS 84") +MAX_LATITUDE = 90.0 +MAX_LONGITUDE = 180.0 + +# 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, +) + +# Objects with a Z axis: alignment without distortion scales it too +CRS_GEODE_OBJECTS_3D = ( + GeodeBRep, + GeodePointSet3D, + GeodeEdgedCurve3D, + 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} + + +def convert_xy_corners( + bounds: tuple[float, float, float, float], + source: og_geosciences.GeographicCoordinateSystemInfo, + target: og_geosciences.GeographicCoordinateSystemInfo, +) -> list[tuple[float, float]]: + """The four corners of bounds, given in source, converted to target. + + Raises RuntimeError when PROJ rejects coordinates outside either CRS domain. + """ + x_min, y_min, x_max, y_max = bounds + corners = GeodePointSet3D() + builder = corners.builder() + for x, y in ((x_min, y_min), (x_max, y_min), (x_min, y_max), (x_max, y_max)): + builder.create_point(og.Point3D([x, y, 0.0])) + corners.assign_crs(source.authority_code(), source) + # Assigning then converting to the same CRS name is rejected by OpenGeode + if source.authority_code() != target.authority_code(): + corners.convert_crs(target.authority_code(), target) + points = (corners.point_set.point(index) for index in range(4)) + return [(point.value(0), point.value(1)) for point in points] + + +# Extents (south, north, west, east) PROJ declares for a CRS +AREA_OF_USE_QUERY = """ +SELECT extent.south_lat, extent.north_lat, extent.west_lon, extent.east_lon +FROM usage JOIN extent +ON extent.auth_name = usage.extent_auth_name AND extent.code = usage.extent_code +WHERE usage.object_auth_name = ? AND usage.object_code = ? +AND usage.object_table_name IN ('projected_crs', 'geodetic_crs', 'compound_crs') +""" + + +# Projected CRS, alone or as the horizontal part of a compound CRS +PROJECTED_QUERY = """ +SELECT 1 FROM projected_crs WHERE auth_name = ? AND code = ? +UNION +SELECT 1 FROM compound_crs JOIN projected_crs +ON projected_crs.auth_name = compound_crs.horiz_crs_auth_name +AND projected_crs.code = compound_crs.horiz_crs_code +WHERE compound_crs.auth_name = ? AND compound_crs.code = ? +""" + + +def _query_proj_database(query: str, parameters: tuple[str, ...]) -> list[tuple[Any, ...]] | None: + """Rows from the PROJ database opengeode ships, None when it cannot be read. + + The bindings do not expose this information. + """ + database = (Path(os.environ["PROJ_DATA"]) / "proj.db").resolve() + try: + with contextlib.closing( + sqlite3.connect(f"{database.as_uri()}?mode=ro", uri=True) + ) as connection: + rows: list[tuple[Any, ...]] = connection.execute(query, parameters).fetchall() + except sqlite3.Error as error: + logger.warning("Cannot read the PROJ database: %s", error) + return None + return rows + + +@functools.cache +def area_of_use(authority: str, code: str) -> list[tuple[float, float, float, float]]: + """Extents where PROJ declares the CRS usable, empty when unknown.""" + rows = _query_proj_database(AREA_OF_USE_QUERY, (authority, code)) or [] + return [(float(s), float(n), float(w), float(e)) for s, n, w, e in rows] + + +@functools.cache +def is_projected(authority: str, code: str) -> bool: + """Whether the CRS measures X and Y as lengths, not angles. True when unknown.""" + rows = _query_proj_database(PROJECTED_QUERY, (authority, code, authority, code)) + return rows is None or len(rows) > 0 + + +def _overlaps( + latitudes: tuple[float, float], + longitudes: tuple[float, float], + area: tuple[float, float, float, float], +) -> bool: + south, north, west, east = area + if latitudes[1] < south or latitudes[0] > north: + return False + if west <= east: + return longitudes[1] >= west and longitudes[0] <= east + # The area crosses the antimeridian + return longitudes[1] >= west or longitudes[0] <= east + + +def coordinates_implausible( + geode_object: CrsGeodeObject, info: og_geosciences.GeographicCoordinateSystemInfo +) -> bool: + """Whether the data XY extent, read in info, misses the area where info is used.""" + boxes = [mesh.bounding_box() for mesh in geode_object.crs_meshes()] + # Without mesh, the infinite bounds below mark the data as implausible + bounds = ( + min((box.min().value(0) for box in boxes), default=math.inf), + min((box.min().value(1) for box in boxes), default=math.inf), + max((box.max().value(0) for box in boxes), default=math.inf), + max((box.max().value(1) for box in boxes), default=math.inf), + ) + if not all(math.isfinite(value) for value in bounds): + return True + try: + corners = convert_xy_corners(bounds, info, WGS84) + except RuntimeError: + return True + latitudes = [latitude for latitude, _ in corners] + longitudes = [longitude for _, longitude in corners] + # Written as "not within" so NaN counts as outside + if not all(abs(value) <= MAX_LATITUDE for value in latitudes) or not all( + abs(value) <= MAX_LONGITUDE for value in longitudes + ): + return True + areas = area_of_use(info.authority, info.code) + if not areas: + return False + latitude_range = (min(latitudes), max(latitudes)) + longitude_range = (min(longitudes), max(longitudes)) + # Partial overlap is enough: models often go a little beyond the declared area + return not any(_overlaps(latitude_range, longitude_range, area) for area in areas) + + +# Frames are fitted on a sample of the vertices: large meshes would be slow to scan in Python +MAX_FRAME_VERTICES = 10_000 +MIN_FRAME_POINTS = 3 + + +def _xy( + crs: og.CoordinateReferenceSystem2D | og.CoordinateReferenceSystem3D, vertex: int +) -> list[float]: + point = crs.point(vertex) + return [point.value(0), point.value(1)] + + +def _cross(origin: list[float], first: list[float], second: list[float]) -> float: + return (first[0] - origin[0]) * (second[1] - origin[1]) - (first[1] - origin[1]) * ( + second[0] - origin[0] + ) + + +def _frames(mesh: Mesh) -> tuple[og.CoordinateSystem2D, og.CoordinateSystem2D] | None: + """The same three vertices as frames in the active and in the original CRS.""" + nb_vertices = mesh.nb_vertices() + if nb_vertices < MIN_FRAME_POINTS: + return None + crs_manager = mesh.main_coordinate_reference_system_manager() + active = crs_manager.active_coordinate_reference_system() + original = crs_manager.find_coordinate_reference_system(ORIGINAL_CRS) + vertices = range(0, nb_vertices, max(1, nb_vertices // MAX_FRAME_VERTICES)) + points = {vertex: _xy(active, vertex) for vertex in vertices} + origin = points[0] + # The farthest vertex, then the one spanning the largest triangle: the best conditioned frame + first = max( + vertices, + key=lambda vertex: math.dist(origin, points[vertex]), + ) + second = max( + vertices, + key=lambda vertex: abs(_cross(origin, points[first], points[vertex])), + ) + span = math.dist(origin, points[first]) ** 2 + if span == 0 or abs(_cross(origin, points[first], points[second])) <= span * 1e-9: + return None + + def frame( + crs: og.CoordinateReferenceSystem2D | og.CoordinateReferenceSystem3D, + ) -> og.CoordinateSystem2D: + return og.CoordinateSystem2D( + og.Point2D(_xy(crs, 0)), [og.Point2D(_xy(crs, first)), og.Point2D(_xy(crs, second))] + ) + + return frame(active), frame(original) + + +def original_coordinates( + geode_object: CrsGeodeObject, points: list[og.Point2D] +) -> list[og.Point2D] | None: + """Points given in the active CRS, expressed in the original coordinates. + + An alignment is affine: three vertices known in both CRS invert it (approximately when the + data was converted since). None when no mesh has three non-colinear vertices. + """ + for mesh in geode_object.crs_meshes(): + frames = _frames(mesh) + if frames is not None: + active, original = frames + return [original.global_coordinates(active.coordinates(point)) for point in points] + return None + + +def coordinate_system(points: list[og.Point2D]) -> og.CoordinateSystem2D: + origin, first, second = points + return og.CoordinateSystem2D(origin, [first, second]) + + +def load_crs_object(data_id: str) -> tuple[Data, CrsGeodeObject]: + data = geode_functions.get_data_info(data_id) + geode_object = geode_functions.geode_object_from_string(data.geode_object).load( + geode_functions.data_file_path(data_id, data.native_file) + ) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + msg = f"{data_id} does not support coordinate reference systems" + raise CrsError(msg) + return data, geode_object + + +def linked_objects( + data_id: str, linked_ids: list[str] | None +) -> list[tuple[str, Data, CrsGeodeObject]]: + """Data sharing the change of the main one, all loaded before any file is written.""" + linked_ids = linked_ids or [] + if data_id in linked_ids: + msg = "The data cannot also be linked to itself" + raise CrsError(msg) + return [(linked_id, *load_crs_object(linked_id)) for linked_id in linked_ids] + + +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: + msg = f"Unknown CRS {authority}:{code}" + raise CrsError(msg) + return info 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..e974d3be --- /dev/null +++ b/src/opengeodeweb_back/geode_objects/crs_coordinates.py @@ -0,0 +1,61 @@ +# 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 +) +Mesh3D = og.PointSet3D | og.EdgedCurve3D | og.SurfaceMesh3D | og.SolidMesh3D +MeshBuilder3D = ( + og.PointSetBuilder3D | og.EdgedCurveBuilder3D | og.SurfaceMeshBuilder3D | og.SolidMeshBuilder3D +) + + +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() + # Model components may not all hold the same CRS + if not crs_manager.coordinate_reference_system_exists(crs_name): + return + 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) + + +def scale_z(mesh: Mesh3D, builder: MeshBuilder3D, factor: float) -> None: + """Scale the active coordinates along Z around Z = 0.""" + for vertex in range(mesh.nb_vertices()): + point = mesh.point(vertex) + builder.set_point( + vertex, og.Point3D([point.value(0), point.value(1), point.value(2) * factor]) + ) diff --git a/src/opengeodeweb_back/geode_objects/geode_brep.py b/src/opengeodeweb_back/geode_objects/geode_brep.py index 21d5e172..b0a3c1dd 100644 --- a/src/opengeodeweb_back/geode_objects/geode_brep.py +++ b/src/opengeodeweb_back/geode_objects/geode_brep.py @@ -11,11 +11,14 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_model import ComponentRegistry, GeodeModel if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeModelType + from .crs_coordinates import Mesh + class GeodeBRep(GeodeModel): brep: og.BRep @@ -136,6 +139,76 @@ 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 set_active_crs(self, crs_name: str) -> None: + # Models have no CRS manager of their own: activate the CRS 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() + crs_builder.set_active_coordinate_reference_system(crs_name) + for line in self.brep.lines(): + crs_builder = builder.line_mesh_builder( + line + ).main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + for surface in self.brep.surfaces(): + crs_builder = builder.surface_mesh_builder( + surface + ).main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + for block in self.brep.blocks(): + crs_builder = builder.block_mesh_builder( + block + ).main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + # Models have no CRS manager of their own: CRS live on every component mesh + meshes: list[Mesh] = [] + meshes.extend(corner.mesh() for corner in self.brep.corners()) + meshes.extend(line.mesh() for line in self.brep.lines()) + meshes.extend(surface.mesh() for surface in self.brep.surfaces()) + meshes.extend(block.mesh() for block in self.brep.blocks()) + return meshes + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: @@ -158,3 +231,15 @@ def create_crs( ) -> None: builder = self.builder() og.create_brep_coordinate_system(self.brep, builder, crs_name, input_crs, output_crs) + + def scale_z(self, factor: float) -> None: + # Models have no CRS manager of their own: every component mesh is scaled + builder = self.builder() + for corner in self.brep.corners(): + scale_z(corner.mesh(), builder.corner_mesh_builder(corner), factor) + for line in self.brep.lines(): + scale_z(line.mesh(), builder.line_mesh_builder(line), factor) + for surface in self.brep.surfaces(): + scale_z(surface.mesh(), builder.surface_mesh_builder(surface), factor) + for block in self.brep.blocks(): + scale_z(block.mesh(), builder.block_mesh_builder(block), factor) diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py index 6a6cd622..7f79f71e 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py @@ -11,11 +11,14 @@ 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: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodeEdgedCurve2D(GeodeGraph): edged_curve: og.EdgedCurve2D @@ -103,6 +106,20 @@ 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 set_active_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + return [self.edged_curve] + 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..71609136 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py @@ -11,11 +11,14 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_graph import GeodeGraph if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodeEdgedCurve3D(GeodeGraph): edged_curve: og.EdgedCurve3D @@ -103,6 +106,20 @@ 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 set_active_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + return [self.edged_curve] + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: @@ -129,3 +146,6 @@ def create_crs( og.create_edged_curve_coordinate_system3D( self.edged_curve, builder, crs_name, input_crs, output_crs ) + + def scale_z(self, factor: float) -> None: + scale_z(self.edged_curve, self.builder(), factor) diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py index 7a423348..e565abd0 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py @@ -11,11 +11,14 @@ 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: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodePointSet2D(GeodeVertexSet): point_set: og.PointSet2D @@ -101,6 +104,20 @@ 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 set_active_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + return [self.point_set] + 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..f84f7ca1 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py @@ -11,11 +11,14 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodePointSet3D(GeodeVertexSet): point_set: og.PointSet3D @@ -101,6 +104,20 @@ 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 set_active_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + return [self.point_set] + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: @@ -127,3 +144,6 @@ def create_crs( og.create_point_set_coordinate_system3D( self.point_set, builder, crs_name, input_crs, output_crs ) + + def scale_z(self, factor: float) -> None: + scale_z(self.point_set, self.builder(), factor) diff --git a/src/opengeodeweb_back/geode_objects/geode_section.py b/src/opengeodeweb_back/geode_objects/geode_section.py index c6b7a403..a9191374 100644 --- a/src/opengeodeweb_back/geode_objects/geode_section.py +++ b/src/opengeodeweb_back/geode_objects/geode_section.py @@ -11,11 +11,14 @@ 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: from opengeodeweb_microservice.database.data_types import GeodeModelType + from .crs_coordinates import Mesh + class GeodeSection(GeodeModel): section: og.Section @@ -125,6 +128,64 @@ 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 set_active_crs(self, crs_name: str) -> None: + # Models have no CRS manager of their own: activate the CRS 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() + crs_builder.set_active_coordinate_reference_system(crs_name) + for line in self.section.lines(): + crs_builder = builder.line_mesh_builder( + line + ).main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + for surface in self.section.surfaces(): + crs_builder = builder.surface_mesh_builder( + surface + ).main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + # Models have no CRS manager of their own: CRS live on every component mesh + meshes: list[Mesh] = [] + meshes.extend(corner.mesh() for corner in self.section.corners()) + meshes.extend(line.mesh() for line in self.section.lines()) + meshes.extend(surface.mesh() for surface in self.section.surfaces()) + return meshes + 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..700e5cd4 100644 --- a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py @@ -9,11 +9,14 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import ViewerElementsType + from .crs_coordinates import Mesh + class GeodeSolidMesh3D(GeodeVertexSet): solid_mesh: og.SolidMesh3D @@ -49,6 +52,20 @@ 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 set_active_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + return [self.solid_mesh] + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: @@ -76,5 +93,8 @@ def create_crs( self.solid_mesh, builder, crs_name, input_crs, output_crs ) + def scale_z(self, factor: float) -> None: + scale_z(self.solid_mesh, self.builder(), factor) + def polyhedron_attribute_manager(self) -> og.AttributeManager: return self.solid_mesh.polyhedron_attribute_manager() diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py index ceea9ed1..5ec48d2e 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py @@ -9,11 +9,14 @@ 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: from opengeodeweb_microservice.database.data_types import ViewerElementsType + from .crs_coordinates import Mesh + class GeodeSurfaceMesh2D(GeodeVertexSet): surface_mesh: og.SurfaceMesh2D @@ -49,6 +52,20 @@ 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 set_active_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + return [self.surface_mesh] + 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..ed804276 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py @@ -9,11 +9,14 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import ViewerElementsType + from .crs_coordinates import Mesh + class GeodeSurfaceMesh3D(GeodeVertexSet): surface_mesh: og.SurfaceMesh3D @@ -49,6 +52,20 @@ 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 set_active_crs(self, crs_name: str) -> None: + crs_builder = self.builder().main_coordinate_reference_system_manager_builder() + crs_builder.set_active_coordinate_reference_system(crs_name) + + def crs_meshes(self) -> list[Mesh]: + return [self.surface_mesh] + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: @@ -76,6 +93,9 @@ def create_crs( self.surface_mesh, builder, crs_name, input_crs, output_crs ) + def scale_z(self, factor: float) -> None: + scale_z(self.surface_mesh, self.builder(), factor) + def polygon_attribute_manager(self) -> og.AttributeManager: return self.surface_mesh.polygon_attribute_manager() 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..8484647e --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +# Standard library imports +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any + +# Third party imports +import flask + +# Local application imports +from opengeodeweb_back import geode_functions, utils_functions +from opengeodeweb_back.crs import crs_alignment, crs_assignment +from opengeodeweb_back.crs.crs_functions import ( + CRS_GEODE_OBJECTS, + CrsError, + geographic_coordinate_systems, + geographic_info, + is_projected, + known_info, + linked_objects, + load_crs_object, +) +from opengeodeweb_back.routes.crs import schemas +from opengeodeweb_back.typed_route import typed_route + +if TYPE_CHECKING: + from collections.abc import Generator + + from opengeodeweb_microservice.database.data import Data + + from opengeodeweb_back.crs.crs_functions import CrsGeodeObject + +routes = flask.Blueprint("crs", __name__, url_prefix="/crs") + + +@contextmanager +def _bad_request() -> Generator[None]: + # The CRS package reports what the data cannot follow: a request error, the data unchanged + try: + yield + except CrsError as error: + flask.abort(400, str(error)) + + +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} + + +def _saved_response(data_id: str, data: Data, geode_object: CrsGeodeObject) -> dict[str, Any]: + # The front rebuilds the local actors from the new light viewables + return _crs_response( + utils_functions.save_all_viewables_and_return_info( + geode_object, data, geode_functions.data_file_path(data_id) + ) + ) + + +@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: + with _bad_request(): + data, geode_object = load_crs_object(params.id) + linked = linked_objects(params.id, params.linked_ids) + entries = crs_assignment.assign_all( + [(params.id, data, geode_object), *linked], + params.authority, + params.code, + only_plausible=bool(params.only_plausible), + ) + response = entries[params.id] + if linked: + response["linked"] = [{"id": linked_id, **entries[linked_id]} for linked_id, _, _ in linked] + return schemas.AssignGeographicResponse.from_dict(response) + + +@typed_route(routes, schemas.convert_geographic_route) +def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeographicResponse: + with _bad_request(): + data, geode_object = load_crs_object(params.id) + linked = linked_objects(params.id, params.linked_ids) + # Every data is converted in memory before any file is written + crs_assignment.convert(geode_object, params.authority, params.code) + for _, _, linked_object in linked: + crs_assignment.convert(linked_object, params.authority, params.code) + response = _saved_response(params.id, data, geode_object) + if linked: + response["linked"] = [ + {"id": linked_id, **_saved_response(linked_id, linked_data, linked_object)} + for linked_id, linked_data, linked_object in linked + ] + return schemas.ConvertGeographicResponse.from_dict(response) + + +@typed_route(routes, schemas.align_route) +def align(params: schemas.Align) -> schemas.AlignResponse: + mode = params.mode.value if params.mode is not None else crs_alignment.SIMILARITY + with _bad_request(): + data, geode_object = load_crs_object(params.id) + info = known_info(geode_object, params.authority, params.code) + crs_alignment.check_pair_count(mode, len(params.source_points), len(params.target_points)) + # In degrees, a uniform scale would flatten Z and (lat, lon) axes would mirror the data + if mode == crs_alignment.SIMILARITY and not is_projected(params.authority, params.code): + msg = "Alignment without distortion needs a projected CRS: use the affine mode" + raise CrsError(msg) + # Linked data share the local frame of the aligned one + linked = linked_objects(params.id, params.linked_ids) + source, target, scale, rotation = crs_alignment.coordinate_systems( + mode, + crs_alignment.source_points_in_original_coordinates(geode_object, params.source_points), + crs_alignment.points(params.target_points), + ) + alignment = crs_alignment.Alignment(source, target, scale, info) + crs_alignment.apply_alignment(geode_object, alignment) + for _, _, linked_object in linked: + crs_alignment.apply_alignment(linked_object, alignment) + response = _saved_response(params.id, data, geode_object) + if scale is not None: + response.update(scale=scale, rotation=rotation) + if linked: + response["linked"] = [ + {"id": linked_id, **_saved_response(linked_id, linked_data, linked_object)} + for linked_id, linked_data, linked_object in linked + ] + return schemas.AlignResponse.from_dict(response) 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..5bb80859 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/align.json @@ -0,0 +1,171 @@ +{ + "route": "/align", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, + "mode": { + "type": "string", + "enum": [ + "similarity", + "affine" + ] + }, + "source_points": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "picked": { + "type": "boolean", + "default": false + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "target_points": { + "type": "array", + "minItems": 1, + "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 + }, + "scale": { + "type": "number" + }, + "rotation": { + "type": "number" + }, + "linked": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "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": [ + "id" + ], + "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..9fefc6f2 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/align.py @@ -0,0 +1,97 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from enum import Enum +from dataclasses import dataclass +from typing import Optional, List + + +class Mode(Enum): + AFFINE = "affine" + SIMILARITY = "similarity" + + +@dataclass +class SourcePoint(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + x: float + y: float + picked: Optional[bool] = None + + +@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] + linked_ids: Optional[List[str]] = None + mode: Optional[Mode] = None + + +@dataclass +class AlignResponseCRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class LinkedCRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class Linked(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + id: str + binary_light_viewable: Optional[str] = None + crs: Optional[LinkedCRS] = None + + +@dataclass +class AlignResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + binary_light_viewable: Optional[str] = None + crs: Optional[AlignResponseCRS] = None + linked: Optional[List[Linked]] = None + rotation: Optional[float] = None + scale: Optional[float] = 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..3fa9e3f4 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json @@ -0,0 +1,113 @@ +{ + "route": "/assign_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "only_plausible": { + "type": "boolean" + } + }, + "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 + }, + "implausible": { + "type": "boolean" + }, + "linked": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + }, + "implausible": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "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..da90f98b --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py @@ -0,0 +1,69 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional, List +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class AssignGeographic(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + linked_ids: Optional[List[str]] = None + only_plausible: Optional[bool] = None + + +@dataclass +class AssignGeographicResponseCRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class LinkedCRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class Linked(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + id: str + crs: Optional[LinkedCRS] = None + implausible: Optional[bool] = None + + +@dataclass +class AssignGeographicResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + crs: Optional[AssignGeographicResponseCRS] = None + implausible: Optional[bool] = None + linked: Optional[List[Linked]] = 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..75453eb4 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json @@ -0,0 +1,110 @@ +{ + "route": "/convert_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, + "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 + }, + "linked": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + }, + "binary_light_viewable": { + "type": "string" + } + }, + "required": [ + "id" + ], + "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..7fb0af14 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py @@ -0,0 +1,68 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional, List +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class ConvertGeographic(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + linked_ids: Optional[List[str]] = None + + +@dataclass +class ConvertGeographicResponseCRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class LinkedCRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + +@dataclass +class Linked(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + id: str + binary_light_viewable: Optional[str] = None + crs: Optional[LinkedCRS] = None + + +@dataclass +class ConvertGeographicResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + binary_light_viewable: Optional[str] = None + crs: Optional[ConvertGeographicResponseCRS] = None + linked: Optional[List[Linked]] = 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..e47639c6 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.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..7b3a2d43 --- /dev/null +++ b/tests/test_crs_routes.py @@ -0,0 +1,784 @@ +from __future__ import annotations + +# Standard library imports +import os +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Any + +# Third party imports +import opengeode as og +import pytest + +# Local application imports +from opengeodeweb_microservice.database.data import Data + +from opengeodeweb_back import geode_functions, test_utils +from opengeodeweb_back.crs import crs_alignment, crs_functions +from opengeodeweb_back.geode_objects.geode_brep import GeodeBRep +from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D + +if TYPE_CHECKING: + from flask.testing import FlaskClient + from werkzeug.test import TestResponse + +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, points: list[dict[str, float]] | None = None) -> str: + if points is None: + 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], + "mode": "affine", + **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 + + def get_similarity_data() -> test_utils.JsonData: + # Without the optional mode: every key left is required + return { + "id": point_set_id, + "source_points": [dict(point) for point in SOURCE[:2]], + "target_points": [dict(point) for point in TARGET[:2]], + **LAMBERT, + } + + test_utils.test_route_wrong_params(client, route, get_similarity_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, + "mode": "affine", + **LAMBERT, + }, + ) + assert response.status_code == 400 + + +def _align(client: FlaskClient, data_id: str, **params: object) -> TestResponse: + return client.post("/opengeodeweb_back/crs/align", json={"id": data_id, **LAMBERT, **params}) + + +def test_align_similarity_translation(client: FlaskClient) -> None: + point_set_id = _create_point_set(client) + # One pair: the data only moves, the default mode keeps its shape + response = _align( + client, point_set_id, source_points=SOURCE[:1], target_points=[{"x": 1000, "y": 2000}] + ) + assert response.status_code == 200 + assert response.get_json()["scale"] == pytest.approx(1) + assert response.get_json()["rotation"] == pytest.approx(0) + assert _first_point(client, point_set_id) == pytest.approx([1000, 2000, 5]) + + +def test_align_similarity_errors(client: FlaskClient) -> None: + point_set_id = _create_point_set(client) + # 3 pairs only in affine mode, 2 pairs never in affine mode + assert ( + _align(client, point_set_id, source_points=SOURCE, target_points=TARGET).status_code == 400 + ) + response = _align( + client, point_set_id, source_points=SOURCE[:2], target_points=TARGET[:2], mode="affine" + ) + assert response.status_code == 400 + response = _align(client, point_set_id, source_points=SOURCE[:2], target_points=TARGET[:1]) + assert response.status_code == 400 + coincident = [{"x": 1, "y": 1}, {"x": 1, "y": 1}] + response = _align(client, point_set_id, source_points=coincident, target_points=TARGET[:2]) + assert response.status_code == 400 + response = _align(client, point_set_id, source_points=SOURCE[:2], target_points=coincident) + assert response.status_code == 400 + # Degrees are no length: X/Y would be mirrored and Z flattened + response = _align( + client, point_set_id, source_points=SOURCE[:2], target_points=TARGET[:2], **WGS84 + ) + assert response.status_code == 400 + + +def _all_points(client: FlaskClient, data_id: str) -> list[list[float]]: + with client.application.app_context(): + geode_object = geode_functions.load_geode_object(data_id) + assert isinstance(geode_object, GeodePointSet3D) + point_set = geode_object.point_set + return [ + [point_set.point(vertex).value(axis) for axis in range(3)] + for vertex in range(point_set.nb_vertices()) + ] + + +def test_align_similarity(client: FlaskClient) -> None: + point_set_id = _create_point_set(client) + # (0, 0) -> (1000, 2000) and (10, 0) -> (1000, 2020): scale 2, rotation 90° + response = _align(client, point_set_id, source_points=SOURCE[:2], target_points=TARGET[:2]) + assert response.status_code == 200 + assert response.get_json()["scale"] == pytest.approx(2) + assert response.get_json()["rotation"] == pytest.approx(90) + # Z is scaled as well, around Z = 0 + assert _all_points(client, point_set_id) == [ + pytest.approx([1000, 2000, 10]), + pytest.approx([1000, 2020, 10]), + pytest.approx([980, 2000, 10]), + ] + # Re-aligning restarts from the original Z, not from the scaled one + picked = [{**point, "picked": True} for point in TARGET[:2]] + response = _align(client, point_set_id, source_points=picked, target_points=TARGET[:2]) + assert response.status_code == 200 + assert _first_point(client, point_set_id) == pytest.approx([1000, 2000, 10]) + + +def test_align_similarity_brep(client: FlaskClient) -> None: + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + original = _brep_points(client, brep_id) + response = _align(client, brep_id, source_points=SOURCE[:2], target_points=TARGET[:2]) + assert response.status_code == 200 + # Every component mesh has its Z scaled by 2 + aligned = _brep_points(client, brep_id) + assert aligned[2::3] == pytest.approx([2 * z for z in original[2::3]]) + + +def test_align_similarity_2d(client: FlaskClient) -> None: + # 2D data has no Z: only X and Y move + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet2D", "test.og_pts2d" + ).get_json()["id"] + response = _align(client, point_set_id, source_points=SOURCE[:2], target_points=TARGET[:2]) + assert response.status_code == 200 + + +def test_align_linked(client: FlaskClient) -> None: + # A model and its wells share the local frame: one request aligns both + point_set_id = _create_point_set(client) + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + pairs = {"source_points": SOURCE[:2], "target_points": TARGET[:2]} + response = _align(client, point_set_id, linked_ids=[brep_id], **pairs) + assert response.status_code == 200 + assert response.get_json()["scale"] == pytest.approx(2) + [linked] = response.get_json()["linked"] + assert linked["id"] == brep_id + assert linked["crs"] == LAMBERT + assert type(linked["binary_light_viewable"]) is str + fresh_brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()[ + "id" + ] + assert _align(client, fresh_brep_id, **pairs).status_code == 200 + assert _brep_points(client, brep_id) == pytest.approx(_brep_points(client, fresh_brep_id)) + + # Re-aligning both from points picked on the aligned data restarts from the originals + picked = [{**point, "picked": True} for point in TARGET[:2]] + other_target = [{"x": 500, "y": 700}, {"x": 500, "y": 720}] + response = _align( + client, + point_set_id, + linked_ids=[brep_id], + source_points=picked, + target_points=other_target, + ) + assert response.status_code == 200 + assert ( + _align( + client, fresh_brep_id, source_points=SOURCE[:2], target_points=other_target + ).status_code + == 200 + ) + assert _brep_points(client, brep_id) == pytest.approx(_brep_points(client, fresh_brep_id)) + + +def test_align_linked_2d(client: FlaskClient) -> None: + # 2D and 3D data each get the CRS of their own dimension + point_set_id = _create_point_set(client) + point_set_2d_id = test_utils.check_save_viewable_file( + client, "PointSet2D", "test.og_pts2d" + ).get_json()["id"] + response = _align( + client, + point_set_id, + linked_ids=[point_set_2d_id], + source_points=SOURCE[:2], + target_points=TARGET[:2], + ) + assert response.status_code == 200 + assert response.get_json()["linked"][0]["crs"] == LAMBERT + + +def test_align_linked_errors(client: FlaskClient) -> None: + point_set_id = _create_point_set(client) + pairs = {"source_points": SOURCE[:2], "target_points": TARGET[:2]} + # Every data is checked before any is written: the main one stays untouched + response = _align(client, point_set_id, linked_ids=["0" * 32], **pairs) + assert response.status_code == 404 + assert _first_point(client, point_set_id) == pytest.approx([0, 0, 5]) + response = _align(client, point_set_id, linked_ids=[point_set_id], **pairs) + assert response.status_code == 400 + + +def _crs_names(client: FlaskClient, data_id: str) -> list[str]: + with client.application.app_context(): + geode_object = geode_functions.load_geode_object(data_id) + assert isinstance(geode_object, GeodePointSet3D) + return sorted(geode_object.crs_manager().coordinate_reference_system_names()) + + +def test_realign(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/align" + shifted_target = [{"x": 500, "y": 2000}, {"x": 500, "y": 2020}, {"x": 480, "y": 2000}] + + def align(data_id: str, source: list[dict[str, Any]], target: list[dict[str, int]]) -> int: + response = client.post( + route, + json={ + "id": data_id, + "source_points": source, + "target_points": target, + "mode": "affine", + **LAMBERT, + }, + ) + return response.status_code + + point_set_id = _create_point_set(client) + assert align(point_set_id, SOURCE, TARGET) == 200 + # The points are picked again on the aligned data, where SOURCE now lies at TARGET + picked = [{**point, "picked": True} for point in TARGET] + assert align(point_set_id, picked, shifted_target) == 200 + fresh_id = _create_point_set(client) + assert align(fresh_id, SOURCE, shifted_target) == 200 + # Re-aligning restarts from the original coordinates, not from the first alignment + assert _first_point(client, point_set_id) == pytest.approx(_first_point(client, fresh_id)) + assert _crs_names(client, point_set_id) == ["EPSG:2154", "points"] + assert _attribute_count(client, point_set_id) == _attribute_count(client, fresh_id) + + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + assert align(brep_id, SOURCE, TARGET) == 200 + assert align(brep_id, picked, shifted_target) == 200 + + +def _brep_points(client: FlaskClient, data_id: str) -> list[float]: + with client.application.app_context(): + brep = geode_functions.load_geode_object(data_id) + assert isinstance(brep, GeodeBRep) + return [ + mesh.point(vertex).value(axis) + for mesh in brep.crs_meshes() + for vertex in range(mesh.nb_vertices()) + for axis in range(3) + ] + + +def test_align_after_assign(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/align" + assign_route = "/opengeodeweb_back/crs/assign_geographic" + + def align( + data_id: str, source: list[dict[str, Any]], target: list[dict[str, int]] = TARGET + ) -> int: + response = client.post( + route, + json={ + "id": data_id, + "source_points": source, + "target_points": target, + "mode": "affine", + **LAMBERT, + }, + ) + return response.status_code + + # Assigning keeps the original coordinates: re-aligning always restarts from them + point_set_id = _create_point_set(client) + client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + assert _crs_names(client, point_set_id) == ["EPSG:2154", "points"] + assert align(point_set_id, SOURCE) == 200 + fresh_id = _create_point_set(client) + assert align(fresh_id, SOURCE) == 200 + assert _first_point(client, point_set_id) == pytest.approx(_first_point(client, fresh_id)) + attribute_count = _attribute_count(client, point_set_id) + # The points are picked again on the aligned data, where SOURCE now lies at TARGET + assert align(point_set_id, [{**point, "picked": True} for point in TARGET]) == 200 + assert _first_point(client, point_set_id) == pytest.approx(_first_point(client, fresh_id)) + assert _crs_names(client, point_set_id) == ["EPSG:2154", "points"] + assert _attribute_count(client, point_set_id) == attribute_count + # Typed points are original coordinates: they are not mapped back + other_target = [{"x": 500, "y": 700}, {"x": 500, "y": 720}, {"x": 480, "y": 700}] + assert align(point_set_id, SOURCE, other_target) == 200 + other_fresh_id = _create_point_set(client) + assert align(other_fresh_id, SOURCE, other_target) == 200 + assert _first_point(client, point_set_id) == pytest.approx(_first_point(client, other_fresh_id)) + + 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}) + assert align(brep_id, SOURCE) == 200 + fresh_brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()[ + "id" + ] + assert align(fresh_brep_id, SOURCE) == 200 + assert _brep_points(client, brep_id) == pytest.approx(_brep_points(client, fresh_brep_id)) + with client.application.app_context(): + brep = geode_functions.load_geode_object(brep_id) + assert isinstance(brep, GeodeBRep) + for mesh in brep.crs_meshes(): + crs_manager = mesh.main_coordinate_reference_system_manager() + assert crs_manager.active_coordinate_reference_system_name() == "EPSG:2154" + assert sorted(crs_manager.coordinate_reference_system_names()) == ["EPSG:2154", "points"] + + +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 + # The cause given by PROJ / OpenGeode is kept in the message + description = response.get_json()["description"] + assert description.startswith("Coordinates cannot be converted to EPSG:2154: ") + assert len(description) > len("Coordinates cannot be converted to EPSG:2154: ") + + +def test_colinear_points_whatever_their_scale() -> None: + def frame(*xy: tuple[float, float]) -> list[og.Point2D]: + return [og.Point2D([x, y]) for x, y in xy] + + # Lambert-93 coordinates: the check is relative to the size of the triangle + assert crs_alignment.colinear(frame((700000, 6600000), (700010, 6600010), (700020, 6600020))) + assert not crs_alignment.colinear( + frame((700000, 6600000), (700010, 6600000), (700000, 6600010)) + ) + assert crs_alignment.colinear(frame((0, 0), (0, 0), (1, 1))) + assert not crs_alignment.colinear(frame((0, 0), (1e-3, 0), (0, 1e-3))) + + +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() + + +def test_assign_geographic_plausibility(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/assign_geographic" + for point, implausible in ( + ({"x": 650000, "y": 6860000, "z": 0}, False), + # Local coordinates read as Lambert-93 land far outside the Earth + ({"x": 6710278, "y": 48021667, "z": 0}, True), + ({"x": 1e9, "y": 1e9, "z": 0}, True), + # Lands on the Earth, but far from France where Lambert-93 is used + ({"x": 1000, "y": 1000, "z": 0}, True), + ): + point_set_id = _create_point_set(client, [point]) + response = client.post(route, json={"id": point_set_id, **LAMBERT}) + # A warning only: the CRS is assigned anyway + assert response.status_code == 200 + assert response.get_json()["crs"] == LAMBERT + assert response.get_json()["implausible"] is implausible + + +def _active_crs(client: FlaskClient, data_id: str) -> dict[str, str] | None: + return ( + client.post("/opengeodeweb_back/crs/active", json={"id": data_id}) + .get_json() + .get("crs_info") + ) + + +def test_assign_only_plausible(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/assign_geographic" + # Local coordinates: implausible in Lambert-93 + local_id = _create_point_set(client, [{"x": 1000, "y": 1000, "z": 0}]) + plausible_id = _create_point_set(client, [{"x": 650000, "y": 6860000, "z": 0}]) + response = client.post( + route, + json={"id": local_id, "linked_ids": [plausible_id], "only_plausible": True, **LAMBERT}, + ) + assert response.status_code == 200 + body = response.get_json() + assert body["implausible"] is True + assert "crs" not in body + [linked] = body["linked"] + assert linked == {"id": plausible_id, "crs": LAMBERT, "implausible": False} + # The refused data was not written + assert _active_crs(client, local_id) is None + assert _active_crs(client, plausible_id) == LAMBERT + + +def test_assign_linked(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/assign_geographic" + point_set_id = _create_point_set(client) + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + response = client.post(route, json={"id": point_set_id, "linked_ids": [brep_id], **LAMBERT}) + assert response.status_code == 200 + [linked] = response.get_json()["linked"] + assert linked["id"] == brep_id + assert linked["crs"] == LAMBERT + assert isinstance(linked["implausible"], bool) + assert _active_crs(client, brep_id) == LAMBERT + assert _active_crs(client, point_set_id) == LAMBERT + + +def test_convert_linked(client: FlaskClient) -> None: + assign_route = "/opengeodeweb_back/crs/assign_geographic" + route = "/opengeodeweb_back/crs/convert_geographic" + first_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + second_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + client.post(assign_route, json={"id": first_id, "linked_ids": [second_id], **LAMBERT}) + response = client.post(route, json={"id": first_id, "linked_ids": [second_id], **UTM}) + assert response.status_code == 200 + [linked] = response.get_json()["linked"] + assert linked["id"] == second_id + assert linked["crs"] == UTM + assert type(linked["binary_light_viewable"]) is str + assert _first_point(client, second_id) == pytest.approx(_first_point(client, first_id)) + + +def test_assign_and_convert_linked_errors(client: FlaskClient) -> None: + assign_route = "/opengeodeweb_back/crs/assign_geographic" + convert_route = "/opengeodeweb_back/crs/convert_geographic" + point_set_id = _create_point_set(client) + # Every data is checked before any is written: the main one stays untouched + response = client.post( + assign_route, json={"id": point_set_id, "linked_ids": ["0" * 32], **LAMBERT} + ) + assert response.status_code == 404 + assert _active_crs(client, point_set_id) is None + response = client.post( + assign_route, json={"id": point_set_id, "linked_ids": [point_set_id], **LAMBERT} + ) + assert response.status_code == 400 + # A linked data without CRS cannot be converted: nothing is converted + other_id = _create_point_set(client) + client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + response = client.post( + convert_route, json={"id": point_set_id, "linked_ids": [other_id], **UTM} + ) + assert response.status_code == 400 + assert _active_crs(client, point_set_id) == LAMBERT 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