From b40e8e30711c2b29f2ea1e4ede1a05aca9ffc631 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Thu, 22 Oct 2026 14:43:02 +0200 Subject: [PATCH 01/17] share check_upload and check_save_viewalble [skip ci] --- src/opengeodeweb_back/test_utils.py | 41 +++++++++++++++++++ tests/test_routes.py | 62 +++++------------------------ 2 files changed, 52 insertions(+), 51 deletions(-) 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/tests/test_routes.py b/tests/test_routes.py index 594a9343..6510cc6c 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: @@ -213,7 +203,7 @@ def get_full_data() -> test_utils.JsonData: 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 +215,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}) @@ -260,41 +252,7 @@ def get_full_data() -> test_utils.JsonData: 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 + test_utils.check_save_viewable_file(client, "BRep", "corbi.og_brep") def test_texture_coordinates(client: FlaskClient) -> None: @@ -523,7 +481,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 +637,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 +990,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 From 0304b1e3e633202eeef3bb908c7ce35c5b3e38e1 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Thu, 22 Oct 2026 14:46:03 +0200 Subject: [PATCH 02/17] add crs_manager to CRS-capable wrappers [skip ci] --- src/opengeodeweb_back/geode_objects/geode_brep.py | 13 +++++++++++++ .../geode_objects/geode_edged_curve2d.py | 3 +++ .../geode_objects/geode_edged_curve3d.py | 3 +++ .../geode_objects/geode_point_set2d.py | 3 +++ .../geode_objects/geode_point_set3d.py | 3 +++ .../geode_objects/geode_section.py | 12 ++++++++++++ .../geode_objects/geode_solid_mesh3d.py | 3 +++ .../geode_objects/geode_surface_mesh2d.py | 3 +++ .../geode_objects/geode_surface_mesh3d.py | 3 +++ 9 files changed, 46 insertions(+) diff --git a/src/opengeodeweb_back/geode_objects/geode_brep.py b/src/opengeodeweb_back/geode_objects/geode_brep.py index 21d5e172..f0fc9196 100644 --- a/src/opengeodeweb_back/geode_objects/geode_brep.py +++ b/src/opengeodeweb_back/geode_objects/geode_brep.py @@ -136,6 +136,19 @@ 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 assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py index 6a6cd622..3bc50233 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py @@ -103,6 +103,9 @@ 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 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..18aade0d 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py @@ -103,6 +103,9 @@ 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 assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py index 7a423348..a770c9d7 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py @@ -101,6 +101,9 @@ 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 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..da49c938 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py @@ -101,6 +101,9 @@ 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 assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_section.py b/src/opengeodeweb_back/geode_objects/geode_section.py index c6b7a403..d4e84ce1 100644 --- a/src/opengeodeweb_back/geode_objects/geode_section.py +++ b/src/opengeodeweb_back/geode_objects/geode_section.py @@ -125,6 +125,18 @@ 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 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..6b978031 100644 --- a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py @@ -49,6 +49,9 @@ 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 assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py index ceea9ed1..eb1d62f9 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py @@ -49,6 +49,9 @@ 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 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..1f172af3 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py @@ -49,6 +49,9 @@ 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 assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: From c300513326048a5255566cfcf3a0c1474158e8d8 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Thu, 22 Oct 2026 14:47:22 +0200 Subject: [PATCH 03/17] feat(crs): CRS blueprint with geographic_list, active and assign_geographic --- opengeodeweb_back_schemas.json | 193 +++-- opengeodeweb_back_typed_schemas.d.ts | 731 ++++++++++-------- pyproject.toml | 1 + src/opengeodeweb_back/app.py | 6 + .../routes/blueprint_routes.py | 20 - src/opengeodeweb_back/routes/crs/__init__.py | 0 .../routes/crs/blueprint_crs.py | 84 ++ .../routes/crs/schemas/__init__.py | 3 + .../routes/crs/schemas/active.json | 50 ++ .../routes/crs/schemas/active.py | 42 + .../routes/crs/schemas/assign_geographic.json | 39 + .../routes/crs/schemas/assign_geographic.py | 33 + .../schemas/geographic_list.json} | 2 +- .../schemas/geographic_list.py} | 12 +- .../routes/schemas/__init__.py | 1 - tests/test_crs_routes.py | 94 +++ tests/test_routes.py | 19 - 17 files changed, 908 insertions(+), 422 deletions(-) create mode 100644 src/opengeodeweb_back/routes/crs/__init__.py create mode 100644 src/opengeodeweb_back/routes/crs/blueprint_crs.py create mode 100644 src/opengeodeweb_back/routes/crs/schemas/__init__.py create mode 100644 src/opengeodeweb_back/routes/crs/schemas/active.json create mode 100644 src/opengeodeweb_back/routes/crs/schemas/active.py create mode 100644 src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json create mode 100644 src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py rename src/opengeodeweb_back/routes/{schemas/geographic_coordinate_systems.json => crs/schemas/geographic_list.json} (95%) rename src/opengeodeweb_back/routes/{schemas/geographic_coordinate_systems.py => crs/schemas/geographic_list.py} (64%) create mode 100644 tests/test_crs_routes.py diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index 6f0d757d..a440388c 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -572,6 +572,149 @@ } } }, + "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 + } + }, + "assign_geographic": { + "$id": "opengeodeweb_back/crs/assign_geographic", + "route": "/assign_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": {}, + "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", @@ -1819,56 +1962,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..e087d88e 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -2,565 +2,584 @@ import type json from "./opengeodeweb_back_schemas.json"; export interface CreatePolygonalSurfaceParams { - name: string; - points: PolygonalSurfacePoint[]; - polygons: Array; + name: string; + points: PolygonalSurfacePoint[]; + polygons: Array; } export interface PolygonalSurfacePoint { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreatePolygonalSurfaceResponse { - binary_light_viewable?: string; - collection_components?: CreatePolygonalSurfaceResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreatePolygonalSurfaceResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreatePolygonalSurfaceResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreatePolygonalSurfaceResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreatePolygonalSurfaceResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreatePolygonalSurfaceResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CreatePointSetParams { - name: string; - points: Point[]; + name: string; + points: Point[]; } export interface Point { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreatePointSetResponse { - binary_light_viewable?: string; - collection_components?: CreatePointSetResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreatePointSetResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreatePointSetResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreatePointSetResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreatePointSetResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreatePointSetResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CreateEdgedCurveParams { - edges: Array; - name: string; - points: EdgedCurvePoint[]; + edges: Array; + name: string; + points: EdgedCurvePoint[]; } export interface EdgedCurvePoint { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreateEdgedCurveResponse { - binary_light_viewable?: string; - collection_components?: CreateEdgedCurveResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreateEdgedCurveResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreateEdgedCurveResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreateEdgedCurveResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreateEdgedCurveResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreateEdgedCurveResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + 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 CRSAssignGeographicParams { + authority: string; + code: string; + id: string; + name: string; +} + +export interface CRSAssignGeographicResponse {} + +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; + attribute_name: string; + id: string; } export interface VertexAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface VertexAttributeNamesParams { - id: string; + id: string; } export interface VertexAttributeNamesResponse { - attributes: VertexAttributeNamesResponseAttribute[]; + attributes: VertexAttributeNamesResponseAttribute[]; } export interface VertexAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ValidateParams { - id: string; + id: string; } export interface ValidateResponse { - is_valid: boolean; - issues: string[]; - nb_issues: number; + is_valid: boolean; + issues: string[]; + nb_issues: number; } export interface UploadFileParams { - filename?: string; + filename?: string; } export interface UploadFileResponse { - message: string; + message: string; } export interface TimeSeriesMissingFilesParams { - filename: string; + filename: string; } export interface TimeSeriesMissingFilesResponse { - additional_files: string[]; - has_missing_files: boolean; - mandatory_files: string[]; + additional_files: string[]; + has_missing_files: boolean; + mandatory_files: string[]; } export interface TimeSeriesAllowedObjectsParams { - filename: string; + filename: string; } export interface TimeSeriesAllowedObjectsResponse { - allowed_objects: string[]; + allowed_objects: string[]; } export interface TextureCoordinatesParams { - id: string; + id: string; } export interface TextureCoordinatesResponse { - texture_coordinates: string[]; + texture_coordinates: string[]; } export interface SaveViewableFileParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface SaveViewableFileResponse { - binary_light_viewable?: string; - collection_components?: SaveViewableFileResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: SaveViewableFileResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: SaveViewableFileResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: SaveViewableFileResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface SaveViewableFileResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface SaveViewableFileResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface PolyhedronAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface PolyhedronAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface PolyhedronAttributeNamesParams { - id: string; + id: string; } export interface PolyhedronAttributeNamesResponse { - attributes: PolyhedronAttributeNamesResponseAttribute[]; + attributes: PolyhedronAttributeNamesResponseAttribute[]; } export interface PolyhedronAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface PolygonAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface PolygonAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface PolygonAttributeNamesParams { - id: string; + id: string; } export interface PolygonAttributeNamesResponse { - attributes: PolygonAttributeNamesResponseAttribute[]; + attributes: PolygonAttributeNamesResponseAttribute[]; } export interface PolygonAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } -export interface PingParams { -} +export interface PingParams {} export interface PingResponse { - message: string; + message: string; } export interface ModelComponentVertexAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentVertexAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentVertexAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentVertexAttributeNamesResponse { - attributes: ModelComponentVertexAttributeNamesResponseAttribute[]; + attributes: ModelComponentVertexAttributeNamesResponseAttribute[]; } export interface ModelComponentVertexAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentPolyhedronAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolyhedronAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentPolyhedronAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolyhedronAttributeNamesResponse { - attributes: ModelComponentPolyhedronAttributeNamesResponseAttribute[]; + attributes: ModelComponentPolyhedronAttributeNamesResponseAttribute[]; } export interface ModelComponentPolyhedronAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentPolygonAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolygonAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentPolygonAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolygonAttributeNamesResponse { - attributes: ModelComponentPolygonAttributeNamesResponseAttribute[]; + attributes: ModelComponentPolygonAttributeNamesResponseAttribute[]; } export interface ModelComponentPolygonAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentEdgeAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentEdgeAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentEdgeAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentEdgeAttributeNamesResponse { - attributes: ModelComponentEdgeAttributeNamesResponseAttribute[]; + attributes: ModelComponentEdgeAttributeNamesResponseAttribute[]; } export interface ModelComponentEdgeAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } -export interface KillParams { -} +export interface KillParams {} export interface KillResponse { - message: string; + message: string; } -export interface ImportProjectParams { -} +export interface ImportProjectParams {} 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; + snapshot: { [key: string]: any }; } export interface GeodeObjectsAndOutputExtensionsParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface GeodeObjectsAndOutputExtensionsResponse { - geode_objects_and_output_extensions: { [key: string]: { [key: string]: boolean } }; + geode_objects_and_output_extensions: { [key: string]: { [key: string]: boolean } }; } export interface GeodeObjectInheritanceParams { - geode_object_type: string; + geode_object_type: string; } export interface GeodeObjectInheritanceResponse { - children: string[]; - parents: string[]; + children: string[]; + parents: string[]; } export interface ExportProjectParams { - filename: string; - snapshot: { [key: string]: any }; + filename: string; + snapshot: { [key: string]: any }; } export interface EdgeAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface EdgeAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface EdgeAttributeNamesParams { - id: string; + id: string; } export interface EdgeAttributeNamesResponse { - attributes: EdgeAttributeNamesResponseAttribute[]; + attributes: EdgeAttributeNamesResponseAttribute[]; } export interface EdgeAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface DataMissingFilesParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface DataMissingFilesResponse { - additional_files: string[]; - has_missing_files: boolean; - mandatory_files: string[]; + additional_files: string[]; + has_missing_files: boolean; + mandatory_files: string[]; } export interface CellAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface CellAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface CellAttributeNamesParams { - id: string; + id: string; } export interface CellAttributeNamesResponse { - attributes: CellAttributeNamesResponseAttribute[]; + attributes: CellAttributeNamesResponseAttribute[]; } export interface CellAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ApplyTimeSeriesParams { - filename: string; - id: string; + filename: string; + id: string; } -export interface ApplyTimeSeriesResponse { -} +export interface ApplyTimeSeriesResponse {} export interface AllowedObjectsParams { - filename: string; + filename: string; } export interface AllowedObjectsResponse { - allowed_objects: { [key: string]: AllowedObject }; + allowed_objects: { [key: string]: AllowedObject }; } export interface AllowedObject { - is_loadable: number; - object_priority: number; + is_loadable: number; + object_priority: number; } -export interface AllowedFilesParams { -} +export interface AllowedFilesParams {} export interface AllowedFilesResponse { - data_extensions: string[]; - time_series_extensions: string[]; + data_extensions: string[]; + time_series_extensions: string[]; } export interface ErrorResponse { - code: number; - description: string; - name: string; + code: number; + description: string; + name: string; } // `__params` and `__response` only exist at type level, to infer request/response types from a schema. @@ -573,45 +592,107 @@ export type TypedSchema = { export interface Schemas { readonly opengeodeweb_back: { readonly create: { - readonly polygonal_surface: (typeof json)["opengeodeweb_back"]["create"]["polygonal_surface"] & TypedSchema; - readonly point_set: (typeof json)["opengeodeweb_back"]["create"]["point_set"] & TypedSchema; - readonly edged_curve: (typeof json)["opengeodeweb_back"]["create"]["edged_curve"] & TypedSchema; + readonly polygonal_surface: (typeof json)["opengeodeweb_back"]["create"]["polygonal_surface"] & + TypedSchema; + 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 assign_geographic: (typeof json)["opengeodeweb_back"]["crs"]["assign_geographic"] & + 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; - readonly upload_file: (typeof json)["opengeodeweb_back"]["upload_file"] & TypedSchema; - readonly time_series_missing_files: (typeof json)["opengeodeweb_back"]["time_series_missing_files"] & TypedSchema; - readonly time_series_allowed_objects: (typeof json)["opengeodeweb_back"]["time_series_allowed_objects"] & TypedSchema; - readonly texture_coordinates: (typeof json)["opengeodeweb_back"]["texture_coordinates"] & TypedSchema; - readonly save_viewable_file: (typeof json)["opengeodeweb_back"]["save_viewable_file"] & TypedSchema; - readonly polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_range"] & TypedSchema; - readonly polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_names"] & TypedSchema; - readonly polygon_attribute_range: (typeof json)["opengeodeweb_back"]["polygon_attribute_range"] & TypedSchema; - readonly polygon_attribute_names: (typeof json)["opengeodeweb_back"]["polygon_attribute_names"] & TypedSchema; - readonly ping: (typeof json)["opengeodeweb_back"]["ping"] & TypedSchema; - readonly model_component_vertex_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_range"] & TypedSchema; - readonly model_component_vertex_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_names"] & TypedSchema; - readonly model_component_polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_range"] & TypedSchema; - readonly model_component_polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_names"] & TypedSchema; - readonly model_component_polygon_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_range"] & TypedSchema; - readonly model_component_polygon_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_names"] & TypedSchema; - readonly model_component_edge_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_edge_attribute_range"] & TypedSchema; - 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; - readonly edge_attribute_range: (typeof json)["opengeodeweb_back"]["edge_attribute_range"] & TypedSchema; - readonly edge_attribute_names: (typeof json)["opengeodeweb_back"]["edge_attribute_names"] & TypedSchema; - readonly data_missing_files: (typeof json)["opengeodeweb_back"]["data_missing_files"] & TypedSchema; - readonly cell_attribute_range: (typeof json)["opengeodeweb_back"]["cell_attribute_range"] & TypedSchema; - readonly cell_attribute_names: (typeof json)["opengeodeweb_back"]["cell_attribute_names"] & TypedSchema; - readonly apply_time_series: (typeof json)["opengeodeweb_back"]["apply_time_series"] & TypedSchema; - readonly allowed_objects: (typeof json)["opengeodeweb_back"]["allowed_objects"] & TypedSchema; - readonly allowed_files: (typeof json)["opengeodeweb_back"]["allowed_files"] & 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; + readonly upload_file: (typeof json)["opengeodeweb_back"]["upload_file"] & + TypedSchema; + readonly time_series_missing_files: (typeof json)["opengeodeweb_back"]["time_series_missing_files"] & + TypedSchema; + readonly time_series_allowed_objects: (typeof json)["opengeodeweb_back"]["time_series_allowed_objects"] & + TypedSchema; + readonly texture_coordinates: (typeof json)["opengeodeweb_back"]["texture_coordinates"] & + TypedSchema; + readonly save_viewable_file: (typeof json)["opengeodeweb_back"]["save_viewable_file"] & + TypedSchema; + readonly polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_range"] & + TypedSchema; + readonly polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_names"] & + TypedSchema; + readonly polygon_attribute_range: (typeof json)["opengeodeweb_back"]["polygon_attribute_range"] & + TypedSchema; + readonly polygon_attribute_names: (typeof json)["opengeodeweb_back"]["polygon_attribute_names"] & + TypedSchema; + readonly ping: (typeof json)["opengeodeweb_back"]["ping"] & + TypedSchema; + readonly model_component_vertex_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_range"] & + TypedSchema< + ModelComponentVertexAttributeRangeParams, + ModelComponentVertexAttributeRangeResponse + >; + readonly model_component_vertex_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_names"] & + TypedSchema< + ModelComponentVertexAttributeNamesParams, + ModelComponentVertexAttributeNamesResponse + >; + readonly model_component_polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_range"] & + TypedSchema< + ModelComponentPolyhedronAttributeRangeParams, + ModelComponentPolyhedronAttributeRangeResponse + >; + readonly model_component_polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_names"] & + TypedSchema< + ModelComponentPolyhedronAttributeNamesParams, + ModelComponentPolyhedronAttributeNamesResponse + >; + readonly model_component_polygon_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_range"] & + TypedSchema< + ModelComponentPolygonAttributeRangeParams, + ModelComponentPolygonAttributeRangeResponse + >; + readonly model_component_polygon_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_names"] & + TypedSchema< + ModelComponentPolygonAttributeNamesParams, + ModelComponentPolygonAttributeNamesResponse + >; + readonly model_component_edge_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_edge_attribute_range"] & + TypedSchema; + 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 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; + readonly edge_attribute_range: (typeof json)["opengeodeweb_back"]["edge_attribute_range"] & + TypedSchema; + readonly edge_attribute_names: (typeof json)["opengeodeweb_back"]["edge_attribute_names"] & + TypedSchema; + readonly data_missing_files: (typeof json)["opengeodeweb_back"]["data_missing_files"] & + TypedSchema; + readonly cell_attribute_range: (typeof json)["opengeodeweb_back"]["cell_attribute_range"] & + TypedSchema; + readonly cell_attribute_names: (typeof json)["opengeodeweb_back"]["cell_attribute_names"] & + TypedSchema; + readonly apply_time_series: (typeof json)["opengeodeweb_back"]["apply_time_series"] & + TypedSchema; + readonly allowed_objects: (typeof json)["opengeodeweb_back"]["allowed_objects"] & + TypedSchema; + readonly allowed_files: (typeof json)["opengeodeweb_back"]["allowed_files"] & + 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/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/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/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..99c9288c --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -0,0 +1,84 @@ +# Standard library imports + +# Third party imports +import flask +import opengeode_geosciences as og_geosciences + +# Local application imports +from opengeodeweb_back import geode_functions, utils_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 +from opengeodeweb_back.routes.crs import schemas +from opengeodeweb_back.typed_route import typed_route + +routes = flask.Blueprint("crs", __name__, url_prefix="/crs") + +CRS_GEODE_OBJECTS = ( + GeodeBRep, + GeodeSection, + GeodePointSet2D, + GeodePointSet3D, + GeodeEdgedCurve2D, + GeodeEdgedCurve3D, + GeodeSurfaceMesh2D, + GeodeSurfaceMesh3D, + GeodeSolidMesh3D, +) + + +@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) + infos = ( + og_geosciences.GeographicCoordinateSystem3D.geographic_coordinate_systems() + if geode_object.is_3d() + else og_geosciences.GeographicCoordinateSystem2D.geographic_coordinate_systems() + ) + crs_list = [ + schemas.geographic_list.CRSList(name=info.name, code=info.code, authority=info.authority) + for info in infos + ] + 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() + crs = crs_manager.active_coordinate_reference_system() + crs_info = None + if isinstance( + crs, + og_geosciences.GeographicCoordinateSystem2D | og_geosciences.GeographicCoordinateSystem3D, + ): + info = crs.info() + crs_info = schemas.active.CRSInfo(authority=info.authority, code=info.code, name=info.name) + return schemas.ActiveResponse( + crs_name=crs_manager.active_coordinate_reference_system_name(), + crs_info=crs_info, + ) + + +@typed_route(routes, schemas.assign_geographic_route) +def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeographicResponse: + data = geode_functions.get_data_info(params.id) + geode_object = geode_functions.geode_object_from_string(data.geode_object).load( + geode_functions.data_file_path(params.id, data.native_file) + ) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + flask.abort(400, f"{params.id} does not support coordinate reference systems") + info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) + geode_object.assign_crs(info.authority_code(), info) + utils_functions.save_all_viewables_and_return_info( + geode_object, data, geode_functions.data_file_path(params.id) + ) + return schemas.AssignGeographicResponse() 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..28bc91e2 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/__init__.py @@ -0,0 +1,3 @@ +from .geographic_list import * +from .assign_geographic 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/assign_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json new file mode 100644 index 00000000..4052b452 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json @@ -0,0 +1,39 @@ +{ + "route": "/assign_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": {}, + "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..42b5590f --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py @@ -0,0 +1,33 @@ +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 dataclasses import dataclass + + +@dataclass +class AssignGeographic(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + + +@dataclass +class AssignGeographicResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + pass + + +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/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/tests/test_crs_routes.py b/tests/test_crs_routes.py new file mode 100644 index 00000000..2c5ce4d0 --- /dev/null +++ b/tests/test_crs_routes.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +# Standard library imports +from typing import TYPE_CHECKING + +# Third party imports +# Local application imports +from opengeodeweb_back import test_utils + +if TYPE_CHECKING: + from flask.testing import FlaskClient + +LAMBERT = {"authority": "EPSG", "code": "2154", "name": "RGF93 v1 / Lambert-93"} +UTM = {"authority": "EPSG", "code": "32631", "name": "WGS 84 / UTM zone 31N"} + + +def test_geographic_list(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/geographic_list" + + def get_full_data() -> test_utils.JsonData: + return { + "geode_object_type": "BRep", + } + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + crs_list = response.get_json()["crs_list"] + assert type(crs_list) is list + for crs in crs_list: + assert type(crs) is dict + + # Test all params + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_active(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/active" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + + def get_full_data() -> test_utils.JsonData: + return {"id": point_set_id} + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + assert response.get_json()["crs_name"] == "points" + assert "crs_info" not in response.get_json() + + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_assign_geographic(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/assign_geographic" + active_route = "/opengeodeweb_back/crs/active" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + + def get_full_data() -> test_utils.JsonData: + return {"id": point_set_id, **LAMBERT} + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + 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 diff --git a/tests/test_routes.py b/tests/test_routes.py index 6510cc6c..aa35949e 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -183,25 +183,6 @@ 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 = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep") assert response_save.status_code == 200 From a69bb7a1a02ab862840c502b0d54022bfaf87574 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Thu, 22 Oct 2026 16:37:27 +0200 Subject: [PATCH 04/17] Add delete_crs to CRS-capable wrappers [skip ci] --- .../geode_objects/geode_brep.py | 24 +++++++++++++++++++ .../geode_objects/geode_edged_curve2d.py | 4 ++++ .../geode_objects/geode_edged_curve3d.py | 4 ++++ .../geode_objects/geode_point_set2d.py | 4 ++++ .../geode_objects/geode_point_set3d.py | 4 ++++ .../geode_objects/geode_section.py | 19 +++++++++++++++ .../geode_objects/geode_solid_mesh3d.py | 4 ++++ .../geode_objects/geode_surface_mesh2d.py | 4 ++++ .../geode_objects/geode_surface_mesh3d.py | 4 ++++ 9 files changed, 71 insertions(+) diff --git a/src/opengeodeweb_back/geode_objects/geode_brep.py b/src/opengeodeweb_back/geode_objects/geode_brep.py index f0fc9196..f6656c4e 100644 --- a/src/opengeodeweb_back/geode_objects/geode_brep.py +++ b/src/opengeodeweb_back/geode_objects/geode_brep.py @@ -149,6 +149,30 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: 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() + crs_builder.delete_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.delete_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.delete_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.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py index 3bc50233..68406d27 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py @@ -106,6 +106,10 @@ def validate(self) -> og_inspector.ObjectValidity: 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() + crs_builder.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py index 18aade0d..9b7914d4 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py @@ -106,6 +106,10 @@ def validate(self) -> og_inspector.ObjectValidity: 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() + crs_builder.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py index a770c9d7..96203af1 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py @@ -104,6 +104,10 @@ def validate(self) -> og_inspector.ObjectValidity: 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() + crs_builder.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py index da49c938..56820126 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py @@ -104,6 +104,10 @@ def validate(self) -> og_inspector.ObjectValidity: 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() + crs_builder.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_section.py b/src/opengeodeweb_back/geode_objects/geode_section.py index d4e84ce1..24d372f5 100644 --- a/src/opengeodeweb_back/geode_objects/geode_section.py +++ b/src/opengeodeweb_back/geode_objects/geode_section.py @@ -137,6 +137,25 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: 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() + crs_builder.delete_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.delete_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.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py index 6b978031..eb872b21 100644 --- a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py @@ -52,6 +52,10 @@ def validate(self) -> og_inspector.ObjectValidity: 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() + crs_builder.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py index eb1d62f9..3f46bfd0 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py @@ -52,6 +52,10 @@ def validate(self) -> og_inspector.ObjectValidity: 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() + crs_builder.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py index 1f172af3..3fe48a77 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py @@ -52,6 +52,10 @@ def validate(self) -> og_inspector.ObjectValidity: 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() + crs_builder.delete_coordinate_reference_system(crs_name) + def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo ) -> None: From 723e2dca6d1d068125987549b60c72b6fbb9f243 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Thu, 22 Oct 2026 16:37:53 +0200 Subject: [PATCH 05/17] feat(crs): convert_geographic and align routes --- opengeodeweb_back_schemas.json | 132 ++++++++++++++ opengeodeweb_back_typed_schemas.d.ts | 38 ++++ .../routes/crs/blueprint_crs.py | 107 ++++++++++- .../routes/crs/schemas/__init__.py | 2 + .../routes/crs/schemas/align.json | 87 +++++++++ .../routes/crs/schemas/align.py | 55 ++++++ .../crs/schemas/convert_geographic.json | 43 +++++ .../routes/crs/schemas/convert_geographic.py | 34 ++++ tests/test_crs_routes.py | 171 +++++++++++++++++- 9 files changed, 660 insertions(+), 9 deletions(-) create mode 100644 src/opengeodeweb_back/routes/crs/schemas/align.json create mode 100644 src/opengeodeweb_back/routes/crs/schemas/align.py create mode 100644 src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json create mode 100644 src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index a440388c..aecbe7d6 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -623,6 +623,50 @@ "additionalProperties": false } }, + "convert_geographic": { + "$id": "opengeodeweb_back/crs/convert_geographic", + "route": "/convert_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + } + }, "assign_geographic": { "$id": "opengeodeweb_back/crs/assign_geographic", "route": "/assign_geographic", @@ -663,6 +707,94 @@ "additionalProperties": false } }, + "align": { + "$id": "opengeodeweb_back/crs/align", + "route": "/align", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "source_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "target_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "source_points", + "target_points", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + } + }, "active": { "$id": "opengeodeweb_back/crs/active", "route": "/active", diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index e087d88e..4337f9f3 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -149,6 +149,17 @@ export interface CRSList { name: string; } +export interface CRSConvertGeographicParams { + authority: string; + code: string; + id: string; + name: string; +} + +export interface CRSConvertGeographicResponse { + binary_light_viewable?: string; +} + export interface CRSAssignGeographicParams { authority: string; code: string; @@ -158,6 +169,29 @@ export interface CRSAssignGeographicParams { export interface CRSAssignGeographicResponse {} +export interface CRSAlignParams { + authority: string; + code: string; + id: string; + name: string; + source_points: SourcePoint[]; + target_points: TargetPoint[]; +} + +export interface SourcePoint { + x: number; + y: number; +} + +export interface TargetPoint { + x: number; + y: number; +} + +export interface CRSAlignResponse { + binary_light_viewable?: string; +} + export interface CRSActiveParams { id: string; } @@ -602,8 +636,12 @@ export interface Schemas { 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; }; diff --git a/src/opengeodeweb_back/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index 99c9288c..e0d87f05 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -2,6 +2,7 @@ # Third party imports import flask +import opengeode as og import opengeode_geosciences as og_geosciences # Local application imports @@ -33,6 +34,39 @@ ) +def _prepare_crs_name( + geode_object: GeodeBRep + | GeodeSection + | GeodePointSet2D + | GeodePointSet3D + | GeodeEdgedCurve2D + | GeodeEdgedCurve3D + | GeodeSurfaceMesh2D + | GeodeSurfaceMesh3D + | GeodeSolidMesh3D, + crs_name: str, +) -> None: + crs_manager = geode_object.crs_manager() + info = _geographic_info(crs_manager) + if info is not None and info.authority_code() == crs_name: + flask.abort(400, f"{crs_name} is already the active CRS") + # A registered inactive CRS may hold coordinates derived from an outdated label: recompute it + if crs_manager.coordinate_reference_system_exists(crs_name): + geode_object.delete_crs(crs_name) + + +def _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 + + @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) @@ -54,14 +88,12 @@ def active(params: schemas.Active) -> schemas.ActiveResponse: 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() - crs = crs_manager.active_coordinate_reference_system() - crs_info = None - if isinstance( - crs, - og_geosciences.GeographicCoordinateSystem2D | og_geosciences.GeographicCoordinateSystem3D, - ): - info = crs.info() - crs_info = schemas.active.CRSInfo(authority=info.authority, code=info.code, name=info.name) + 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, @@ -77,8 +109,67 @@ def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeograp if not isinstance(geode_object, CRS_GEODE_OBJECTS): flask.abort(400, f"{params.id} does not support coordinate reference systems") info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) + _prepare_crs_name(geode_object, info.authority_code()) geode_object.assign_crs(info.authority_code(), info) utils_functions.save_all_viewables_and_return_info( geode_object, data, geode_functions.data_file_path(params.id) ) return schemas.AssignGeographicResponse() + + +@typed_route(routes, schemas.convert_geographic_route) +def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeographicResponse: + data = geode_functions.get_data_info(params.id) + geode_object = geode_functions.geode_object_from_string(data.geode_object).load( + geode_functions.data_file_path(params.id, data.native_file) + ) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + flask.abort(400, f"{params.id} does not support coordinate reference systems") + crs_manager = geode_object.crs_manager() + if _geographic_info(crs_manager) is None: + flask.abort(400, "Assign a geographic CRS before converting") + info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) + _prepare_crs_name(geode_object, info.authority_code()) + try: + geode_object.convert_crs(info.authority_code(), info) + except RuntimeError: # PROJ rejects coordinates outside the source or target CRS domain + flask.abort(400, f"Coordinates cannot be converted to {info.authority_code()}") + saved = utils_functions.save_all_viewables_and_return_info( + geode_object, data, geode_functions.data_file_path(params.id) + ) + # The front rebuilds its local actor from the new light viewable + return schemas.ConvertGeographicResponse( + binary_light_viewable=saved.get("binary_light_viewable") + ) + + +def _coordinate_system( + points: list[schemas.SourcePoint] | list[schemas.TargetPoint], +) -> og.CoordinateSystem2D: + origin, first, second = (og.Point2D([point.x, point.y]) for point in points) + return og.CoordinateSystem2D(origin, [first, second]) + + +@typed_route(routes, schemas.align_route) +def align(params: schemas.Align) -> schemas.AlignResponse: + data = geode_functions.get_data_info(params.id) + geode_object = geode_functions.geode_object_from_string(data.geode_object).load( + geode_functions.data_file_path(params.id, data.native_file) + ) + if not isinstance(geode_object, CRS_GEODE_OBJECTS): + flask.abort(400, f"{params.id} does not support coordinate reference systems") + if _geographic_info(geode_object.crs_manager()) is not None: + flask.abort(400, "Data already has a geographic CRS, use convert") + try: + source = _coordinate_system(params.source_points) + target = _coordinate_system(params.target_points) + except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points + flask.abort(400, "Points must not be colinear") + geode_object.create_crs("aligned", source, target) + info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) + geode_object.assign_crs(info.authority_code(), info) + saved = utils_functions.save_all_viewables_and_return_info( + geode_object, data, geode_functions.data_file_path(params.id) + ) + # The front rebuilds its local actor from the new light viewable + return schemas.AlignResponse(binary_light_viewable=saved.get("binary_light_viewable")) diff --git a/src/opengeodeweb_back/routes/crs/schemas/__init__.py b/src/opengeodeweb_back/routes/crs/schemas/__init__.py index 28bc91e2..d5a0bc5d 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/__init__.py +++ b/src/opengeodeweb_back/routes/crs/schemas/__init__.py @@ -1,3 +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/align.json b/src/opengeodeweb_back/routes/crs/schemas/align.json new file mode 100644 index 00000000..bbe3e8bd --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/align.json @@ -0,0 +1,87 @@ +{ + "route": "/align", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "source_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "target_points": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + } + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "source_points", + "target_points", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + } + }, + "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..89bb6e1a --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/align.py @@ -0,0 +1,55 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass +from typing import List + + +@dataclass +class SourcePoint(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + x: float + y: float + + +@dataclass +class TargetPoint(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + x: float + y: float + + +@dataclass +class Align(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + source_points: List[SourcePoint] + target_points: List[TargetPoint] + + +@dataclass +class AlignResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + binary_light_viewable: Optional[str] = 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/convert_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json new file mode 100644 index 00000000..3113fdf7 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json @@ -0,0 +1,43 @@ +{ + "route": "/convert_geographic", + "methods": [ + "POST" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "authority", + "code", + "name" + ], + "additionalProperties": false, + "response": { + "type": "object", + "properties": { + "binary_light_viewable": { + "type": "string" + } + }, + "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..280ac867 --- /dev/null +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py @@ -0,0 +1,34 @@ +from pathlib import Path +from opengeodeweb_microservice.schemas import Route, load_schema +from typing import Optional +from dataclasses_json import DataClassJsonMixin +from opengeodeweb_microservice.schemas import print_dataclass +from dataclasses import dataclass + + +@dataclass +class ConvertGeographic(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + id: str + name: str + + +@dataclass +class ConvertGeographicResponse(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + binary_light_viewable: Optional[str] = None + + +convert_geographic_route = Route( + schema=load_schema(Path(__file__)), + params=ConvertGeographic, + response=ConvertGeographicResponse, +) + +__all__ = ["ConvertGeographic", "ConvertGeographicResponse", "convert_geographic_route"] diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index 2c5ce4d0..b47c5b14 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -4,8 +4,11 @@ from typing import TYPE_CHECKING # Third party imports +import pytest + # Local application imports -from opengeodeweb_back import test_utils +from opengeodeweb_back import geode_functions, test_utils +from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D if TYPE_CHECKING: from flask.testing import FlaskClient @@ -92,3 +95,169 @@ def test_crs_errors(client: FlaskClient) -> None: 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 + response = client.post(active_route, json={"id": point_set_id}) + assert response.get_json() == {"crs_name": "EPSG:32631", "crs_info": UTM} + assert _first_point(client, point_set_id) != lambert_point + + response = client.post(route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 200 + response = client.post(active_route, json={"id": point_set_id}) + assert response.get_json()["crs_name"] == "EPSG:2154" + assert _first_point(client, point_set_id) == pytest.approx(lambert_point, abs=1e-6) + + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + client.post(assign_route, json={"id": brep_id, **LAMBERT}) + response = client.post(route, json={"id": brep_id, **UTM}) + assert response.status_code == 200 + response = client.post(active_route, json={"id": brep_id}) + assert response.get_json()["crs_name"] == "EPSG:32631" + + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_convert_geographic_errors(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/convert_geographic" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + + response = client.post(route, json={"id": point_set_id, **UTM}) + assert response.status_code == 400 + + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **UTM}) + response = client.post(route, json={"id": point_set_id, **UTM}) + assert response.status_code == 400 + + response = client.post(route, json={"id": "0" * 32, **UTM}) + assert response.status_code == 404 + + +SOURCE = [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 0, "y": 10}] +TARGET = [{"x": 1000, "y": 2000}, {"x": 1000, "y": 2020}, {"x": 980, "y": 2000}] + + +def _create_point_set(client: FlaskClient) -> str: + points = [ + {"x": 0, "y": 0, "z": 5}, + {"x": 10, "y": 0, "z": 5}, + {"x": 0, "y": 10, "z": 5}, + ] + response = client.post( + "/opengeodeweb_back/create/point_set", json={"name": "align", "points": points} + ) + assert response.status_code == 200 + data_id: str = response.get_json()["id"] + return data_id + + +def test_align(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/align" + point_set_id = _create_point_set(client) + + def get_full_data() -> test_utils.JsonData: + # Copies: test_route_wrong_params mutates the returned points + return { + "id": point_set_id, + "source_points": [dict(point) for point in SOURCE], + "target_points": [dict(point) for point in TARGET], + **LAMBERT, + } + + response = client.post(route, json=get_full_data()) + assert response.status_code == 200 + assert type(response.get_json()["binary_light_viewable"]) is str + response = client.post("/opengeodeweb_back/crs/active", json={"id": point_set_id}) + assert response.get_json() == {"crs_name": "EPSG:2154", "crs_info": LAMBERT} + assert _first_point(client, point_set_id) == [1000, 2000, 5] + + brep_id = test_utils.check_save_viewable_file(client, "BRep", "cube.og_brep").get_json()["id"] + response = client.post(route, json={**get_full_data(), "id": brep_id}) + assert response.status_code == 200 + + test_utils.test_route_wrong_params(client, route, get_full_data) + + +def test_align_errors(client: FlaskClient) -> None: + route = "/opengeodeweb_back/crs/align" + point_set_id = _create_point_set(client) + colinear = [{"x": 0, "y": 0}, {"x": 1, "y": 1}, {"x": 2, "y": 2}] + + response = client.post( + route, + json={"id": point_set_id, "source_points": colinear, "target_points": TARGET, **LAMBERT}, + ) + assert response.status_code == 400 + + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) + response = client.post( + route, + json={"id": point_set_id, "source_points": SOURCE, "target_points": TARGET, **LAMBERT}, + ) + assert response.status_code == 400 + + +WGS84 = {"authority": "EPSG", "code": "4326", "name": "WGS 84"} +PSEUDO_MERCATOR = {"authority": "EPSG", "code": "3857", "name": "WGS 84 / Pseudo-Mercator"} + + +def test_reassign_after_convert(client: FlaskClient) -> None: + assign_route = "/opengeodeweb_back/crs/assign_geographic" + convert_route = "/opengeodeweb_back/crs/convert_geographic" + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + client.post(convert_route, json={"id": point_set_id, **UTM}) + stale_utm_point = _first_point(client, point_set_id) + + # EPSG:2154 is still registered (inactive) after the conversion + response = client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 200 + response = client.post(assign_route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 400 + + # Relabelling then converting must reproject, not reactivate the stale EPSG:32631 + client.post(assign_route, json={"id": point_set_id, **PSEUDO_MERCATOR}) + response = client.post(convert_route, json={"id": point_set_id, **UTM}) + assert response.status_code == 200 + assert _first_point(client, point_set_id) != pytest.approx(stale_utm_point) + + +def test_convert_out_of_domain(client: FlaskClient) -> None: + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + convert_route = "/opengeodeweb_back/crs/convert_geographic" + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) + client.post(convert_route, json={"id": point_set_id, **UTM}) + # UTM metres read as degrees give an invalid latitude + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **WGS84}) + response = client.post(convert_route, json={"id": point_set_id, **LAMBERT}) + assert response.status_code == 400 From ece953dbd11d4766a54c1ff91be64f15ab0f4462 Mon Sep 17 00:00:00 2001 From: MaxNumerique <144453705+MaxNumerique@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:34:38 +0000 Subject: [PATCH 06/17] Apply prepare changes --- .releaserc | 2 +- opengeodeweb_back_typed_schemas.d.ts | 750 +++++++++++++-------------- requirements.txt | 1 - 3 files changed, 349 insertions(+), 404 deletions(-) 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_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index 4337f9f3..67234df8 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -2,618 +2,624 @@ import type json from "./opengeodeweb_back_schemas.json"; export interface CreatePolygonalSurfaceParams { - name: string; - points: PolygonalSurfacePoint[]; - polygons: Array; + name: string; + points: PolygonalSurfacePoint[]; + polygons: Array; } export interface PolygonalSurfacePoint { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreatePolygonalSurfaceResponse { - binary_light_viewable?: string; - collection_components?: CreatePolygonalSurfaceResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreatePolygonalSurfaceResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreatePolygonalSurfaceResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreatePolygonalSurfaceResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreatePolygonalSurfaceResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreatePolygonalSurfaceResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CreatePointSetParams { - name: string; - points: Point[]; + name: string; + points: Point[]; } export interface Point { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreatePointSetResponse { - binary_light_viewable?: string; - collection_components?: CreatePointSetResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreatePointSetResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreatePointSetResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreatePointSetResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreatePointSetResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreatePointSetResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CreateEdgedCurveParams { - edges: Array; - name: string; - points: EdgedCurvePoint[]; + edges: Array; + name: string; + points: EdgedCurvePoint[]; } export interface EdgedCurvePoint { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreateEdgedCurveResponse { - binary_light_viewable?: string; - collection_components?: CreateEdgedCurveResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreateEdgedCurveResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreateEdgedCurveResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreateEdgedCurveResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreateEdgedCurveResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreateEdgedCurveResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CRSGeographicListParams { - geode_object_type: string; + geode_object_type: string; } export interface CRSGeographicListResponse { - crs_list: CRSList[]; + crs_list: CRSList[]; } export interface CRSList { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CRSConvertGeographicParams { - authority: string; - code: string; - id: string; - name: string; + authority: string; + code: string; + id: string; + name: string; } export interface CRSConvertGeographicResponse { - binary_light_viewable?: string; + binary_light_viewable?: string; } export interface CRSAssignGeographicParams { - authority: string; - code: string; - id: string; - name: string; + authority: string; + code: string; + id: string; + name: string; } -export interface CRSAssignGeographicResponse {} +export interface CRSAssignGeographicResponse { +} export interface CRSAlignParams { - authority: string; - code: string; - id: string; - name: string; - source_points: SourcePoint[]; - target_points: TargetPoint[]; + authority: string; + code: string; + id: string; + name: string; + source_points: SourcePoint[]; + target_points: TargetPoint[]; } export interface SourcePoint { - x: number; - y: number; + x: number; + y: number; } export interface TargetPoint { - x: number; - y: number; + x: number; + y: number; } export interface CRSAlignResponse { - binary_light_viewable?: string; + binary_light_viewable?: string; } export interface CRSActiveParams { - id: string; + id: string; } export interface CRSActiveResponse { - crs_info?: CRSInfo; - crs_name: string; + crs_info?: CRSInfo; + crs_name: string; } export interface CRSInfo { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface VertexAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface VertexAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface VertexAttributeNamesParams { - id: string; + id: string; } export interface VertexAttributeNamesResponse { - attributes: VertexAttributeNamesResponseAttribute[]; + attributes: VertexAttributeNamesResponseAttribute[]; } export interface VertexAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ValidateParams { - id: string; + id: string; } export interface ValidateResponse { - is_valid: boolean; - issues: string[]; - nb_issues: number; + is_valid: boolean; + issues: string[]; + nb_issues: number; } export interface UploadFileParams { - filename?: string; + filename?: string; } export interface UploadFileResponse { - message: string; + message: string; } export interface TimeSeriesMissingFilesParams { - filename: string; + filename: string; } export interface TimeSeriesMissingFilesResponse { - additional_files: string[]; - has_missing_files: boolean; - mandatory_files: string[]; + additional_files: string[]; + has_missing_files: boolean; + mandatory_files: string[]; } export interface TimeSeriesAllowedObjectsParams { - filename: string; + filename: string; } export interface TimeSeriesAllowedObjectsResponse { - allowed_objects: string[]; + allowed_objects: string[]; } export interface TextureCoordinatesParams { - id: string; + id: string; } export interface TextureCoordinatesResponse { - texture_coordinates: string[]; + texture_coordinates: string[]; } export interface SaveViewableFileParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface SaveViewableFileResponse { - binary_light_viewable?: string; - collection_components?: SaveViewableFileResponseCollectionComponent[]; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: SaveViewableFileResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: SaveViewableFileResponseCollectionComponent[]; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: SaveViewableFileResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface SaveViewableFileResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface SaveViewableFileResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface PolyhedronAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface PolyhedronAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface PolyhedronAttributeNamesParams { - id: string; + id: string; } export interface PolyhedronAttributeNamesResponse { - attributes: PolyhedronAttributeNamesResponseAttribute[]; + attributes: PolyhedronAttributeNamesResponseAttribute[]; } export interface PolyhedronAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface PolygonAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface PolygonAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface PolygonAttributeNamesParams { - id: string; + id: string; } export interface PolygonAttributeNamesResponse { - attributes: PolygonAttributeNamesResponseAttribute[]; + attributes: PolygonAttributeNamesResponseAttribute[]; } export interface PolygonAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } -export interface PingParams {} +export interface PingParams { +} export interface PingResponse { - message: string; + message: string; } export interface ModelComponentVertexAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentVertexAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentVertexAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentVertexAttributeNamesResponse { - attributes: ModelComponentVertexAttributeNamesResponseAttribute[]; + attributes: ModelComponentVertexAttributeNamesResponseAttribute[]; } export interface ModelComponentVertexAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentPolyhedronAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolyhedronAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentPolyhedronAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolyhedronAttributeNamesResponse { - attributes: ModelComponentPolyhedronAttributeNamesResponseAttribute[]; + attributes: ModelComponentPolyhedronAttributeNamesResponseAttribute[]; } export interface ModelComponentPolyhedronAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentPolygonAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolygonAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentPolygonAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolygonAttributeNamesResponse { - attributes: ModelComponentPolygonAttributeNamesResponseAttribute[]; + attributes: ModelComponentPolygonAttributeNamesResponseAttribute[]; } export interface ModelComponentPolygonAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentEdgeAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentEdgeAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentEdgeAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentEdgeAttributeNamesResponse { - attributes: ModelComponentEdgeAttributeNamesResponseAttribute[]; + attributes: ModelComponentEdgeAttributeNamesResponseAttribute[]; } export interface ModelComponentEdgeAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } -export interface KillParams {} +export interface KillParams { +} export interface KillResponse { - message: string; + message: string; } -export interface ImportProjectParams {} +export interface ImportProjectParams { +} export interface ImportProjectResponse { - snapshot: { [key: string]: any }; + snapshot: { [key: string]: any }; } export interface GeodeObjectsAndOutputExtensionsParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface GeodeObjectsAndOutputExtensionsResponse { - geode_objects_and_output_extensions: { [key: string]: { [key: string]: boolean } }; + geode_objects_and_output_extensions: { [key: string]: { [key: string]: boolean } }; } export interface GeodeObjectInheritanceParams { - geode_object_type: string; + geode_object_type: string; } export interface GeodeObjectInheritanceResponse { - children: string[]; - parents: string[]; + children: string[]; + parents: string[]; } export interface ExportProjectParams { - filename: string; - snapshot: { [key: string]: any }; + filename: string; + snapshot: { [key: string]: any }; } export interface EdgeAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface EdgeAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface EdgeAttributeNamesParams { - id: string; + id: string; } export interface EdgeAttributeNamesResponse { - attributes: EdgeAttributeNamesResponseAttribute[]; + attributes: EdgeAttributeNamesResponseAttribute[]; } export interface EdgeAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface DataMissingFilesParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface DataMissingFilesResponse { - additional_files: string[]; - has_missing_files: boolean; - mandatory_files: string[]; + additional_files: string[]; + has_missing_files: boolean; + mandatory_files: string[]; } export interface CellAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface CellAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface CellAttributeNamesParams { - id: string; + id: string; } export interface CellAttributeNamesResponse { - attributes: CellAttributeNamesResponseAttribute[]; + attributes: CellAttributeNamesResponseAttribute[]; } export interface CellAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ApplyTimeSeriesParams { - filename: string; - id: string; + filename: string; + id: string; } -export interface ApplyTimeSeriesResponse {} +export interface ApplyTimeSeriesResponse { +} export interface AllowedObjectsParams { - filename: string; + filename: string; } export interface AllowedObjectsResponse { - allowed_objects: { [key: string]: AllowedObject }; + allowed_objects: { [key: string]: AllowedObject }; } export interface AllowedObject { - is_loadable: number; - object_priority: number; + is_loadable: number; + object_priority: number; } -export interface AllowedFilesParams {} +export interface AllowedFilesParams { +} export interface AllowedFilesResponse { - data_extensions: string[]; - time_series_extensions: string[]; + data_extensions: string[]; + time_series_extensions: string[]; } export interface ErrorResponse { - code: number; - description: string; - name: string; + code: number; + description: string; + name: string; } // `__params` and `__response` only exist at type level, to infer request/response types from a schema. @@ -626,111 +632,51 @@ export type TypedSchema = { export interface Schemas { readonly opengeodeweb_back: { readonly create: { - readonly polygonal_surface: (typeof json)["opengeodeweb_back"]["create"]["polygonal_surface"] & - TypedSchema; - readonly point_set: (typeof json)["opengeodeweb_back"]["create"]["point_set"] & - TypedSchema; - readonly edged_curve: (typeof json)["opengeodeweb_back"]["create"]["edged_curve"] & - TypedSchema; + readonly polygonal_surface: (typeof json)["opengeodeweb_back"]["create"]["polygonal_surface"] & TypedSchema; + 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 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; - readonly upload_file: (typeof json)["opengeodeweb_back"]["upload_file"] & - TypedSchema; - readonly time_series_missing_files: (typeof json)["opengeodeweb_back"]["time_series_missing_files"] & - TypedSchema; - readonly time_series_allowed_objects: (typeof json)["opengeodeweb_back"]["time_series_allowed_objects"] & - TypedSchema; - readonly texture_coordinates: (typeof json)["opengeodeweb_back"]["texture_coordinates"] & - TypedSchema; - readonly save_viewable_file: (typeof json)["opengeodeweb_back"]["save_viewable_file"] & - TypedSchema; - readonly polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_range"] & - TypedSchema; - readonly polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_names"] & - TypedSchema; - readonly polygon_attribute_range: (typeof json)["opengeodeweb_back"]["polygon_attribute_range"] & - TypedSchema; - readonly polygon_attribute_names: (typeof json)["opengeodeweb_back"]["polygon_attribute_names"] & - TypedSchema; - readonly ping: (typeof json)["opengeodeweb_back"]["ping"] & - TypedSchema; - readonly model_component_vertex_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_range"] & - TypedSchema< - ModelComponentVertexAttributeRangeParams, - ModelComponentVertexAttributeRangeResponse - >; - readonly model_component_vertex_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_names"] & - TypedSchema< - ModelComponentVertexAttributeNamesParams, - ModelComponentVertexAttributeNamesResponse - >; - readonly model_component_polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_range"] & - TypedSchema< - ModelComponentPolyhedronAttributeRangeParams, - ModelComponentPolyhedronAttributeRangeResponse - >; - readonly model_component_polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_names"] & - TypedSchema< - ModelComponentPolyhedronAttributeNamesParams, - ModelComponentPolyhedronAttributeNamesResponse - >; - readonly model_component_polygon_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_range"] & - TypedSchema< - ModelComponentPolygonAttributeRangeParams, - ModelComponentPolygonAttributeRangeResponse - >; - readonly model_component_polygon_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_names"] & - TypedSchema< - ModelComponentPolygonAttributeNamesParams, - ModelComponentPolygonAttributeNamesResponse - >; - readonly model_component_edge_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_edge_attribute_range"] & - TypedSchema; - 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 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; - readonly edge_attribute_range: (typeof json)["opengeodeweb_back"]["edge_attribute_range"] & - TypedSchema; - readonly edge_attribute_names: (typeof json)["opengeodeweb_back"]["edge_attribute_names"] & - TypedSchema; - readonly data_missing_files: (typeof json)["opengeodeweb_back"]["data_missing_files"] & - TypedSchema; - readonly cell_attribute_range: (typeof json)["opengeodeweb_back"]["cell_attribute_range"] & - TypedSchema; - readonly cell_attribute_names: (typeof json)["opengeodeweb_back"]["cell_attribute_names"] & - TypedSchema; - readonly apply_time_series: (typeof json)["opengeodeweb_back"]["apply_time_series"] & - TypedSchema; - readonly allowed_objects: (typeof json)["opengeodeweb_back"]["allowed_objects"] & - TypedSchema; - readonly allowed_files: (typeof json)["opengeodeweb_back"]["allowed_files"] & - 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; + readonly upload_file: (typeof json)["opengeodeweb_back"]["upload_file"] & TypedSchema; + readonly time_series_missing_files: (typeof json)["opengeodeweb_back"]["time_series_missing_files"] & TypedSchema; + readonly time_series_allowed_objects: (typeof json)["opengeodeweb_back"]["time_series_allowed_objects"] & TypedSchema; + readonly texture_coordinates: (typeof json)["opengeodeweb_back"]["texture_coordinates"] & TypedSchema; + readonly save_viewable_file: (typeof json)["opengeodeweb_back"]["save_viewable_file"] & TypedSchema; + readonly polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_range"] & TypedSchema; + readonly polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_names"] & TypedSchema; + readonly polygon_attribute_range: (typeof json)["opengeodeweb_back"]["polygon_attribute_range"] & TypedSchema; + readonly polygon_attribute_names: (typeof json)["opengeodeweb_back"]["polygon_attribute_names"] & TypedSchema; + readonly ping: (typeof json)["opengeodeweb_back"]["ping"] & TypedSchema; + readonly model_component_vertex_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_range"] & TypedSchema; + readonly model_component_vertex_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_names"] & TypedSchema; + readonly model_component_polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_range"] & TypedSchema; + readonly model_component_polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_names"] & TypedSchema; + readonly model_component_polygon_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_range"] & TypedSchema; + readonly model_component_polygon_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_names"] & TypedSchema; + readonly model_component_edge_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_edge_attribute_range"] & TypedSchema; + 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 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; + readonly edge_attribute_range: (typeof json)["opengeodeweb_back"]["edge_attribute_range"] & TypedSchema; + readonly edge_attribute_names: (typeof json)["opengeodeweb_back"]["edge_attribute_names"] & TypedSchema; + readonly data_missing_files: (typeof json)["opengeodeweb_back"]["data_missing_files"] & TypedSchema; + readonly cell_attribute_range: (typeof json)["opengeodeweb_back"]["cell_attribute_range"] & TypedSchema; + readonly cell_attribute_names: (typeof json)["opengeodeweb_back"]["cell_attribute_names"] & TypedSchema; + readonly apply_time_series: (typeof json)["opengeodeweb_back"]["apply_time_series"] & TypedSchema; + readonly allowed_objects: (typeof json)["opengeodeweb_back"]["allowed_objects"] & TypedSchema; + readonly allowed_files: (typeof json)["opengeodeweb_back"]["allowed_files"] & TypedSchema; }; } 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 From f44887b6fb8d8d05b420c17de1bd19651b97ea2b Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Fri, 23 Oct 2026 11:35:44 +0200 Subject: [PATCH 07/17] feat(crs): active CRS in data responses --- opengeodeweb_back_schemas.json | 143 +++++++++++++++++- opengeodeweb_back_typed_schemas.d.ts | 52 ++++++- src/opengeodeweb_back/crs_functions.py | 72 +++++++++ .../routes/create/schemas/edged_curve.json | 22 ++- .../routes/create/schemas/edged_curve.py | 11 ++ .../routes/create/schemas/point_set.json | 22 ++- .../routes/create/schemas/point_set.py | 11 ++ .../create/schemas/polygonal_surface.json | 22 ++- .../create/schemas/polygonal_surface.py | 11 ++ .../routes/crs/blueprint_crs.py | 68 +++------ .../routes/crs/schemas/align.json | 20 +++ .../routes/crs/schemas/align.py | 11 ++ .../routes/crs/schemas/assign_geographic.json | 23 ++- .../routes/crs/schemas/assign_geographic.py | 13 +- .../crs/schemas/convert_geographic.json | 20 +++ .../routes/crs/schemas/convert_geographic.py | 11 ++ .../routes/schemas/save_viewable_file.json | 22 ++- .../routes/schemas/save_viewable_file.py | 11 ++ src/opengeodeweb_back/utils_functions.py | 4 + tests/test_crs_routes.py | 32 ++++ tests/test_routes.py | 5 +- 21 files changed, 546 insertions(+), 60 deletions(-) create mode 100644 src/opengeodeweb_back/crs_functions.py diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index aecbe7d6..b464a7d4 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -181,6 +181,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -362,6 +382,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -557,6 +597,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ @@ -661,6 +721,26 @@ "properties": { "binary_light_viewable": { "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [], @@ -702,7 +782,28 @@ "additionalProperties": false, "response": { "type": "object", - "properties": {}, + "properties": { + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, "required": [], "additionalProperties": false } @@ -789,6 +890,26 @@ "properties": { "binary_light_viewable": { "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [], @@ -1283,6 +1404,26 @@ "type": "string", "minLength": 36, "maxLength": 36 + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [ diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index 4337f9f3..d0b7c375 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; @@ -158,6 +179,13 @@ export interface CRSConvertGeographicParams { export interface CRSConvertGeographicResponse { binary_light_viewable?: string; + crs?: CRSConvertGeographicResponseCRS; +} + +export interface CRSConvertGeographicResponseCRS { + authority: string; + code: string; + name: string; } export interface CRSAssignGeographicParams { @@ -167,7 +195,15 @@ export interface CRSAssignGeographicParams { name: string; } -export interface CRSAssignGeographicResponse {} +export interface CRSAssignGeographicResponse { + crs?: CRSAssignGeographicResponseCRS; +} + +export interface CRSAssignGeographicResponseCRS { + authority: string; + code: string; + name: string; +} export interface CRSAlignParams { authority: string; @@ -190,6 +226,13 @@ export interface TargetPoint { export interface CRSAlignResponse { binary_light_viewable?: string; + crs?: CRSAlignResponseCRS; +} + +export interface CRSAlignResponseCRS { + authority: string; + code: string; + name: string; } export interface CRSActiveParams { @@ -285,6 +328,7 @@ export interface SaveViewableFileParams { export interface SaveViewableFileResponse { binary_light_viewable?: string; collection_components?: SaveViewableFileResponseCollectionComponent[]; + crs?: SaveViewableFileResponseCRS; geode_id: string; geode_object_type: string; id: string; @@ -305,6 +349,12 @@ export interface SaveViewableFileResponseCollectionComponent { type: string; } +export interface SaveViewableFileResponseCRS { + authority: string; + code: string; + name: string; +} + export interface SaveViewableFileResponseMeshComponent { boundaries: string[]; geode_id: string; diff --git a/src/opengeodeweb_back/crs_functions.py b/src/opengeodeweb_back/crs_functions.py new file mode 100644 index 00000000..9ecd930d --- /dev/null +++ b/src/opengeodeweb_back/crs_functions.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +# Standard library imports +from typing import TYPE_CHECKING + +# Third party imports +import opengeode_geosciences as og_geosciences + +# Local application imports +from opengeodeweb_back.geode_objects.geode_brep import GeodeBRep +from opengeodeweb_back.geode_objects.geode_edged_curve2d import GeodeEdgedCurve2D +from opengeodeweb_back.geode_objects.geode_edged_curve3d import GeodeEdgedCurve3D +from opengeodeweb_back.geode_objects.geode_point_set2d import GeodePointSet2D +from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D +from opengeodeweb_back.geode_objects.geode_section import GeodeSection +from opengeodeweb_back.geode_objects.geode_solid_mesh3d import GeodeSolidMesh3D +from opengeodeweb_back.geode_objects.geode_surface_mesh2d import GeodeSurfaceMesh2D +from opengeodeweb_back.geode_objects.geode_surface_mesh3d import GeodeSurfaceMesh3D + +if TYPE_CHECKING: + import opengeode as og + + from opengeodeweb_back.geode_objects.geode_object import GeodeObject + +CRS_GEODE_OBJECTS = ( + GeodeBRep, + GeodeSection, + GeodePointSet2D, + GeodePointSet3D, + GeodeEdgedCurve2D, + GeodeEdgedCurve3D, + GeodeSurfaceMesh2D, + GeodeSurfaceMesh3D, + GeodeSolidMesh3D, +) + +CrsGeodeObject = ( + GeodeBRep + | GeodeSection + | GeodePointSet2D + | GeodePointSet3D + | GeodeEdgedCurve2D + | GeodeEdgedCurve3D + | GeodeSurfaceMesh2D + | GeodeSurfaceMesh3D + | GeodeSolidMesh3D +) + + +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: # A model without any component mesh has no CRS + return None + if info is None: + return None + return {"authority": info.authority, "code": info.code, "name": info.name} 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/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index e0d87f05..6c5d817a 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -1,4 +1,5 @@ # Standard library imports +from typing import Any # Third party imports import flask @@ -7,47 +8,23 @@ # Local application imports from opengeodeweb_back import geode_functions, utils_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 +from opengeodeweb_back.crs_functions import ( + CRS_GEODE_OBJECTS, + CrsGeodeObject, + geographic_info, +) from opengeodeweb_back.routes.crs import schemas from opengeodeweb_back.typed_route import typed_route routes = flask.Blueprint("crs", __name__, url_prefix="/crs") -CRS_GEODE_OBJECTS = ( - GeodeBRep, - GeodeSection, - GeodePointSet2D, - GeodePointSet3D, - GeodeEdgedCurve2D, - GeodeEdgedCurve3D, - GeodeSurfaceMesh2D, - GeodeSurfaceMesh3D, - GeodeSolidMesh3D, -) - def _prepare_crs_name( - geode_object: GeodeBRep - | GeodeSection - | GeodePointSet2D - | GeodePointSet3D - | GeodeEdgedCurve2D - | GeodeEdgedCurve3D - | GeodeSurfaceMesh2D - | GeodeSurfaceMesh3D - | GeodeSolidMesh3D, + geode_object: CrsGeodeObject, crs_name: str, ) -> None: crs_manager = geode_object.crs_manager() - info = _geographic_info(crs_manager) + info = geographic_info(crs_manager) if info is not None and info.authority_code() == crs_name: flask.abort(400, f"{crs_name} is already the active CRS") # A registered inactive CRS may hold coordinates derived from an outdated label: recompute it @@ -55,16 +32,9 @@ def _prepare_crs_name( geode_object.delete_crs(crs_name) -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 _crs_response(saved: dict[str, Any]) -> dict[str, Any]: + # Only the fields the front refreshes after a CRS change + return {key: saved[key] for key in ("crs", "binary_light_viewable") if key in saved} @typed_route(routes, schemas.geographic_list_route) @@ -88,7 +58,7 @@ def active(params: schemas.Active) -> schemas.ActiveResponse: 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) + info = geographic_info(crs_manager) crs_info = ( None if info is None @@ -111,10 +81,10 @@ def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeograp info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) _prepare_crs_name(geode_object, info.authority_code()) geode_object.assign_crs(info.authority_code(), info) - utils_functions.save_all_viewables_and_return_info( + saved = utils_functions.save_all_viewables_and_return_info( geode_object, data, geode_functions.data_file_path(params.id) ) - return schemas.AssignGeographicResponse() + return schemas.AssignGeographicResponse.from_dict(_crs_response(saved)) @typed_route(routes, schemas.convert_geographic_route) @@ -126,7 +96,7 @@ def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeog if not isinstance(geode_object, CRS_GEODE_OBJECTS): flask.abort(400, f"{params.id} does not support coordinate reference systems") crs_manager = geode_object.crs_manager() - if _geographic_info(crs_manager) is None: + if geographic_info(crs_manager) is None: flask.abort(400, "Assign a geographic CRS before converting") info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) _prepare_crs_name(geode_object, info.authority_code()) @@ -138,9 +108,7 @@ def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeog geode_object, data, geode_functions.data_file_path(params.id) ) # The front rebuilds its local actor from the new light viewable - return schemas.ConvertGeographicResponse( - binary_light_viewable=saved.get("binary_light_viewable") - ) + return schemas.ConvertGeographicResponse.from_dict(_crs_response(saved)) def _coordinate_system( @@ -158,7 +126,7 @@ def align(params: schemas.Align) -> schemas.AlignResponse: ) if not isinstance(geode_object, CRS_GEODE_OBJECTS): flask.abort(400, f"{params.id} does not support coordinate reference systems") - if _geographic_info(geode_object.crs_manager()) is not None: + if geographic_info(geode_object.crs_manager()) is not None: flask.abort(400, "Data already has a geographic CRS, use convert") try: source = _coordinate_system(params.source_points) @@ -172,4 +140,4 @@ def align(params: schemas.Align) -> schemas.AlignResponse: geode_object, data, geode_functions.data_file_path(params.id) ) # The front rebuilds its local actor from the new light viewable - return schemas.AlignResponse(binary_light_viewable=saved.get("binary_light_viewable")) + return schemas.AlignResponse.from_dict(_crs_response(saved)) diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.json b/src/opengeodeweb_back/routes/crs/schemas/align.json index bbe3e8bd..1fc0113c 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.json +++ b/src/opengeodeweb_back/routes/crs/schemas/align.json @@ -79,6 +79,26 @@ "properties": { "binary_light_viewable": { "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [], diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.py b/src/opengeodeweb_back/routes/crs/schemas/align.py index 89bb6e1a..9d5a47d1 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.py +++ b/src/opengeodeweb_back/routes/crs/schemas/align.py @@ -38,12 +38,23 @@ def __post_init__(self) -> None: target_points: List[TargetPoint] +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + @dataclass class AlignResponse(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) binary_light_viewable: Optional[str] = None + crs: Optional[CRS] = None align_route = Route( diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json index 4052b452..1b3059e8 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json @@ -32,7 +32,28 @@ "additionalProperties": false, "response": { "type": "object", - "properties": {}, + "properties": { + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false + } + }, "required": [], "additionalProperties": false } diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py index 42b5590f..c8ed1204 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py @@ -1,5 +1,6 @@ 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 @@ -16,12 +17,22 @@ def __post_init__(self) -> None: name: str +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + @dataclass class AssignGeographicResponse(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) - pass + crs: Optional[CRS] = None assign_geographic_route = Route( diff --git a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json index 3113fdf7..07c77e6e 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json @@ -35,6 +35,26 @@ "properties": { "binary_light_viewable": { "type": "string" + }, + "crs": { + "type": "object", + "properties": { + "authority": { + "type": "string" + }, + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "authority", + "code", + "name" + ], + "additionalProperties": false } }, "required": [], diff --git a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py index 280ac867..59311161 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py @@ -17,12 +17,23 @@ def __post_init__(self) -> None: name: str +@dataclass +class CRS(DataClassJsonMixin): + def __post_init__(self) -> None: + print_dataclass(self) + + authority: str + code: str + name: str + + @dataclass class ConvertGeographicResponse(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) binary_light_viewable: Optional[str] = None + crs: Optional[CRS] = None convert_geographic_route = Route( 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/utils_functions.py b/src/opengeodeweb_back/utils_functions.py index 5f4c1287..1870a3e5 100644 --- a/src/opengeodeweb_back/utils_functions.py +++ b/src/opengeodeweb_back/utils_functions.py @@ -25,6 +25,7 @@ # Local application imports from . import geode_functions +from .crs_functions import active_crs from .geode_objects import geode_objects from .geode_objects.geode_model import GeodeModel from .geode_objects.geode_vertex_set import GeodeVertexSet @@ -344,6 +345,9 @@ def save_all_viewables_and_return_info( response["nb_vertices"] = geode_object.vertex_set.nb_vertices() if isinstance(geode_object, GeodeModel): response |= model_components(data.id, geode_object, data.viewable_file) + crs = active_crs(geode_object) + if crs is not None: + response["crs"] = crs return response diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index b47c5b14..89fcd3c9 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -1,12 +1,16 @@ from __future__ import annotations # Standard library imports +import shutil +from pathlib import Path from typing import TYPE_CHECKING # Third party imports import pytest # Local application imports +from opengeodeweb_microservice.database.data import Data + from opengeodeweb_back import geode_functions, test_utils from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D @@ -65,6 +69,7 @@ def get_full_data() -> test_utils.JsonData: 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}) @@ -122,6 +127,7 @@ def get_full_data() -> test_utils.JsonData: 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 @@ -193,6 +199,7 @@ def get_full_data() -> test_utils.JsonData: 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] @@ -261,3 +268,28 @@ def test_convert_out_of_domain(client: FlaskClient) -> None: client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **WGS84}) response = client.post(convert_route, json={"id": point_set_id, **LAMBERT}) assert response.status_code == 400 + + +def test_saved_native_file_keeps_its_crs(client: FlaskClient) -> None: + point_set_id = test_utils.check_save_viewable_file( + client, "PointSet3D", "test.og_pts3d" + ).get_json()["id"] + client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) + with client.application.app_context(): + data = Data.get(point_set_id) + assert data is not None + native_path = Path(geode_functions.data_file_path(point_set_id, data.native_file)) + upload_path = Path(geode_functions.upload_file_path("georeferenced.og_pts3d")) + upload_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(native_path, upload_path) + try: + response = client.post( + "/opengeodeweb_back/save_viewable_file", + json={"geode_object_type": "PointSet3D", "filename": "georeferenced.og_pts3d"}, + ) + finally: + upload_path.unlink() + assert response.status_code == 200 + assert response.get_json()["crs"] == LAMBERT + # The copy lives in the shared test data folder: it must not outlive the test + assert not upload_path.exists() diff --git a/tests/test_routes.py b/tests/test_routes.py index aa35949e..46fa75a8 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -232,8 +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: - test_utils.check_save_viewable_file(client, "BRep", "corbi.og_brep") +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: From 9fc5284e5cc56a3965e6dbff0341e3ed3a2b845b Mon Sep 17 00:00:00 2001 From: MaxNumerique <144453705+MaxNumerique@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:38:16 +0000 Subject: [PATCH 08/17] Apply prepare changes --- opengeodeweb_back_typed_schemas.d.ts | 803 +++++++++++++-------------- 1 file changed, 374 insertions(+), 429 deletions(-) diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index d0b7c375..7c8bb273 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -2,668 +2,673 @@ import type json from "./opengeodeweb_back_schemas.json"; export interface CreatePolygonalSurfaceParams { - name: string; - points: PolygonalSurfacePoint[]; - polygons: Array; + name: string; + points: PolygonalSurfacePoint[]; + polygons: Array; } export interface PolygonalSurfacePoint { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreatePolygonalSurfaceResponse { - binary_light_viewable?: string; - collection_components?: CreatePolygonalSurfaceResponseCollectionComponent[]; - crs?: CreatePolygonalSurfaceResponseCRS; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreatePolygonalSurfaceResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreatePolygonalSurfaceResponseCollectionComponent[]; + crs?: CreatePolygonalSurfaceResponseCRS; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreatePolygonalSurfaceResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreatePolygonalSurfaceResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreatePolygonalSurfaceResponseCRS { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CreatePolygonalSurfaceResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CreatePointSetParams { - name: string; - points: Point[]; + name: string; + points: Point[]; } export interface Point { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreatePointSetResponse { - binary_light_viewable?: string; - collection_components?: CreatePointSetResponseCollectionComponent[]; - crs?: CreatePointSetResponseCRS; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreatePointSetResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreatePointSetResponseCollectionComponent[]; + crs?: CreatePointSetResponseCRS; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreatePointSetResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreatePointSetResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreatePointSetResponseCRS { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CreatePointSetResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CreateEdgedCurveParams { - edges: Array; - name: string; - points: EdgedCurvePoint[]; + edges: Array; + name: string; + points: EdgedCurvePoint[]; } export interface EdgedCurvePoint { - x: number; - y: number; - z: number; + x: number; + y: number; + z: number; } export interface CreateEdgedCurveResponse { - binary_light_viewable?: string; - collection_components?: CreateEdgedCurveResponseCollectionComponent[]; - crs?: CreateEdgedCurveResponseCRS; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: CreateEdgedCurveResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: CreateEdgedCurveResponseCollectionComponent[]; + crs?: CreateEdgedCurveResponseCRS; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: CreateEdgedCurveResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface CreateEdgedCurveResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface CreateEdgedCurveResponseCRS { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CreateEdgedCurveResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface CRSGeographicListParams { - geode_object_type: string; + geode_object_type: string; } export interface CRSGeographicListResponse { - crs_list: CRSList[]; + crs_list: CRSList[]; } export interface CRSList { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CRSConvertGeographicParams { - authority: string; - code: string; - id: string; - name: string; + authority: string; + code: string; + id: string; + name: string; } export interface CRSConvertGeographicResponse { - binary_light_viewable?: string; - crs?: CRSConvertGeographicResponseCRS; + binary_light_viewable?: string; + crs?: CRSConvertGeographicResponseCRS; } export interface CRSConvertGeographicResponseCRS { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CRSAssignGeographicParams { - authority: string; - code: string; - id: string; - name: string; + authority: string; + code: string; + id: string; + name: string; } export interface CRSAssignGeographicResponse { - crs?: CRSAssignGeographicResponseCRS; + crs?: CRSAssignGeographicResponseCRS; } export interface CRSAssignGeographicResponseCRS { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CRSAlignParams { - authority: string; - code: string; - id: string; - name: string; - source_points: SourcePoint[]; - target_points: TargetPoint[]; + authority: string; + code: string; + id: string; + name: string; + source_points: SourcePoint[]; + target_points: TargetPoint[]; } export interface SourcePoint { - x: number; - y: number; + x: number; + y: number; } export interface TargetPoint { - x: number; - y: number; + x: number; + y: number; } export interface CRSAlignResponse { - binary_light_viewable?: string; - crs?: CRSAlignResponseCRS; + binary_light_viewable?: string; + crs?: CRSAlignResponseCRS; } export interface CRSAlignResponseCRS { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface CRSActiveParams { - id: string; + id: string; } export interface CRSActiveResponse { - crs_info?: CRSInfo; - crs_name: string; + crs_info?: CRSInfo; + crs_name: string; } export interface CRSInfo { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface VertexAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface VertexAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface VertexAttributeNamesParams { - id: string; + id: string; } export interface VertexAttributeNamesResponse { - attributes: VertexAttributeNamesResponseAttribute[]; + attributes: VertexAttributeNamesResponseAttribute[]; } export interface VertexAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ValidateParams { - id: string; + id: string; } export interface ValidateResponse { - is_valid: boolean; - issues: string[]; - nb_issues: number; + is_valid: boolean; + issues: string[]; + nb_issues: number; } export interface UploadFileParams { - filename?: string; + filename?: string; } export interface UploadFileResponse { - message: string; + message: string; } export interface TimeSeriesMissingFilesParams { - filename: string; + filename: string; } export interface TimeSeriesMissingFilesResponse { - additional_files: string[]; - has_missing_files: boolean; - mandatory_files: string[]; + additional_files: string[]; + has_missing_files: boolean; + mandatory_files: string[]; } export interface TimeSeriesAllowedObjectsParams { - filename: string; + filename: string; } export interface TimeSeriesAllowedObjectsResponse { - allowed_objects: string[]; + allowed_objects: string[]; } export interface TextureCoordinatesParams { - id: string; + id: string; } export interface TextureCoordinatesResponse { - texture_coordinates: string[]; + texture_coordinates: string[]; } export interface SaveViewableFileParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface SaveViewableFileResponse { - binary_light_viewable?: string; - collection_components?: SaveViewableFileResponseCollectionComponent[]; - crs?: SaveViewableFileResponseCRS; - geode_id: string; - geode_object_type: string; - id: string; - is_viewable: boolean; - mesh_components?: SaveViewableFileResponseMeshComponent[]; - name: string; - native_file: string; - nb_vertices?: number; - viewable_file?: string; - viewer_type: string; + binary_light_viewable?: string; + collection_components?: SaveViewableFileResponseCollectionComponent[]; + crs?: SaveViewableFileResponseCRS; + geode_id: string; + geode_object_type: string; + id: string; + is_viewable: boolean; + mesh_components?: SaveViewableFileResponseMeshComponent[]; + name: string; + native_file: string; + nb_vertices?: number; + viewable_file?: string; + viewer_type: string; } export interface SaveViewableFileResponseCollectionComponent { - geode_id: string; - is_active: boolean; - items: string[]; - name: string; - type: string; + geode_id: string; + is_active: boolean; + items: string[]; + name: string; + type: string; } export interface SaveViewableFileResponseCRS { - authority: string; - code: string; - name: string; + authority: string; + code: string; + name: string; } export interface SaveViewableFileResponseMeshComponent { - boundaries: string[]; - geode_id: string; - internals: string[]; - is_active: boolean; - name: string; - type: string; - viewer_id: number; + boundaries: string[]; + geode_id: string; + internals: string[]; + is_active: boolean; + name: string; + type: string; + viewer_id: number; } export interface PolyhedronAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface PolyhedronAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface PolyhedronAttributeNamesParams { - id: string; + id: string; } export interface PolyhedronAttributeNamesResponse { - attributes: PolyhedronAttributeNamesResponseAttribute[]; + attributes: PolyhedronAttributeNamesResponseAttribute[]; } export interface PolyhedronAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface PolygonAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface PolygonAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface PolygonAttributeNamesParams { - id: string; + id: string; } export interface PolygonAttributeNamesResponse { - attributes: PolygonAttributeNamesResponseAttribute[]; + attributes: PolygonAttributeNamesResponseAttribute[]; } export interface PolygonAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } -export interface PingParams {} +export interface PingParams { +} export interface PingResponse { - message: string; + message: string; } export interface ModelComponentVertexAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentVertexAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentVertexAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentVertexAttributeNamesResponse { - attributes: ModelComponentVertexAttributeNamesResponseAttribute[]; + attributes: ModelComponentVertexAttributeNamesResponseAttribute[]; } export interface ModelComponentVertexAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentPolyhedronAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolyhedronAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentPolyhedronAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolyhedronAttributeNamesResponse { - attributes: ModelComponentPolyhedronAttributeNamesResponseAttribute[]; + attributes: ModelComponentPolyhedronAttributeNamesResponseAttribute[]; } export interface ModelComponentPolyhedronAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentPolygonAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolygonAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentPolygonAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentPolygonAttributeNamesResponse { - attributes: ModelComponentPolygonAttributeNamesResponseAttribute[]; + attributes: ModelComponentPolygonAttributeNamesResponseAttribute[]; } export interface ModelComponentPolygonAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ModelComponentEdgeAttributeRangeParams { - attribute_name: string; - component_ids: string[]; - id: string; + attribute_name: string; + component_ids: string[]; + id: string; } export interface ModelComponentEdgeAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface ModelComponentEdgeAttributeNamesParams { - component_ids: string[]; - id: string; + component_ids: string[]; + id: string; } export interface ModelComponentEdgeAttributeNamesResponse { - attributes: ModelComponentEdgeAttributeNamesResponseAttribute[]; + attributes: ModelComponentEdgeAttributeNamesResponseAttribute[]; } export interface ModelComponentEdgeAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } -export interface KillParams {} +export interface KillParams { +} export interface KillResponse { - message: string; + message: string; } -export interface ImportProjectParams {} +export interface ImportProjectParams { +} export interface ImportProjectResponse { - snapshot: { [key: string]: any }; + snapshot: { [key: string]: any }; } export interface GeodeObjectsAndOutputExtensionsParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface GeodeObjectsAndOutputExtensionsResponse { - geode_objects_and_output_extensions: { [key: string]: { [key: string]: boolean } }; + geode_objects_and_output_extensions: { [key: string]: { [key: string]: boolean } }; } export interface GeodeObjectInheritanceParams { - geode_object_type: string; + geode_object_type: string; } export interface GeodeObjectInheritanceResponse { - children: string[]; - parents: string[]; + children: string[]; + parents: string[]; } export interface ExportProjectParams { - filename: string; - snapshot: { [key: string]: any }; + filename: string; + snapshot: { [key: string]: any }; } export interface EdgeAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface EdgeAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface EdgeAttributeNamesParams { - id: string; + id: string; } export interface EdgeAttributeNamesResponse { - attributes: EdgeAttributeNamesResponseAttribute[]; + attributes: EdgeAttributeNamesResponseAttribute[]; } export interface EdgeAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface DataMissingFilesParams { - filename: string; - geode_object_type: string; + filename: string; + geode_object_type: string; } export interface DataMissingFilesResponse { - additional_files: string[]; - has_missing_files: boolean; - mandatory_files: string[]; + additional_files: string[]; + has_missing_files: boolean; + mandatory_files: string[]; } export interface CellAttributeRangeParams { - attribute_name: string; - id: string; + attribute_name: string; + id: string; } export interface CellAttributeRangeResponse { - max_values: number[]; - min_values: number[]; - no_data: boolean; + max_values: number[]; + min_values: number[]; + no_data: boolean; } export interface CellAttributeNamesParams { - id: string; + id: string; } export interface CellAttributeNamesResponse { - attributes: CellAttributeNamesResponseAttribute[]; + attributes: CellAttributeNamesResponseAttribute[]; } export interface CellAttributeNamesResponseAttribute { - attribute_id: string; - attribute_name: string; - nb_items: number; - time_steps: number[]; + attribute_id: string; + attribute_name: string; + nb_items: number; + time_steps: number[]; } export interface ApplyTimeSeriesParams { - filename: string; - id: string; + filename: string; + id: string; } -export interface ApplyTimeSeriesResponse {} +export interface ApplyTimeSeriesResponse { +} export interface AllowedObjectsParams { - filename: string; + filename: string; } export interface AllowedObjectsResponse { - allowed_objects: { [key: string]: AllowedObject }; + allowed_objects: { [key: string]: AllowedObject }; } export interface AllowedObject { - is_loadable: number; - object_priority: number; + is_loadable: number; + object_priority: number; } -export interface AllowedFilesParams {} +export interface AllowedFilesParams { +} export interface AllowedFilesResponse { - data_extensions: string[]; - time_series_extensions: string[]; + data_extensions: string[]; + time_series_extensions: string[]; } export interface ErrorResponse { - code: number; - description: string; - name: string; + code: number; + description: string; + name: string; } // `__params` and `__response` only exist at type level, to infer request/response types from a schema. @@ -676,111 +681,51 @@ export type TypedSchema = { export interface Schemas { readonly opengeodeweb_back: { readonly create: { - readonly polygonal_surface: (typeof json)["opengeodeweb_back"]["create"]["polygonal_surface"] & - TypedSchema; - readonly point_set: (typeof json)["opengeodeweb_back"]["create"]["point_set"] & - TypedSchema; - readonly edged_curve: (typeof json)["opengeodeweb_back"]["create"]["edged_curve"] & - TypedSchema; + readonly polygonal_surface: (typeof json)["opengeodeweb_back"]["create"]["polygonal_surface"] & TypedSchema; + 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 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; - readonly upload_file: (typeof json)["opengeodeweb_back"]["upload_file"] & - TypedSchema; - readonly time_series_missing_files: (typeof json)["opengeodeweb_back"]["time_series_missing_files"] & - TypedSchema; - readonly time_series_allowed_objects: (typeof json)["opengeodeweb_back"]["time_series_allowed_objects"] & - TypedSchema; - readonly texture_coordinates: (typeof json)["opengeodeweb_back"]["texture_coordinates"] & - TypedSchema; - readonly save_viewable_file: (typeof json)["opengeodeweb_back"]["save_viewable_file"] & - TypedSchema; - readonly polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_range"] & - TypedSchema; - readonly polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_names"] & - TypedSchema; - readonly polygon_attribute_range: (typeof json)["opengeodeweb_back"]["polygon_attribute_range"] & - TypedSchema; - readonly polygon_attribute_names: (typeof json)["opengeodeweb_back"]["polygon_attribute_names"] & - TypedSchema; - readonly ping: (typeof json)["opengeodeweb_back"]["ping"] & - TypedSchema; - readonly model_component_vertex_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_range"] & - TypedSchema< - ModelComponentVertexAttributeRangeParams, - ModelComponentVertexAttributeRangeResponse - >; - readonly model_component_vertex_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_names"] & - TypedSchema< - ModelComponentVertexAttributeNamesParams, - ModelComponentVertexAttributeNamesResponse - >; - readonly model_component_polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_range"] & - TypedSchema< - ModelComponentPolyhedronAttributeRangeParams, - ModelComponentPolyhedronAttributeRangeResponse - >; - readonly model_component_polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_names"] & - TypedSchema< - ModelComponentPolyhedronAttributeNamesParams, - ModelComponentPolyhedronAttributeNamesResponse - >; - readonly model_component_polygon_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_range"] & - TypedSchema< - ModelComponentPolygonAttributeRangeParams, - ModelComponentPolygonAttributeRangeResponse - >; - readonly model_component_polygon_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_names"] & - TypedSchema< - ModelComponentPolygonAttributeNamesParams, - ModelComponentPolygonAttributeNamesResponse - >; - readonly model_component_edge_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_edge_attribute_range"] & - TypedSchema; - 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 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; - readonly edge_attribute_range: (typeof json)["opengeodeweb_back"]["edge_attribute_range"] & - TypedSchema; - readonly edge_attribute_names: (typeof json)["opengeodeweb_back"]["edge_attribute_names"] & - TypedSchema; - readonly data_missing_files: (typeof json)["opengeodeweb_back"]["data_missing_files"] & - TypedSchema; - readonly cell_attribute_range: (typeof json)["opengeodeweb_back"]["cell_attribute_range"] & - TypedSchema; - readonly cell_attribute_names: (typeof json)["opengeodeweb_back"]["cell_attribute_names"] & - TypedSchema; - readonly apply_time_series: (typeof json)["opengeodeweb_back"]["apply_time_series"] & - TypedSchema; - readonly allowed_objects: (typeof json)["opengeodeweb_back"]["allowed_objects"] & - TypedSchema; - readonly allowed_files: (typeof json)["opengeodeweb_back"]["allowed_files"] & - 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; + readonly upload_file: (typeof json)["opengeodeweb_back"]["upload_file"] & TypedSchema; + readonly time_series_missing_files: (typeof json)["opengeodeweb_back"]["time_series_missing_files"] & TypedSchema; + readonly time_series_allowed_objects: (typeof json)["opengeodeweb_back"]["time_series_allowed_objects"] & TypedSchema; + readonly texture_coordinates: (typeof json)["opengeodeweb_back"]["texture_coordinates"] & TypedSchema; + readonly save_viewable_file: (typeof json)["opengeodeweb_back"]["save_viewable_file"] & TypedSchema; + readonly polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_range"] & TypedSchema; + readonly polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["polyhedron_attribute_names"] & TypedSchema; + readonly polygon_attribute_range: (typeof json)["opengeodeweb_back"]["polygon_attribute_range"] & TypedSchema; + readonly polygon_attribute_names: (typeof json)["opengeodeweb_back"]["polygon_attribute_names"] & TypedSchema; + readonly ping: (typeof json)["opengeodeweb_back"]["ping"] & TypedSchema; + readonly model_component_vertex_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_range"] & TypedSchema; + readonly model_component_vertex_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_vertex_attribute_names"] & TypedSchema; + readonly model_component_polyhedron_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_range"] & TypedSchema; + readonly model_component_polyhedron_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polyhedron_attribute_names"] & TypedSchema; + readonly model_component_polygon_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_range"] & TypedSchema; + readonly model_component_polygon_attribute_names: (typeof json)["opengeodeweb_back"]["model_component_polygon_attribute_names"] & TypedSchema; + readonly model_component_edge_attribute_range: (typeof json)["opengeodeweb_back"]["model_component_edge_attribute_range"] & TypedSchema; + 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 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; + readonly edge_attribute_range: (typeof json)["opengeodeweb_back"]["edge_attribute_range"] & TypedSchema; + readonly edge_attribute_names: (typeof json)["opengeodeweb_back"]["edge_attribute_names"] & TypedSchema; + readonly data_missing_files: (typeof json)["opengeodeweb_back"]["data_missing_files"] & TypedSchema; + readonly cell_attribute_range: (typeof json)["opengeodeweb_back"]["cell_attribute_range"] & TypedSchema; + readonly cell_attribute_names: (typeof json)["opengeodeweb_back"]["cell_attribute_names"] & TypedSchema; + readonly apply_time_series: (typeof json)["opengeodeweb_back"]["apply_time_series"] & TypedSchema; + readonly allowed_objects: (typeof json)["opengeodeweb_back"]["allowed_objects"] & TypedSchema; + readonly allowed_files: (typeof json)["opengeodeweb_back"]["allowed_files"] & TypedSchema; }; } From 97593996240ca2bdcc14d73603fd538605802a48 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Fri, 23 Oct 2026 15:43:16 +0200 Subject: [PATCH 09/17] fixes --- src/opengeodeweb_back/crs_functions.py | 29 ++++++- .../geode_objects/crs_coordinates.py | 45 ++++++++++ .../geode_objects/geode_brep.py | 9 +- .../geode_objects/geode_edged_curve2d.py | 3 +- .../geode_objects/geode_edged_curve3d.py | 3 +- .../geode_objects/geode_point_set2d.py | 3 +- .../geode_objects/geode_point_set3d.py | 3 +- .../geode_objects/geode_section.py | 7 +- .../geode_objects/geode_solid_mesh3d.py | 3 +- .../geode_objects/geode_surface_mesh2d.py | 3 +- .../geode_objects/geode_surface_mesh3d.py | 3 +- .../routes/crs/blueprint_crs.py | 41 +++++---- tests/test_crs_routes.py | 85 ++++++++++++++++++- 13 files changed, 206 insertions(+), 31 deletions(-) create mode 100644 src/opengeodeweb_back/geode_objects/crs_coordinates.py diff --git a/src/opengeodeweb_back/crs_functions.py b/src/opengeodeweb_back/crs_functions.py index 9ecd930d..ef7ff216 100644 --- a/src/opengeodeweb_back/crs_functions.py +++ b/src/opengeodeweb_back/crs_functions.py @@ -1,6 +1,8 @@ from __future__ import annotations # Standard library imports +import functools +import logging from typing import TYPE_CHECKING # Third party imports @@ -22,6 +24,8 @@ from opengeodeweb_back.geode_objects.geode_object import GeodeObject +logger = logging.getLogger(__name__) + CRS_GEODE_OBJECTS = ( GeodeBRep, GeodeSection, @@ -47,6 +51,26 @@ ) +@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: @@ -65,7 +89,10 @@ def active_crs(geode_object: GeodeObject) -> dict[str, str] | None: return None try: info = geographic_info(geode_object.crs_manager()) - except ValueError: # A model without any component mesh has no CRS + 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 diff --git a/src/opengeodeweb_back/geode_objects/crs_coordinates.py b/src/opengeodeweb_back/geode_objects/crs_coordinates.py new file mode 100644 index 00000000..0a4de673 --- /dev/null +++ b/src/opengeodeweb_back/geode_objects/crs_coordinates.py @@ -0,0 +1,45 @@ +# Standard library imports +from __future__ import annotations + +# Third party imports +import opengeode as og +import opengeode_geosciences as og_geosciences + +Mesh = ( + og.PointSet2D + | og.PointSet3D + | og.EdgedCurve2D + | og.EdgedCurve3D + | og.SurfaceMesh2D + | og.SurfaceMesh3D + | og.SolidMesh3D +) +CrsBuilder = ( + og.CoordinateReferenceSystemManagerBuilder2D | og.CoordinateReferenceSystemManagerBuilder3D +) + + +def _coordinates_name( + crs: og.CoordinateReferenceSystem2D | og.CoordinateReferenceSystem3D, +) -> str | None: + # A geographic CRS stores its coordinates in a vertex attribute named after the CRS + if isinstance( + crs, + og_geosciences.GeographicCoordinateSystem2D | og_geosciences.GeographicCoordinateSystem3D, + ): + return crs.info().name + return None + + +def delete_crs_with_coordinates(mesh: Mesh, crs_builder: CrsBuilder, crs_name: str) -> None: + """Delete an inactive CRS and its coordinates: each conversion stores new ones.""" + crs_manager = mesh.main_coordinate_reference_system_manager() + coordinates_name = _coordinates_name(crs_manager.find_coordinate_reference_system(crs_name)) + active_name = _coordinates_name(crs_manager.active_coordinate_reference_system()) + crs_builder.delete_coordinate_reference_system(crs_name) + # Attributes are only found by name: keep them when the active CRS shares it + if coordinates_name is None or coordinates_name == active_name: + return + attribute_manager = mesh.vertex_attribute_manager() + for attribute_id in attribute_manager.attribute_ids_matching_name(coordinates_name) or []: + attribute_manager.delete_attribute(attribute_id) diff --git a/src/opengeodeweb_back/geode_objects/geode_brep.py b/src/opengeodeweb_back/geode_objects/geode_brep.py index f6656c4e..db51e786 100644 --- a/src/opengeodeweb_back/geode_objects/geode_brep.py +++ b/src/opengeodeweb_back/geode_objects/geode_brep.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_model import ComponentRegistry, GeodeModel if TYPE_CHECKING: @@ -156,22 +157,22 @@ def delete_crs(self, crs_name: str) -> None: crs_builder = builder.corner_mesh_builder( corner ).main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + 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() - crs_builder.delete_coordinate_reference_system(crs_name) + 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() - crs_builder.delete_coordinate_reference_system(crs_name) + 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() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(block.mesh(), crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py index 68406d27..8ecb1af1 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_graph import GeodeGraph if TYPE_CHECKING: @@ -108,7 +109,7 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: def delete_crs(self, crs_name: str) -> None: crs_builder = self.builder().main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(self.edged_curve, crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py index 9b7914d4..9e929ef4 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_graph import GeodeGraph if TYPE_CHECKING: @@ -108,7 +109,7 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: def delete_crs(self, crs_name: str) -> None: crs_builder = self.builder().main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(self.edged_curve, crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py index 96203af1..3f32d80c 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -106,7 +107,7 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: def delete_crs(self, crs_name: str) -> None: crs_builder = self.builder().main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(self.point_set, crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py index 56820126..004261e0 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -106,7 +107,7 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: def delete_crs(self, crs_name: str) -> None: crs_builder = self.builder().main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(self.point_set, crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_section.py b/src/opengeodeweb_back/geode_objects/geode_section.py index 24d372f5..c684030b 100644 --- a/src/opengeodeweb_back/geode_objects/geode_section.py +++ b/src/opengeodeweb_back/geode_objects/geode_section.py @@ -11,6 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_model import ComponentRegistry, GeodeModel if TYPE_CHECKING: @@ -144,17 +145,17 @@ def delete_crs(self, crs_name: str) -> None: crs_builder = builder.corner_mesh_builder( corner ).main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + 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() - crs_builder.delete_coordinate_reference_system(crs_name) + 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() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(surface.mesh(), crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py index eb872b21..d0bcba01 100644 --- a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py @@ -9,6 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -54,7 +55,7 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: def delete_crs(self, crs_name: str) -> None: crs_builder = self.builder().main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(self.solid_mesh, crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py index 3f46bfd0..5c41d103 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py @@ -9,6 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -54,7 +55,7 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager2D: def delete_crs(self, crs_name: str) -> None: crs_builder = self.builder().main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(self.surface_mesh, crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py index 3fe48a77..95711b51 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py @@ -9,6 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports +from .crs_coordinates import delete_crs_with_coordinates from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -54,7 +55,7 @@ def crs_manager(self) -> og.CoordinateReferenceSystemManager3D: def delete_crs(self, crs_name: str) -> None: crs_builder = self.builder().main_coordinate_reference_system_manager_builder() - crs_builder.delete_coordinate_reference_system(crs_name) + delete_crs_with_coordinates(self.surface_mesh, crs_builder, crs_name) def assign_crs( self, crs_name: str, info: og_geosciences.GeographicCoordinateSystemInfo diff --git a/src/opengeodeweb_back/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index 6c5d817a..895618e1 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -1,21 +1,28 @@ +from __future__ import annotations + # Standard library imports -from typing import Any +from typing import TYPE_CHECKING, Any # Third party imports import flask import opengeode as og -import opengeode_geosciences as og_geosciences # Local application imports from opengeodeweb_back import geode_functions, utils_functions from opengeodeweb_back.crs_functions import ( CRS_GEODE_OBJECTS, CrsGeodeObject, + active_crs, + geographic_coordinate_systems, geographic_info, + known_geographic_info, ) from opengeodeweb_back.routes.crs import schemas from opengeodeweb_back.typed_route import typed_route +if TYPE_CHECKING: + import opengeode_geosciences as og_geosciences + routes = flask.Blueprint("crs", __name__, url_prefix="/crs") @@ -32,6 +39,16 @@ def _prepare_crs_name( geode_object.delete_crs(crs_name) +def _known_info( + geode_object: CrsGeodeObject, authority: str, code: str +) -> og_geosciences.GeographicCoordinateSystemInfo: + # PROJ crashes the process on an unknown CRS: only accept listed ones, with their own name + info = known_geographic_info(geode_object, authority, code) + if info is None: + flask.abort(400, f"Unknown CRS {authority}:{code}") + return info + + def _crs_response(saved: dict[str, Any]) -> dict[str, Any]: # Only the fields the front refreshes after a CRS change return {key: saved[key] for key in ("crs", "binary_light_viewable") if key in saved} @@ -40,14 +57,9 @@ def _crs_response(saved: dict[str, Any]) -> dict[str, Any]: @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) - infos = ( - og_geosciences.GeographicCoordinateSystem3D.geographic_coordinate_systems() - if geode_object.is_3d() - else og_geosciences.GeographicCoordinateSystem2D.geographic_coordinate_systems() - ) crs_list = [ schemas.geographic_list.CRSList(name=info.name, code=info.code, authority=info.authority) - for info in infos + for info in geographic_coordinate_systems(geode_object.is_3d()).values() ] return schemas.GeographicListResponse(crs_list=crs_list) @@ -78,13 +90,12 @@ def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeograp ) if not isinstance(geode_object, CRS_GEODE_OBJECTS): flask.abort(400, f"{params.id} does not support coordinate reference systems") - info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) + info = _known_info(geode_object, params.authority, params.code) _prepare_crs_name(geode_object, info.authority_code()) geode_object.assign_crs(info.authority_code(), info) - saved = utils_functions.save_all_viewables_and_return_info( - geode_object, data, geode_functions.data_file_path(params.id) - ) - return schemas.AssignGeographicResponse.from_dict(_crs_response(saved)) + # Assigning only relabels the coordinates: the viewables are unchanged + geode_object.save(geode_functions.data_file_path(params.id, data.native_file)) + return schemas.AssignGeographicResponse.from_dict({"crs": active_crs(geode_object)}) @typed_route(routes, schemas.convert_geographic_route) @@ -98,7 +109,7 @@ def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeog crs_manager = geode_object.crs_manager() if geographic_info(crs_manager) is None: flask.abort(400, "Assign a geographic CRS before converting") - info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) + info = _known_info(geode_object, params.authority, params.code) _prepare_crs_name(geode_object, info.authority_code()) try: geode_object.convert_crs(info.authority_code(), info) @@ -128,13 +139,13 @@ def align(params: schemas.Align) -> schemas.AlignResponse: flask.abort(400, f"{params.id} does not support coordinate reference systems") if geographic_info(geode_object.crs_manager()) is not None: flask.abort(400, "Data already has a geographic CRS, use convert") + info = _known_info(geode_object, params.authority, params.code) try: source = _coordinate_system(params.source_points) target = _coordinate_system(params.target_points) except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points flask.abort(400, "Points must not be colinear") geode_object.create_crs("aligned", source, target) - info = og_geosciences.GeographicCoordinateSystemInfo(params.authority, params.code, params.name) geode_object.assign_crs(info.authority_code(), info) saved = utils_functions.save_all_viewables_and_return_info( geode_object, data, geode_functions.data_file_path(params.id) diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index 89fcd3c9..ab8d97ef 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -11,7 +11,7 @@ # Local application imports from opengeodeweb_microservice.database.data import Data -from opengeodeweb_back import geode_functions, test_utils +from opengeodeweb_back import crs_functions, geode_functions, test_utils from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D if TYPE_CHECKING: @@ -293,3 +293,86 @@ def test_saved_native_file_keeps_its_crs(client: FlaskClient) -> None: 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 From 4c72db6bd8c1ba8e8c4a79e46149103019bb39d7 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Fri, 23 Oct 2026 16:56:07 +0200 Subject: [PATCH 10/17] fix(crs): temporary workaround so packaged apps find the PROJ database PyInstaller loads libproj away from opengeode/share/proj, so PROJ cannot find proj.db: the CRS list is empty and conversions fail. TEMPORARY: default PROJ_DATA to the opengeode share folder and bundle it in the spec. To remove once OpenGeode sets PROJ_DATA itself and ships a PyInstaller hook. --- opengeodeweb-back.spec | 2 ++ src/opengeodeweb_back/crs_functions.py | 10 ++++++++-- tests/test_crs_routes.py | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) 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/src/opengeodeweb_back/crs_functions.py b/src/opengeodeweb_back/crs_functions.py index ef7ff216..f9ad706d 100644 --- a/src/opengeodeweb_back/crs_functions.py +++ b/src/opengeodeweb_back/crs_functions.py @@ -3,9 +3,12 @@ # Standard library imports import functools import logging +import os +from pathlib import Path from typing import TYPE_CHECKING # Third party imports +import opengeode as og import opengeode_geosciences as og_geosciences # Local application imports @@ -20,12 +23,15 @@ from opengeodeweb_back.geode_objects.geode_surface_mesh3d import GeodeSurfaceMesh3D if TYPE_CHECKING: - import opengeode as og - from opengeodeweb_back.geode_objects.geode_object import GeodeObject logger = logging.getLogger(__name__) +# TEMPORARY workaround, to remove once OpenGeode sets it itself (and ships a PyInstaller hook): +# PROJ looks for its database next to the loaded libproj, which packaged apps (PyInstaller) +# Load from elsewhere, so point it to the one opengeode ships +os.environ.setdefault("PROJ_DATA", str(Path(og.__file__).parent / "share" / "proj")) + CRS_GEODE_OBJECTS = ( GeodeBRep, GeodeSection, diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index ab8d97ef..98739682 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -1,6 +1,7 @@ from __future__ import annotations # Standard library imports +import os import shutil from pathlib import Path from typing import TYPE_CHECKING @@ -376,3 +377,8 @@ def unreadable(*_args: object) -> None: 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() From dc7b1392cc0139c54efc2971561b35c7e299d5f1 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Mon, 26 Oct 2026 17:14:52 +0100 Subject: [PATCH 11/17] feat(crs): re-align from the original coordinates and warn on implausible assign - Assign keeps the original coordinates as the "points" CRS, so every re-align restarts from them (Align is no longer refused on georeferenced data). - Source points carry a `picked` flag: picked points are mapped from the displayed coordinates back to "points", typed points are original ones. - assign_geographic returns `implausible` when the data lies outside the CRS area of use (read from the PROJ database shipped with opengeode). Co-Authored-By: Claude Opus 5.5 --- src/opengeodeweb_back/crs_functions.py | 176 ++++++++++++++++++ .../geode_objects/crs_coordinates.py | 3 + .../geode_objects/geode_brep.py | 35 ++++ .../geode_objects/geode_edged_curve2d.py | 9 + .../geode_objects/geode_edged_curve3d.py | 9 + .../geode_objects/geode_point_set2d.py | 9 + .../geode_objects/geode_point_set3d.py | 9 + .../geode_objects/geode_section.py | 29 +++ .../geode_objects/geode_solid_mesh3d.py | 9 + .../geode_objects/geode_surface_mesh2d.py | 9 + .../geode_objects/geode_surface_mesh3d.py | 9 + .../routes/crs/blueprint_crs.py | 97 +++++++++- .../routes/crs/schemas/align.json | 4 + .../routes/crs/schemas/align.py | 3 +- .../routes/crs/schemas/assign_geographic.json | 3 + .../routes/crs/schemas/assign_geographic.py | 1 + tests/test_crs_routes.py | 137 ++++++++++++-- 17 files changed, 529 insertions(+), 22 deletions(-) diff --git a/src/opengeodeweb_back/crs_functions.py b/src/opengeodeweb_back/crs_functions.py index f9ad706d..af6fc353 100644 --- a/src/opengeodeweb_back/crs_functions.py +++ b/src/opengeodeweb_back/crs_functions.py @@ -1,9 +1,12 @@ 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 @@ -23,10 +26,17 @@ from opengeodeweb_back.geode_objects.geode_surface_mesh3d import GeodeSurfaceMesh3D if TYPE_CHECKING: + from opengeodeweb_back.geode_objects.crs_coordinates import Mesh from opengeodeweb_back.geode_objects.geode_object import GeodeObject logger = logging.getLogger(__name__) +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 @@ -103,3 +113,169 @@ def active_crs(geode_object: GeodeObject) -> dict[str, str] | 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') +""" + + +@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. + + The bindings do not expose them: read the PROJ database opengeode ships. + """ + 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 = connection.execute(AREA_OF_USE_QUERY, (authority, code)).fetchall() + except sqlite3.Error as error: + logger.warning("Cannot read the CRS area of use: %s", error) + return [] + return [(float(s), float(n), float(w), float(e)) for s, n, w, e in rows] + + +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 diff --git a/src/opengeodeweb_back/geode_objects/crs_coordinates.py b/src/opengeodeweb_back/geode_objects/crs_coordinates.py index 0a4de673..ec691799 100644 --- a/src/opengeodeweb_back/geode_objects/crs_coordinates.py +++ b/src/opengeodeweb_back/geode_objects/crs_coordinates.py @@ -34,6 +34,9 @@ def _coordinates_name( 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) diff --git a/src/opengeodeweb_back/geode_objects/geode_brep.py b/src/opengeodeweb_back/geode_objects/geode_brep.py index db51e786..00d013e8 100644 --- a/src/opengeodeweb_back/geode_objects/geode_brep.py +++ b/src/opengeodeweb_back/geode_objects/geode_brep.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeModelType + from .crs_coordinates import Mesh + class GeodeBRep(GeodeModel): brep: og.BRep @@ -174,6 +176,39 @@ def delete_crs(self, crs_name: str) -> None: ).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: diff --git a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py index 8ecb1af1..7f79f71e 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodeEdgedCurve2D(GeodeGraph): edged_curve: og.EdgedCurve2D @@ -111,6 +113,13 @@ 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 9e929ef4..261b6ce5 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodeEdgedCurve3D(GeodeGraph): edged_curve: og.EdgedCurve3D @@ -111,6 +113,13 @@ 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_point_set2d.py b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py index 3f32d80c..e565abd0 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set2d.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodePointSet2D(GeodeVertexSet): point_set: og.PointSet2D @@ -109,6 +111,13 @@ 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 004261e0..ee1583a0 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeMeshType + from .crs_coordinates import Mesh + class GeodePointSet3D(GeodeVertexSet): point_set: og.PointSet3D @@ -109,6 +111,13 @@ 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_section.py b/src/opengeodeweb_back/geode_objects/geode_section.py index c684030b..a9191374 100644 --- a/src/opengeodeweb_back/geode_objects/geode_section.py +++ b/src/opengeodeweb_back/geode_objects/geode_section.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import GeodeModelType + from .crs_coordinates import Mesh + class GeodeSection(GeodeModel): section: og.Section @@ -157,6 +159,33 @@ def delete_crs(self, crs_name: str) -> None: ).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 d0bcba01..6a976d66 100644 --- a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py @@ -15,6 +15,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import ViewerElementsType + from .crs_coordinates import Mesh + class GeodeSolidMesh3D(GeodeVertexSet): solid_mesh: og.SolidMesh3D @@ -57,6 +59,13 @@ 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: diff --git a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py index 5c41d103..5ec48d2e 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py @@ -15,6 +15,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import ViewerElementsType + from .crs_coordinates import Mesh + class GeodeSurfaceMesh2D(GeodeVertexSet): surface_mesh: og.SurfaceMesh2D @@ -57,6 +59,13 @@ 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 95711b51..8c208ac5 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py @@ -15,6 +15,8 @@ if TYPE_CHECKING: from opengeodeweb_microservice.database.data_types import ViewerElementsType + from .crs_coordinates import Mesh + class GeodeSurfaceMesh3D(GeodeVertexSet): surface_mesh: og.SurfaceMesh3D @@ -57,6 +59,13 @@ 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/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index 895618e1..4e4617fe 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -11,11 +11,14 @@ from opengeodeweb_back import geode_functions, utils_functions from opengeodeweb_back.crs_functions import ( CRS_GEODE_OBJECTS, + ORIGINAL_CRS, CrsGeodeObject, active_crs, + coordinates_implausible, geographic_coordinate_systems, geographic_info, known_geographic_info, + original_coordinates, ) from opengeodeweb_back.routes.crs import schemas from opengeodeweb_back.typed_route import typed_route @@ -82,6 +85,18 @@ def active(params: schemas.Active) -> schemas.ActiveResponse: ) +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) + + @typed_route(routes, schemas.assign_geographic_route) def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeographicResponse: data = geode_functions.get_data_info(params.id) @@ -92,10 +107,17 @@ def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeograp flask.abort(400, f"{params.id} does not support coordinate reference systems") info = _known_info(geode_object, params.authority, params.code) _prepare_crs_name(geode_object, info.authority_code()) + _keep_original_coordinates(geode_object) geode_object.assign_crs(info.authority_code(), info) # Assigning only relabels the coordinates: the viewables are unchanged geode_object.save(geode_functions.data_file_path(params.id, data.native_file)) - return schemas.AssignGeographicResponse.from_dict({"crs": active_crs(geode_object)}) + # A warning only: the label is kept, the front offers to align instead + return schemas.AssignGeographicResponse.from_dict( + { + "crs": active_crs(geode_object), + "implausible": coordinates_implausible(geode_object, info), + } + ) @typed_route(routes, schemas.convert_geographic_route) @@ -122,10 +144,60 @@ def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeog return schemas.ConvertGeographicResponse.from_dict(_crs_response(saved)) -def _coordinate_system( +def _points( points: list[schemas.SourcePoint] | list[schemas.TargetPoint], -) -> og.CoordinateSystem2D: - origin, first, second = (og.Point2D([point.x, point.y]) for point in points) +) -> list[og.Point2D]: + return [og.Point2D([point.x, point.y]) for point in points] + + +def _restore_original_coordinates( + geode_object: CrsGeodeObject, source_points: list[schemas.SourcePoint] +) -> list[og.Point2D]: + """Restart from the loaded coordinates: alignments must not stack. + + Picked points lie on the displayed data: they are mapped back as well. + Typed points are already original coordinates. + """ + points = _points(source_points) + original_points = original_coordinates(geode_object, points) + if original_points is None and any(point.picked for point in source_points): + flask.abort(400, "Data has no three non-colinear vertices to re-align") + geode_object.set_active_crs(ORIGINAL_CRS) + _delete_other_crs(geode_object, ORIGINAL_CRS) + if original_points is None: + return points + return [ + original if source.picked else typed + for source, original, typed in zip(source_points, original_points, points, strict=True) + ] + + +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 _coordinate_system(points: list[og.Point2D]) -> og.CoordinateSystem2D: + origin, first, second = points return og.CoordinateSystem2D(origin, [first, second]) @@ -137,15 +209,24 @@ def align(params: schemas.Align) -> schemas.AlignResponse: ) if not isinstance(geode_object, CRS_GEODE_OBJECTS): flask.abort(400, f"{params.id} does not support coordinate reference systems") - if geographic_info(geode_object.crs_manager()) is not None: - flask.abort(400, "Data already has a geographic CRS, use convert") info = _known_info(geode_object, params.authority, params.code) + source_points = _points(params.source_points) + # Without original coordinates, align the active ones: the points were picked on them + from_active = False + if geographic_info(geode_object.crs_manager()) is not None: + if _has_original_coordinates(geode_object): + source_points = _restore_original_coordinates(geode_object, params.source_points) + else: + from_active = True try: - source = _coordinate_system(params.source_points) - target = _coordinate_system(params.target_points) + source = _coordinate_system(source_points) + target = _coordinate_system(_points(params.target_points)) except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points flask.abort(400, "Points must not be colinear") geode_object.create_crs("aligned", source, target) + 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(info.authority_code(), info) saved = utils_functions.save_all_viewables_and_return_info( geode_object, data, geode_functions.data_file_path(params.id) diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.json b/src/opengeodeweb_back/routes/crs/schemas/align.json index 1fc0113c..70700d06 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.json +++ b/src/opengeodeweb_back/routes/crs/schemas/align.json @@ -22,6 +22,10 @@ }, "y": { "type": "number" + }, + "picked": { + "type": "boolean", + "default": false } }, "required": [ diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.py b/src/opengeodeweb_back/routes/crs/schemas/align.py index 9d5a47d1..77743d3e 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.py +++ b/src/opengeodeweb_back/routes/crs/schemas/align.py @@ -4,7 +4,7 @@ from dataclasses_json import DataClassJsonMixin from opengeodeweb_microservice.schemas import print_dataclass from dataclasses import dataclass -from typing import List +from typing import Optional, List @dataclass @@ -14,6 +14,7 @@ def __post_init__(self) -> None: x: float y: float + picked: Optional[bool] = None @dataclass diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json index 1b3059e8..f4863f4a 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json @@ -52,6 +52,9 @@ "name" ], "additionalProperties": false + }, + "implausible": { + "type": "boolean" } }, "required": [], diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py index c8ed1204..9d73b648 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py @@ -33,6 +33,7 @@ def __post_init__(self) -> None: print_dataclass(self) crs: Optional[CRS] = None + implausible: Optional[bool] = None assign_geographic_route = Route( diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index 98739682..a50a0976 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -4,7 +4,7 @@ import os import shutil from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any # Third party imports import pytest @@ -13,6 +13,7 @@ from opengeodeweb_microservice.database.data import Data from opengeodeweb_back import crs_functions, geode_functions, test_utils +from opengeodeweb_back.geode_objects.geode_brep import GeodeBRep from opengeodeweb_back.geode_objects.geode_point_set3d import GeodePointSet3D if TYPE_CHECKING: @@ -170,12 +171,13 @@ def test_convert_geographic_errors(client: FlaskClient) -> None: TARGET = [{"x": 1000, "y": 2000}, {"x": 1000, "y": 2020}, {"x": 980, "y": 2000}] -def _create_point_set(client: FlaskClient) -> str: - points = [ - {"x": 0, "y": 0, "z": 5}, - {"x": 10, "y": 0, "z": 5}, - {"x": 0, "y": 10, "z": 5}, - ] +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} ) @@ -223,12 +225,103 @@ def test_align_errors(client: FlaskClient) -> None: ) assert response.status_code == 400 - client.post("/opengeodeweb_back/crs/assign_geographic", json={"id": point_set_id, **LAMBERT}) - response = client.post( - route, - json={"id": point_set_id, "source_points": SOURCE, "target_points": TARGET, **LAMBERT}, - ) - assert response.status_code == 400 + +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, **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, **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"} @@ -382,3 +475,21 @@ def unreadable(*_args: object) -> 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 From f3e7db508a833c0cd219a5cc3d3aa0b5d4789353 Mon Sep 17 00:00:00 2001 From: MaxNumerique <144453705+MaxNumerique@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:28:33 +0000 Subject: [PATCH 12/17] Apply prepare changes --- opengeodeweb_back_schemas.json | 7 +++++++ opengeodeweb_back_typed_schemas.d.ts | 8 +++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index b464a7d4..dba1c749 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -802,6 +802,9 @@ "name" ], "additionalProperties": false + }, + "implausible": { + "type": "boolean" } }, "required": [], @@ -833,6 +836,10 @@ }, "y": { "type": "number" + }, + "picked": { + "type": "boolean", + "default": false } }, "required": [ diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index 7c8bb273..f481a575 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -196,7 +196,8 @@ export interface CRSAssignGeographicParams { } export interface CRSAssignGeographicResponse { - crs?: CRSAssignGeographicResponseCRS; + crs?: CRSAssignGeographicResponseCRS; + implausible?: boolean; } export interface CRSAssignGeographicResponseCRS { @@ -215,8 +216,9 @@ export interface CRSAlignParams { } export interface SourcePoint { - x: number; - y: number; + picked?: boolean; + x: number; + y: number; } export interface TargetPoint { From 5a1083d4e646f3afa8566b00956526eaad711902 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 27 Oct 2026 10:47:16 +0100 Subject: [PATCH 13/17] feat(crs): align without distortion by default - New `mode` param on crs/align: "similarity" (default) or "affine". - Similarity takes 1 pair (translation) or 2 pairs (translation, rotation and uniform scale). Two frames with perpendicular axes of equal length go through the existing create_*_coordinate_system, then every Z of the aligned CRS is scaled around Z = 0 (meshes and every BRep component). - The response returns the applied `scale` and `rotation` (degrees). - Similarity onto a non projected CRS (degrees) is refused: Z would be flattened and (lat, lon) axes would mirror the data. - Affine keeps the 3 pairs behaviour (X/Y only). Co-Authored-By: Claude Opus 5.5 --- opengeodeweb_back_schemas.json | 17 ++- opengeodeweb_back_typed_schemas.d.ts | 5 + src/opengeodeweb_back/crs_functions.py | 49 +++++-- .../geode_objects/crs_coordinates.py | 13 ++ .../geode_objects/geode_brep.py | 14 +- .../geode_objects/geode_edged_curve3d.py | 5 +- .../geode_objects/geode_point_set3d.py | 5 +- .../geode_objects/geode_solid_mesh3d.py | 5 +- .../geode_objects/geode_surface_mesh3d.py | 5 +- .../routes/crs/blueprint_crs.py | 81 ++++++++++- .../routes/crs/schemas/align.json | 17 ++- .../routes/crs/schemas/align.py | 9 ++ tests/test_crs_routes.py | 127 +++++++++++++++++- 13 files changed, 325 insertions(+), 27 deletions(-) diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index dba1c749..a32aca3e 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -824,9 +824,16 @@ "minLength": 32, "maxLength": 32 }, + "mode": { + "type": "string", + "enum": [ + "similarity", + "affine" + ] + }, "source_points": { "type": "array", - "minItems": 3, + "minItems": 1, "maxItems": 3, "items": { "type": "object", @@ -851,7 +858,7 @@ }, "target_points": { "type": "array", - "minItems": 3, + "minItems": 1, "maxItems": 3, "items": { "type": "object", @@ -917,6 +924,12 @@ "name" ], "additionalProperties": false + }, + "scale": { + "type": "number" + }, + "rotation": { + "type": "number" } }, "required": [], diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index f481a575..d1a3e165 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -210,11 +210,14 @@ export interface CRSAlignParams { authority: string; code: string; id: string; + mode?: Mode; name: string; source_points: SourcePoint[]; target_points: TargetPoint[]; } +export type Mode = "similarity" | "affine"; + export interface SourcePoint { picked?: boolean; x: number; @@ -229,6 +232,8 @@ export interface TargetPoint { export interface CRSAlignResponse { binary_light_viewable?: string; crs?: CRSAlignResponseCRS; + rotation?: number; + scale?: number; } export interface CRSAlignResponseCRS { diff --git a/src/opengeodeweb_back/crs_functions.py b/src/opengeodeweb_back/crs_functions.py index af6fc353..ed51ef48 100644 --- a/src/opengeodeweb_back/crs_functions.py +++ b/src/opengeodeweb_back/crs_functions.py @@ -8,7 +8,7 @@ import os import sqlite3 from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any # Third party imports import opengeode as og @@ -54,6 +54,15 @@ GeodeSolidMesh3D, ) +# Objects with a Z axis: alignment without distortion scales it too +CRS_GEODE_OBJECTS_3D = ( + GeodeBRep, + GeodePointSet3D, + GeodeEdgedCurve3D, + GeodeSurfaceMesh3D, + GeodeSolidMesh3D, +) + CrsGeodeObject = ( GeodeBRep | GeodeSection @@ -147,24 +156,48 @@ def convert_xy_corners( """ -@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. +# 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 = ? +""" - The bindings do not expose them: read the PROJ database opengeode ships. + +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 = connection.execute(AREA_OF_USE_QUERY, (authority, code)).fetchall() + rows: list[tuple[Any, ...]] = connection.execute(query, parameters).fetchall() except sqlite3.Error as error: - logger.warning("Cannot read the CRS area of use: %s", error) - return [] + 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], diff --git a/src/opengeodeweb_back/geode_objects/crs_coordinates.py b/src/opengeodeweb_back/geode_objects/crs_coordinates.py index ec691799..e974d3be 100644 --- a/src/opengeodeweb_back/geode_objects/crs_coordinates.py +++ b/src/opengeodeweb_back/geode_objects/crs_coordinates.py @@ -17,6 +17,10 @@ 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( @@ -46,3 +50,12 @@ def delete_crs_with_coordinates(mesh: Mesh, crs_builder: CrsBuilder, crs_name: s 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 00d013e8..b0a3c1dd 100644 --- a/src/opengeodeweb_back/geode_objects/geode_brep.py +++ b/src/opengeodeweb_back/geode_objects/geode_brep.py @@ -11,7 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports -from .crs_coordinates import delete_crs_with_coordinates +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_model import ComponentRegistry, GeodeModel if TYPE_CHECKING: @@ -231,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_curve3d.py b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py index 261b6ce5..71609136 100644 --- a/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py @@ -11,7 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports -from .crs_coordinates import delete_crs_with_coordinates +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_graph import GeodeGraph if TYPE_CHECKING: @@ -146,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_set3d.py b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py index ee1583a0..f84f7ca1 100644 --- a/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_point_set3d.py @@ -11,7 +11,7 @@ import opengeode_inspector as og_inspector # Local application imports -from .crs_coordinates import delete_crs_with_coordinates +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -144,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_solid_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py index 6a976d66..700e5cd4 100644 --- a/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py @@ -9,7 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports -from .crs_coordinates import delete_crs_with_coordinates +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -93,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_mesh3d.py b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py index 8c208ac5..ed804276 100644 --- a/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +++ b/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py @@ -9,7 +9,7 @@ import opengeode_inspector as og_inspector # Local application imports -from .crs_coordinates import delete_crs_with_coordinates +from .crs_coordinates import delete_crs_with_coordinates, scale_z from .geode_vertex_set import GeodeVertexSet if TYPE_CHECKING: @@ -93,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/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index 4e4617fe..d44301c6 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -1,6 +1,7 @@ from __future__ import annotations # Standard library imports +import math from typing import TYPE_CHECKING, Any # Third party imports @@ -11,12 +12,14 @@ from opengeodeweb_back import geode_functions, utils_functions from opengeodeweb_back.crs_functions import ( CRS_GEODE_OBJECTS, + CRS_GEODE_OBJECTS_3D, ORIGINAL_CRS, CrsGeodeObject, active_crs, coordinates_implausible, geographic_coordinate_systems, geographic_info, + is_projected, known_geographic_info, original_coordinates, ) @@ -28,6 +31,11 @@ routes = flask.Blueprint("crs", __name__, url_prefix="/crs") +SIMILARITY = "similarity" +AFFINE = "affine" +SIMILARITY_PAIRS = (1, 2) +AFFINE_PAIRS = 3 + def _prepare_crs_name( geode_object: CrsGeodeObject, @@ -201,6 +209,48 @@ def _coordinate_system(points: list[og.Point2D]) -> og.CoordinateSystem2D: return og.CoordinateSystem2D(origin, [first, second]) +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: + flask.abort(400, "The two points of the source or of the target must differ") + 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 _check_pair_count(mode: str, params: schemas.Align) -> None: + count = len(params.source_points) + if count != len(params.target_points): + flask.abort(400, "Source and target points must come in pairs") + if mode == AFFINE and count != AFFINE_PAIRS: + flask.abort(400, "Affine alignment needs 3 pairs of points") + if mode == SIMILARITY and count not in SIMILARITY_PAIRS: + flask.abort(400, "Alignment without distortion needs 1 or 2 pairs of points") + + @typed_route(routes, schemas.align_route) def align(params: schemas.Align) -> schemas.AlignResponse: data = geode_functions.get_data_info(params.id) @@ -210,6 +260,11 @@ def align(params: schemas.Align) -> schemas.AlignResponse: if not isinstance(geode_object, CRS_GEODE_OBJECTS): flask.abort(400, f"{params.id} does not support coordinate reference systems") info = _known_info(geode_object, params.authority, params.code) + mode = params.mode.value if params.mode is not None else SIMILARITY + _check_pair_count(mode, params) + # In degrees, a uniform scale would flatten Z and (lat, lon) axes would mirror the data + if mode == SIMILARITY and not is_projected(params.authority, params.code): + flask.abort(400, "Alignment without distortion needs a projected CRS: use the affine mode") source_points = _points(params.source_points) # Without original coordinates, align the active ones: the points were picked on them from_active = False @@ -218,12 +273,23 @@ def align(params: schemas.Align) -> schemas.AlignResponse: source_points = _restore_original_coordinates(geode_object, params.source_points) else: from_active = True - try: - source = _coordinate_system(source_points) - target = _coordinate_system(_points(params.target_points)) - except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points - flask.abort(400, "Points must not be colinear") + target_points = _points(params.target_points) + scale: float | None = None + rotation: float | None = None + if mode == SIMILARITY: + source, target, scale, rotation = _similarity_coordinate_systems( + source_points, target_points + ) + else: + try: + source = _coordinate_system(source_points) + target = _coordinate_system(target_points) + except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points + flask.abort(400, "Points must not be colinear") geode_object.create_crs("aligned", source, target) + # The aligned CRS is active: scale its Z so the data keeps its proportions + 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) @@ -232,4 +298,7 @@ def align(params: schemas.Align) -> schemas.AlignResponse: geode_object, data, geode_functions.data_file_path(params.id) ) # The front rebuilds its local actor from the new light viewable - return schemas.AlignResponse.from_dict(_crs_response(saved)) + response = _crs_response(saved) + if scale is not None: + response.update(scale=scale, rotation=rotation) + return schemas.AlignResponse.from_dict(response) diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.json b/src/opengeodeweb_back/routes/crs/schemas/align.json index 70700d06..da147d45 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.json +++ b/src/opengeodeweb_back/routes/crs/schemas/align.json @@ -10,9 +10,16 @@ "minLength": 32, "maxLength": 32 }, + "mode": { + "type": "string", + "enum": [ + "similarity", + "affine" + ] + }, "source_points": { "type": "array", - "minItems": 3, + "minItems": 1, "maxItems": 3, "items": { "type": "object", @@ -37,7 +44,7 @@ }, "target_points": { "type": "array", - "minItems": 3, + "minItems": 1, "maxItems": 3, "items": { "type": "object", @@ -103,6 +110,12 @@ "name" ], "additionalProperties": false + }, + "scale": { + "type": "number" + }, + "rotation": { + "type": "number" } }, "required": [], diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.py b/src/opengeodeweb_back/routes/crs/schemas/align.py index 77743d3e..09cec926 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.py +++ b/src/opengeodeweb_back/routes/crs/schemas/align.py @@ -3,10 +3,16 @@ from typing import Optional 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: @@ -37,6 +43,7 @@ def __post_init__(self) -> None: name: str source_points: List[SourcePoint] target_points: List[TargetPoint] + mode: Optional[Mode] = None @dataclass @@ -56,6 +63,8 @@ def __post_init__(self) -> None: binary_light_viewable: Optional[str] = None crs: Optional[CRS] = None + rotation: Optional[float] = None + scale: Optional[float] = None align_route = Route( diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index a50a0976..c422d770 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -18,6 +18,7 @@ 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"} @@ -196,6 +197,7 @@ def get_full_data() -> test_utils.JsonData: "id": point_set_id, "source_points": [dict(point) for point in SOURCE], "target_points": [dict(point) for point in TARGET], + "mode": "affine", **LAMBERT, } @@ -211,7 +213,16 @@ def get_full_data() -> test_utils.JsonData: response = client.post(route, json={**get_full_data(), "id": brep_id}) assert response.status_code == 200 - test_utils.test_route_wrong_params(client, route, get_full_data) + def 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: @@ -221,9 +232,105 @@ def test_align_errors(client: FlaskClient) -> None: response = client.post( route, - json={"id": point_set_id, "source_points": colinear, "target_points": TARGET, **LAMBERT}, + 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 _crs_names(client: FlaskClient, data_id: str) -> list[str]: @@ -240,7 +347,13 @@ def test_realign(client: FlaskClient) -> None: 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, **LAMBERT}, + json={ + "id": data_id, + "source_points": source, + "target_points": target, + "mode": "affine", + **LAMBERT, + }, ) return response.status_code @@ -282,7 +395,13 @@ def align( ) -> int: response = client.post( route, - json={"id": data_id, "source_points": source, "target_points": target, **LAMBERT}, + json={ + "id": data_id, + "source_points": source, + "target_points": target, + "mode": "affine", + **LAMBERT, + }, ) return response.status_code From 46ede82570e64cad3f8b88655bce9e08bcdc89ef Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 27 Oct 2026 11:50:38 +0100 Subject: [PATCH 14/17] feat(crs): align linked data sharing the same local frame - New optional `linked_ids` on crs/align: data sharing the local frame of the aligned one (a model and its wells) are aligned in the same request, with the same frames and Z scale. - The alignment is computed once, on the aligned data (picked points mapped back to its original coordinates); each linked data restarts from its own original coordinates. - Every data is loaded and checked before any file is written: an unknown or unsupported linked id leaves everything untouched. - The response lists the linked data with their CRS and light viewable. Co-Authored-By: Claude Opus 5.5 --- opengeodeweb_back_schemas.json | 47 ++++++ opengeodeweb_back_typed_schemas.d.ts | 14 ++ .../routes/crs/blueprint_crs.py | 134 ++++++++++++------ .../routes/crs/schemas/align.json | 47 ++++++ .../routes/crs/schemas/align.py | 27 +++- tests/test_crs_routes.py | 66 +++++++++ 6 files changed, 291 insertions(+), 44 deletions(-) diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index a32aca3e..65cf6f6f 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -824,6 +824,15 @@ "minLength": 32, "maxLength": 32 }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, "mode": { "type": "string", "enum": [ @@ -930,6 +939,44 @@ }, "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": [], diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index d1a3e165..af350215 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -210,6 +210,7 @@ export interface CRSAlignParams { authority: string; code: string; id: string; + linked_ids?: string[]; mode?: Mode; name: string; source_points: SourcePoint[]; @@ -232,6 +233,7 @@ export interface TargetPoint { export interface CRSAlignResponse { binary_light_viewable?: string; crs?: CRSAlignResponseCRS; + linked?: Linked[]; rotation?: number; scale?: number; } @@ -242,6 +244,18 @@ export interface CRSAlignResponseCRS { name: string; } +export interface Linked { + binary_light_viewable?: string; + crs?: LinkedCRS; + id: string; +} + +export interface LinkedCRS { + authority: string; + code: string; + name: string; +} + export interface CRSActiveParams { id: string; } diff --git a/src/opengeodeweb_back/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index d44301c6..9bad5b58 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -2,6 +2,7 @@ # Standard library imports import math +from dataclasses import dataclass from typing import TYPE_CHECKING, Any # Third party imports @@ -28,6 +29,7 @@ if TYPE_CHECKING: import opengeode_geosciences as og_geosciences + from opengeodeweb_microservice.database.data import Data routes = flask.Blueprint("crs", __name__, url_prefix="/crs") @@ -158,21 +160,19 @@ def _points( return [og.Point2D([point.x, point.y]) for point in points] -def _restore_original_coordinates( +def _original_source_points( geode_object: CrsGeodeObject, source_points: list[schemas.SourcePoint] ) -> list[og.Point2D]: - """Restart from the loaded coordinates: alignments must not stack. + """Source points in the loaded coordinates: alignments must not stack. - Picked points lie on the displayed data: they are mapped back as well. + Picked points lie on the displayed data: they are mapped back. Typed points are already original coordinates. """ points = _points(source_points) original_points = original_coordinates(geode_object, points) - if original_points is None and any(point.picked for point in source_points): - flask.abort(400, "Data has no three non-colinear vertices to re-align") - geode_object.set_active_crs(ORIGINAL_CRS) - _delete_other_crs(geode_object, ORIGINAL_CRS) if original_points is None: + if any(point.picked for point in source_points): + flask.abort(400, "Data has no three non-colinear vertices to re-align") return points return [ original if source.picked else typed @@ -180,6 +180,17 @@ def _restore_original_coordinates( ] +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 _crs_names(geode_object: CrsGeodeObject) -> set[str]: # Model components may hold different CRS: gather them all managers = [ @@ -251,54 +262,95 @@ def _check_pair_count(mode: str, params: schemas.Align) -> None: flask.abort(400, "Alignment without distortion needs 1 or 2 pairs of points") -@typed_route(routes, schemas.align_route) -def align(params: schemas.Align) -> schemas.AlignResponse: - data = geode_functions.get_data_info(params.id) +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(params.id, data.native_file) + geode_functions.data_file_path(data_id, data.native_file) ) if not isinstance(geode_object, CRS_GEODE_OBJECTS): - flask.abort(400, f"{params.id} does not support coordinate reference systems") - info = _known_info(geode_object, params.authority, params.code) - mode = params.mode.value if params.mode is not None else SIMILARITY - _check_pair_count(mode, params) - # In degrees, a uniform scale would flatten Z and (lat, lon) axes would mirror the data - if mode == SIMILARITY and not is_projected(params.authority, params.code): - flask.abort(400, "Alignment without distortion needs a projected CRS: use the affine mode") - source_points = _points(params.source_points) - # Without original coordinates, align the active ones: the points were picked on them - from_active = False - if geographic_info(geode_object.crs_manager()) is not None: - if _has_original_coordinates(geode_object): - source_points = _restore_original_coordinates(geode_object, params.source_points) - else: - from_active = True - target_points = _points(params.target_points) - scale: float | None = None - rotation: float | None = None + flask.abort(400, f"{data_id} does not support coordinate reference systems") + return data, geode_object + + +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: - source, target, scale, rotation = _similarity_coordinate_systems( - source_points, target_points - ) - else: - try: - source = _coordinate_system(source_points) - target = _coordinate_system(target_points) - except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points - flask.abort(400, "Points must not be colinear") - geode_object.create_crs("aligned", source, target) + return _similarity_coordinate_systems(source_points, target_points) + try: + source = _coordinate_system(source_points) + target = _coordinate_system(target_points) + except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points + flask.abort(400, "Points must not be colinear") + return source, target, None, None + + +@dataclass(frozen=True) +class _Alignment: + source: og.CoordinateSystem2D + target: og.CoordinateSystem2D + scale: float | None + info: og_geosciences.GeographicCoordinateSystemInfo + + +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(info.authority_code(), info) + geode_object.assign_crs(alignment.info.authority_code(), alignment.info) + + +@typed_route(routes, schemas.align_route) +def align(params: schemas.Align) -> schemas.AlignResponse: + data, geode_object = _load_crs_object(params.id) + info = _known_info(geode_object, params.authority, params.code) + mode = params.mode.value if params.mode is not None else SIMILARITY + _check_pair_count(mode, params) + # In degrees, a uniform scale would flatten Z and (lat, lon) axes would mirror the data + if mode == SIMILARITY and not is_projected(params.authority, params.code): + flask.abort(400, "Alignment without distortion needs a projected CRS: use the affine mode") + linked_ids = params.linked_ids or [] + if params.id in linked_ids: + flask.abort(400, "The aligned data cannot also be linked to itself") + # Linked data share the local frame of the aligned one: all are loaded and checked + # before any file is written + linked = [(data_id, *_load_crs_object(data_id)) for data_id in linked_ids] + source_points = _points(params.source_points) + if geographic_info(geode_object.crs_manager()) is not None and _has_original_coordinates( + geode_object + ): + source_points = _original_source_points(geode_object, params.source_points) + source, target, scale, rotation = _coordinate_systems( + mode, source_points, _points(params.target_points) + ) + alignment = _Alignment(source, target, scale, info) + _apply_alignment(geode_object, alignment) + for _, _, linked_object in linked: + _apply_alignment(linked_object, alignment) saved = utils_functions.save_all_viewables_and_return_info( geode_object, data, geode_functions.data_file_path(params.id) ) - # The front rebuilds its local actor from the new light viewable + # The front rebuilds the local actors from the new light viewables response = _crs_response(saved) if scale is not None: response.update(scale=scale, rotation=rotation) + if linked: + response["linked"] = [ + { + "id": data_id, + **_crs_response( + utils_functions.save_all_viewables_and_return_info( + linked_object, linked_data, geode_functions.data_file_path(data_id) + ) + ), + } + for data_id, linked_data, linked_object in linked + ] return schemas.AlignResponse.from_dict(response) diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.json b/src/opengeodeweb_back/routes/crs/schemas/align.json index da147d45..5bb80859 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.json +++ b/src/opengeodeweb_back/routes/crs/schemas/align.json @@ -10,6 +10,15 @@ "minLength": 32, "maxLength": 32 }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, "mode": { "type": "string", "enum": [ @@ -116,6 +125,44 @@ }, "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": [], diff --git a/src/opengeodeweb_back/routes/crs/schemas/align.py b/src/opengeodeweb_back/routes/crs/schemas/align.py index 09cec926..9fefc6f2 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/align.py +++ b/src/opengeodeweb_back/routes/crs/schemas/align.py @@ -1,6 +1,5 @@ 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 enum import Enum @@ -43,11 +42,12 @@ def __post_init__(self) -> None: name: str source_points: List[SourcePoint] target_points: List[TargetPoint] + linked_ids: Optional[List[str]] = None mode: Optional[Mode] = None @dataclass -class CRS(DataClassJsonMixin): +class AlignResponseCRS(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) @@ -56,13 +56,34 @@ def __post_init__(self) -> None: 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[CRS] = None + crs: Optional[AlignResponseCRS] = None + linked: Optional[List[Linked]] = None rotation: Optional[float] = None scale: Optional[float] = None diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index c422d770..4f269301 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -333,6 +333,72 @@ def test_align_similarity_2d(client: FlaskClient) -> None: 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) From d46a524f0d415cb4eeb1fadca5e9393aa6356140 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 27 Oct 2026 15:58:52 +0100 Subject: [PATCH 15/17] feat(crs): assign and convert linked data in the same request - assign_geographic and convert_geographic take optional linked_ids, like align: every data is loaded and changed in memory before any file is written, so an unknown or invalid linked data leaves everything untouched. - The responses list the linked data: their CRS, plus the plausibility for assign and the new light viewable for convert. Co-Authored-By: Claude Opus 5.5 --- opengeodeweb_back_schemas.json | 94 ++++++++++++++++ opengeodeweb_back_typed_schemas.d.ts | 48 ++++++-- .../routes/crs/blueprint_crs.py | 106 ++++++++++++------ .../routes/crs/schemas/assign_geographic.json | 47 ++++++++ .../routes/crs/schemas/assign_geographic.py | 29 ++++- .../crs/schemas/convert_geographic.json | 47 ++++++++ .../routes/crs/schemas/convert_geographic.py | 29 ++++- tests/test_crs_routes.py | 65 +++++++++++ 8 files changed, 415 insertions(+), 50 deletions(-) diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index 65cf6f6f..fe7a5888 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -696,6 +696,15 @@ "minLength": 32, "maxLength": 32 }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, "authority": { "type": "string", "minLength": 1 @@ -741,6 +750,44 @@ "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": [], @@ -760,6 +807,15 @@ "minLength": 32, "maxLength": 32 }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, "authority": { "type": "string", "minLength": 1 @@ -805,6 +861,44 @@ }, "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": [], diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index af350215..c51ebb4a 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -171,15 +171,17 @@ export interface CRSList { } export interface CRSConvertGeographicParams { - authority: string; - code: string; - id: string; - name: string; + 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 { @@ -188,16 +190,30 @@ export interface CRSConvertGeographicResponseCRS { name: string; } -export interface CRSAssignGeographicParams { +export interface CRSConvertGeographicResponseLinked { + binary_light_viewable?: string; + crs?: PurpleCRS; + id: string; +} + +export interface PurpleCRS { authority: string; code: string; - id: string; name: string; } +export interface CRSAssignGeographicParams { + authority: string; + code: string; + id: string; + linked_ids?: string[]; + name: string; +} + export interface CRSAssignGeographicResponse { crs?: CRSAssignGeographicResponseCRS; implausible?: boolean; + linked?: CRSAssignGeographicResponseLinked[]; } export interface CRSAssignGeographicResponseCRS { @@ -206,6 +222,18 @@ export interface CRSAssignGeographicResponseCRS { 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; @@ -233,7 +261,7 @@ export interface TargetPoint { export interface CRSAlignResponse { binary_light_viewable?: string; crs?: CRSAlignResponseCRS; - linked?: Linked[]; + linked?: CRSAlignResponseLinked[]; rotation?: number; scale?: number; } @@ -244,13 +272,13 @@ export interface CRSAlignResponseCRS { name: string; } -export interface Linked { +export interface CRSAlignResponseLinked { binary_light_viewable?: string; - crs?: LinkedCRS; + crs?: TentacledCRS; id: string; } -export interface LinkedCRS { +export interface TentacledCRS { authority: string; code: string; name: string; diff --git a/src/opengeodeweb_back/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index 9bad5b58..06b5ce80 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -107,51 +107,93 @@ def _keep_original_coordinates(geode_object: CrsGeodeObject) -> None: geode_object.create_crs("assigned", identity, identity) -@typed_route(routes, schemas.assign_geographic_route) -def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeographicResponse: - data = geode_functions.get_data_info(params.id) - geode_object = geode_functions.geode_object_from_string(data.geode_object).load( - geode_functions.data_file_path(params.id, data.native_file) - ) - if not isinstance(geode_object, CRS_GEODE_OBJECTS): - flask.abort(400, f"{params.id} does not support coordinate reference systems") - info = _known_info(geode_object, params.authority, params.code) +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: + flask.abort(400, "The data cannot also be linked to itself") + return [(linked_id, *_load_crs_object(linked_id)) for linked_id in linked_ids] + + +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 + + +@typed_route(routes, schemas.assign_geographic_route) +def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeographicResponse: + data, geode_object = _load_crs_object(params.id) + linked = _linked_objects(params.id, params.linked_ids) + # Every data is assigned in memory before any file is written + info = _assign(geode_object, params.authority, params.code) + linked_infos = [ + _assign(linked_object, params.authority, params.code) for _, _, linked_object in linked + ] # Assigning only relabels the coordinates: the viewables are unchanged geode_object.save(geode_functions.data_file_path(params.id, data.native_file)) + for linked_id, linked_data, linked_object in linked: + linked_object.save(geode_functions.data_file_path(linked_id, linked_data.native_file)) # A warning only: the label is kept, the front offers to align instead - return schemas.AssignGeographicResponse.from_dict( - { - "crs": active_crs(geode_object), - "implausible": coordinates_implausible(geode_object, info), - } - ) + response: dict[str, Any] = { + "crs": active_crs(geode_object), + "implausible": coordinates_implausible(geode_object, info), + } + if linked: + response["linked"] = [ + { + "id": linked_id, + "crs": active_crs(linked_object), + "implausible": coordinates_implausible(linked_object, linked_info), + } + for (linked_id, _, linked_object), linked_info in zip(linked, linked_infos, strict=True) + ] + return schemas.AssignGeographicResponse.from_dict(response) -@typed_route(routes, schemas.convert_geographic_route) -def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeographicResponse: - data = geode_functions.get_data_info(params.id) - geode_object = geode_functions.geode_object_from_string(data.geode_object).load( - geode_functions.data_file_path(params.id, data.native_file) - ) - if not isinstance(geode_object, CRS_GEODE_OBJECTS): - flask.abort(400, f"{params.id} does not support coordinate reference systems") - crs_manager = geode_object.crs_manager() - if geographic_info(crs_manager) is None: +def _convert(geode_object: CrsGeodeObject, authority: str, code: str) -> None: + if geographic_info(geode_object.crs_manager()) is None: flask.abort(400, "Assign a geographic CRS before converting") - info = _known_info(geode_object, params.authority, params.code) + 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: # PROJ rejects coordinates outside the source or target CRS domain flask.abort(400, f"Coordinates cannot be converted to {info.authority_code()}") + + +@typed_route(routes, schemas.convert_geographic_route) +def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeographicResponse: + 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 + _convert(geode_object, params.authority, params.code) + for _, _, linked_object in linked: + _convert(linked_object, params.authority, params.code) saved = utils_functions.save_all_viewables_and_return_info( geode_object, data, geode_functions.data_file_path(params.id) ) - # The front rebuilds its local actor from the new light viewable - return schemas.ConvertGeographicResponse.from_dict(_crs_response(saved)) + # The front rebuilds the local actors from the new light viewables + response = _crs_response(saved) + if linked: + response["linked"] = [ + { + "id": linked_id, + **_crs_response( + utils_functions.save_all_viewables_and_return_info( + linked_object, linked_data, geode_functions.data_file_path(linked_id) + ) + ), + } + for linked_id, linked_data, linked_object in linked + ] + return schemas.ConvertGeographicResponse.from_dict(response) def _points( @@ -316,12 +358,8 @@ def align(params: schemas.Align) -> schemas.AlignResponse: # In degrees, a uniform scale would flatten Z and (lat, lon) axes would mirror the data if mode == SIMILARITY and not is_projected(params.authority, params.code): flask.abort(400, "Alignment without distortion needs a projected CRS: use the affine mode") - linked_ids = params.linked_ids or [] - if params.id in linked_ids: - flask.abort(400, "The aligned data cannot also be linked to itself") - # Linked data share the local frame of the aligned one: all are loaded and checked - # before any file is written - linked = [(data_id, *_load_crs_object(data_id)) for data_id in linked_ids] + # Linked data share the local frame of the aligned one + linked = _linked_objects(params.id, params.linked_ids) source_points = _points(params.source_points) if geographic_info(geode_object.crs_manager()) is not None and _has_original_coordinates( geode_object diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json index f4863f4a..3b19e496 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json @@ -10,6 +10,15 @@ "minLength": 32, "maxLength": 32 }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, "authority": { "type": "string", "minLength": 1 @@ -55,6 +64,44 @@ }, "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": [], diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py index 9d73b648..cd9c7700 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py @@ -1,9 +1,10 @@ from pathlib import Path from opengeodeweb_microservice.schemas import Route, load_schema -from typing import Optional +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 @@ -15,10 +16,11 @@ def __post_init__(self) -> None: code: str id: str name: str + linked_ids: Optional[List[str]] = None @dataclass -class CRS(DataClassJsonMixin): +class AssignGeographicResponseCRS(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) @@ -27,13 +29,34 @@ def __post_init__(self) -> None: 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[CRS] = None + crs: Optional[AssignGeographicResponseCRS] = None implausible: Optional[bool] = None + linked: Optional[List[Linked]] = None assign_geographic_route = Route( diff --git a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json index 07c77e6e..75453eb4 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.json @@ -10,6 +10,15 @@ "minLength": 32, "maxLength": 32 }, + "linked_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 32, + "maxLength": 32 + }, + "uniqueItems": true + }, "authority": { "type": "string", "minLength": 1 @@ -55,6 +64,44 @@ "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": [], diff --git a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py index 59311161..7fb0af14 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py +++ b/src/opengeodeweb_back/routes/crs/schemas/convert_geographic.py @@ -1,9 +1,10 @@ from pathlib import Path from opengeodeweb_microservice.schemas import Route, load_schema -from typing import Optional +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 @@ -15,10 +16,11 @@ def __post_init__(self) -> None: code: str id: str name: str + linked_ids: Optional[List[str]] = None @dataclass -class CRS(DataClassJsonMixin): +class ConvertGeographicResponseCRS(DataClassJsonMixin): def __post_init__(self) -> None: print_dataclass(self) @@ -27,13 +29,34 @@ def __post_init__(self) -> None: 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[CRS] = None + crs: Optional[ConvertGeographicResponseCRS] = None + linked: Optional[List[Linked]] = None convert_geographic_route = Route( diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index 4f269301..ded993bc 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -678,3 +678,68 @@ def test_assign_geographic_plausibility(client: FlaskClient) -> None: 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_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 From c00dbfbf40312f1688a0e0937a0157e39352518d Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Mon, 2 Nov 2026 15:32:41 +0100 Subject: [PATCH 16/17] feat(crs): assign only the data whose coordinates fit the crs assign_geographic takes an optional only_plausible flag. The coordinates of every data are checked before any change; with the flag, a data that does not fit the CRS is neither assigned nor saved, and its response has no crs. The front uses it to give the project CRS to data without CRS automatically. Without the flag, the assignment is unchanged and the plausibility stays a warning. Co-Authored-By: Claude Opus 5.5 --- opengeodeweb_back_schemas.json | 3 + opengeodeweb_back_typed_schemas.d.ts | 11 ++-- .../routes/crs/blueprint_crs.py | 58 +++++++++++++------ .../routes/crs/schemas/assign_geographic.json | 3 + .../routes/crs/schemas/assign_geographic.py | 1 + tests/test_crs_routes.py | 20 +++++++ 6 files changed, 73 insertions(+), 23 deletions(-) diff --git a/opengeodeweb_back_schemas.json b/opengeodeweb_back_schemas.json index fe7a5888..411a95d8 100644 --- a/opengeodeweb_back_schemas.json +++ b/opengeodeweb_back_schemas.json @@ -827,6 +827,9 @@ "name": { "type": "string", "minLength": 1 + }, + "only_plausible": { + "type": "boolean" } }, "required": [ diff --git a/opengeodeweb_back_typed_schemas.d.ts b/opengeodeweb_back_typed_schemas.d.ts index c51ebb4a..3749b7ec 100644 --- a/opengeodeweb_back_typed_schemas.d.ts +++ b/opengeodeweb_back_typed_schemas.d.ts @@ -203,11 +203,12 @@ export interface PurpleCRS { } export interface CRSAssignGeographicParams { - authority: string; - code: string; - id: string; - linked_ids?: string[]; - name: string; + authority: string; + code: string; + id: string; + linked_ids?: string[]; + name: string; + only_plausible?: boolean; } export interface CRSAssignGeographicResponse { diff --git a/src/opengeodeweb_back/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index 06b5ce80..5f126968 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -127,32 +127,54 @@ def _assign( return info +def _assign_entry( + geode_object: CrsGeodeObject, *, assigned: bool, implausible: bool +) -> dict[str, Any]: + entry: dict[str, Any] = {"implausible": implausible} + if assigned: + entry["crs"] = active_crs(geode_object) + return entry + + @typed_route(routes, schemas.assign_geographic_route) def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeographicResponse: data, geode_object = _load_crs_object(params.id) - linked = _linked_objects(params.id, params.linked_ids) + targets = [(params.id, data, geode_object), *_linked_objects(params.id, params.linked_ids)] + # 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, params.authority, params.code) + ) + for target_id, _, target_object in targets + } + assigned = { + target_id + for target_id, _, _ in targets + if not (params.only_plausible and implausible[target_id]) + } # Every data is assigned in memory before any file is written - info = _assign(geode_object, params.authority, params.code) - linked_infos = [ - _assign(linked_object, params.authority, params.code) for _, _, linked_object in linked - ] + for target_id, _, target_object in targets: + if target_id in assigned: + _assign(target_object, params.authority, params.code) # Assigning only relabels the coordinates: the viewables are unchanged - geode_object.save(geode_functions.data_file_path(params.id, data.native_file)) - for linked_id, linked_data, linked_object in linked: - linked_object.save(geode_functions.data_file_path(linked_id, linked_data.native_file)) - # A warning only: the label is kept, the front offers to align instead - response: dict[str, Any] = { - "crs": active_crs(geode_object), - "implausible": coordinates_implausible(geode_object, info), - } - if linked: + 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)) + # Without only_plausible, a warning only: the label is kept, the front offers to align instead + response = _assign_entry( + geode_object, assigned=params.id in assigned, implausible=implausible[params.id] + ) + if len(targets) > 1: response["linked"] = [ { - "id": linked_id, - "crs": active_crs(linked_object), - "implausible": coordinates_implausible(linked_object, linked_info), + "id": target_id, + **_assign_entry( + target_object, + assigned=target_id in assigned, + implausible=implausible[target_id], + ), } - for (linked_id, _, linked_object), linked_info in zip(linked, linked_infos, strict=True) + for target_id, _, target_object in targets[1:] ] return schemas.AssignGeographicResponse.from_dict(response) diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json index 3b19e496..3fa9e3f4 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.json @@ -30,6 +30,9 @@ "name": { "type": "string", "minLength": 1 + }, + "only_plausible": { + "type": "boolean" } }, "required": [ diff --git a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py index cd9c7700..da90f98b 100644 --- a/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py +++ b/src/opengeodeweb_back/routes/crs/schemas/assign_geographic.py @@ -17,6 +17,7 @@ def __post_init__(self) -> None: id: str name: str linked_ids: Optional[List[str]] = None + only_plausible: Optional[bool] = None @dataclass diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index ded993bc..0f033a3b 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -688,6 +688,26 @@ def _active_crs(client: FlaskClient, data_id: str) -> dict[str, str] | None: ) +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) From d8b0b74a8c430214857e44351b7a3d2be35d7018 Mon Sep 17 00:00:00 2001 From: MaxNumerique Date: Tue, 3 Nov 2026 09:25:00 +0100 Subject: [PATCH 17/17] refactor(crs): crs package, routes only in the blueprint The CRS logic moves to an opengeodeweb_back.crs package, like basemap: crs_functions (CRS information, plausibility, loading), crs_assignment (assign and convert) and crs_alignment (control point frames). The blueprint keeps the routes only; the package raises CrsError, turned into a 400 by the routes. A failed conversion keeps the cause given by PROJ or OpenGeode in its message and logs the trace, instead of hiding any error behind "cannot be converted". Colinear control points are detected before building the frames, so any other OpenGeode error is no longer reported as a colinearity. Co-Authored-By: Claude Opus 5.5 --- src/opengeodeweb_back/crs/__init__.py | 0 src/opengeodeweb_back/crs/crs_alignment.py | 197 +++++++++ src/opengeodeweb_back/crs/crs_assignment.py | 117 +++++ .../{ => crs}/crs_functions.py | 46 ++ .../routes/crs/blueprint_crs.py | 404 +++--------------- src/opengeodeweb_back/utils_functions.py | 2 +- tests/test_crs_routes.py | 21 +- 7 files changed, 451 insertions(+), 336 deletions(-) create mode 100644 src/opengeodeweb_back/crs/__init__.py create mode 100644 src/opengeodeweb_back/crs/crs_alignment.py create mode 100644 src/opengeodeweb_back/crs/crs_assignment.py rename src/opengeodeweb_back/{ => crs}/crs_functions.py (87%) 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_functions.py b/src/opengeodeweb_back/crs/crs_functions.py similarity index 87% rename from src/opengeodeweb_back/crs_functions.py rename to src/opengeodeweb_back/crs/crs_functions.py index ed51ef48..ce81d204 100644 --- a/src/opengeodeweb_back/crs_functions.py +++ b/src/opengeodeweb_back/crs/crs_functions.py @@ -15,6 +15,7 @@ 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 @@ -26,11 +27,18 @@ 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") @@ -312,3 +320,41 @@ def original_coordinates( 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/routes/crs/blueprint_crs.py b/src/opengeodeweb_back/routes/crs/blueprint_crs.py index 5f126968..8484647e 100644 --- a/src/opengeodeweb_back/routes/crs/blueprint_crs.py +++ b/src/opengeodeweb_back/routes/crs/blueprint_crs.py @@ -1,65 +1,45 @@ from __future__ import annotations # Standard library imports -import math -from dataclasses import dataclass +from contextlib import contextmanager from typing import TYPE_CHECKING, Any # Third party imports import flask -import opengeode as og # Local application imports from opengeodeweb_back import geode_functions, utils_functions -from opengeodeweb_back.crs_functions import ( +from opengeodeweb_back.crs import crs_alignment, crs_assignment +from opengeodeweb_back.crs.crs_functions import ( CRS_GEODE_OBJECTS, - CRS_GEODE_OBJECTS_3D, - ORIGINAL_CRS, - CrsGeodeObject, - active_crs, - coordinates_implausible, + CrsError, geographic_coordinate_systems, geographic_info, is_projected, - known_geographic_info, - original_coordinates, + 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: - import opengeode_geosciences as og_geosciences - from opengeodeweb_microservice.database.data import Data - -routes = flask.Blueprint("crs", __name__, url_prefix="/crs") + from collections.abc import Generator -SIMILARITY = "similarity" -AFFINE = "affine" -SIMILARITY_PAIRS = (1, 2) -AFFINE_PAIRS = 3 + from opengeodeweb_microservice.database.data import Data + from opengeodeweb_back.crs.crs_functions import CrsGeodeObject -def _prepare_crs_name( - geode_object: CrsGeodeObject, - crs_name: str, -) -> None: - crs_manager = geode_object.crs_manager() - info = geographic_info(crs_manager) - if info is not None and info.authority_code() == crs_name: - flask.abort(400, f"{crs_name} is already the active CRS") - # A registered inactive CRS may hold coordinates derived from an outdated label: recompute it - if crs_manager.coordinate_reference_system_exists(crs_name): - geode_object.delete_crs(crs_name) +routes = flask.Blueprint("crs", __name__, url_prefix="/crs") -def _known_info( - geode_object: CrsGeodeObject, authority: str, code: str -) -> og_geosciences.GeographicCoordinateSystemInfo: - # PROJ crashes the process on an unknown CRS: only accept listed ones, with their own name - info = known_geographic_info(geode_object, authority, code) - if info is None: - flask.abort(400, f"Unknown CRS {authority}:{code}") - return info +@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]: @@ -67,6 +47,15 @@ def _crs_response(saved: dict[str, Any]) -> dict[str, Any]: 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) @@ -95,322 +84,69 @@ def active(params: schemas.Active) -> schemas.ActiveResponse: ) -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 _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: - flask.abort(400, "The data cannot also be linked to itself") - return [(linked_id, *_load_crs_object(linked_id)) for linked_id in linked_ids] - - -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_entry( - geode_object: CrsGeodeObject, *, assigned: bool, implausible: bool -) -> dict[str, Any]: - entry: dict[str, Any] = {"implausible": implausible} - if assigned: - entry["crs"] = active_crs(geode_object) - return entry - - @typed_route(routes, schemas.assign_geographic_route) def assign_geographic(params: schemas.AssignGeographic) -> schemas.AssignGeographicResponse: - data, geode_object = _load_crs_object(params.id) - targets = [(params.id, data, geode_object), *_linked_objects(params.id, params.linked_ids)] - # 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, params.authority, params.code) + 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), ) - for target_id, _, target_object in targets - } - assigned = { - target_id - for target_id, _, _ in targets - if not (params.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, params.authority, params.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)) - # Without only_plausible, a warning only: the label is kept, the front offers to align instead - response = _assign_entry( - geode_object, assigned=params.id in assigned, implausible=implausible[params.id] - ) - if len(targets) > 1: - response["linked"] = [ - { - "id": target_id, - **_assign_entry( - target_object, - assigned=target_id in assigned, - implausible=implausible[target_id], - ), - } - for target_id, _, target_object in targets[1:] - ] + 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) -def _convert(geode_object: CrsGeodeObject, authority: str, code: str) -> None: - if geographic_info(geode_object.crs_manager()) is None: - flask.abort(400, "Assign a geographic CRS before converting") - 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: # PROJ rejects coordinates outside the source or target CRS domain - flask.abort(400, f"Coordinates cannot be converted to {info.authority_code()}") - - @typed_route(routes, schemas.convert_geographic_route) def convert_geographic(params: schemas.ConvertGeographic) -> schemas.ConvertGeographicResponse: - 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 - _convert(geode_object, params.authority, params.code) - for _, _, linked_object in linked: - _convert(linked_object, params.authority, params.code) - saved = utils_functions.save_all_viewables_and_return_info( - geode_object, data, geode_functions.data_file_path(params.id) - ) - # The front rebuilds the local actors from the new light viewables - response = _crs_response(saved) + 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, - **_crs_response( - utils_functions.save_all_viewables_and_return_info( - linked_object, linked_data, geode_functions.data_file_path(linked_id) - ) - ), - } + {"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) -def _points( - points: list[schemas.SourcePoint] | list[schemas.TargetPoint], -) -> list[og.Point2D]: - return [og.Point2D([point.x, point.y]) for point in points] - - -def _original_source_points( - geode_object: CrsGeodeObject, source_points: list[schemas.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. - """ - points = _points(source_points) - original_points = original_coordinates(geode_object, points) - if original_points is None: - if any(point.picked for point in source_points): - flask.abort(400, "Data has no three non-colinear vertices to re-align") - return points - return [ - original if source.picked else typed - for source, original, typed in zip(source_points, original_points, points, 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 _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 _coordinate_system(points: list[og.Point2D]) -> og.CoordinateSystem2D: - origin, first, second = points - return og.CoordinateSystem2D(origin, [first, second]) - - -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: - flask.abort(400, "The two points of the source or of the target must differ") - 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 _check_pair_count(mode: str, params: schemas.Align) -> None: - count = len(params.source_points) - if count != len(params.target_points): - flask.abort(400, "Source and target points must come in pairs") - if mode == AFFINE and count != AFFINE_PAIRS: - flask.abort(400, "Affine alignment needs 3 pairs of points") - if mode == SIMILARITY and count not in SIMILARITY_PAIRS: - flask.abort(400, "Alignment without distortion needs 1 or 2 pairs of points") - - -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): - flask.abort(400, f"{data_id} does not support coordinate reference systems") - return data, geode_object - - -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) - try: - source = _coordinate_system(source_points) - target = _coordinate_system(target_points) - except RuntimeError: # OpenGeode cannot build a coordinate system from colinear points - flask.abort(400, "Points must not be colinear") - return source, target, None, None - - -@dataclass(frozen=True) -class _Alignment: - source: og.CoordinateSystem2D - target: og.CoordinateSystem2D - scale: float | None - info: og_geosciences.GeographicCoordinateSystemInfo - - -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) - - @typed_route(routes, schemas.align_route) def align(params: schemas.Align) -> schemas.AlignResponse: - data, geode_object = _load_crs_object(params.id) - info = _known_info(geode_object, params.authority, params.code) - mode = params.mode.value if params.mode is not None else SIMILARITY - _check_pair_count(mode, params) - # In degrees, a uniform scale would flatten Z and (lat, lon) axes would mirror the data - if mode == SIMILARITY and not is_projected(params.authority, params.code): - flask.abort(400, "Alignment without distortion needs a projected CRS: use the affine mode") - # Linked data share the local frame of the aligned one - linked = _linked_objects(params.id, params.linked_ids) - source_points = _points(params.source_points) - if geographic_info(geode_object.crs_manager()) is not None and _has_original_coordinates( - geode_object - ): - source_points = _original_source_points(geode_object, params.source_points) - source, target, scale, rotation = _coordinate_systems( - mode, source_points, _points(params.target_points) - ) - alignment = _Alignment(source, target, scale, info) - _apply_alignment(geode_object, alignment) - for _, _, linked_object in linked: - _apply_alignment(linked_object, alignment) - saved = utils_functions.save_all_viewables_and_return_info( - geode_object, data, geode_functions.data_file_path(params.id) - ) - # The front rebuilds the local actors from the new light viewables - response = _crs_response(saved) + 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": data_id, - **_crs_response( - utils_functions.save_all_viewables_and_return_info( - linked_object, linked_data, geode_functions.data_file_path(data_id) - ) - ), - } - for data_id, linked_data, linked_object in 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/utils_functions.py b/src/opengeodeweb_back/utils_functions.py index 1870a3e5..e47639c6 100644 --- a/src/opengeodeweb_back/utils_functions.py +++ b/src/opengeodeweb_back/utils_functions.py @@ -25,7 +25,7 @@ # Local application imports from . import geode_functions -from .crs_functions import active_crs +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 diff --git a/tests/test_crs_routes.py b/tests/test_crs_routes.py index 0f033a3b..7b3a2d43 100644 --- a/tests/test_crs_routes.py +++ b/tests/test_crs_routes.py @@ -7,12 +7,14 @@ 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 crs_functions, geode_functions, test_utils +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 @@ -547,6 +549,23 @@ def test_convert_out_of_domain(client: FlaskClient) -> None: 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: