From 6b24a59178b263a3a033710c3bd03dacad7a236d Mon Sep 17 00:00:00 2001 From: guille Date: Tue, 10 Oct 2023 11:16:26 +0200 Subject: [PATCH] Add exports for cesium tileset and glb format --- hub/exports/exports_factory.py | 28 ++++- hub/exports/formats/cesiumjs_tileset.py | 137 ++++++++++++++++++++++++ hub/exports/formats/glb.py | 52 +++++++++ hub/exports/formats/obj.py | 2 +- tests/test_exports.py | 24 ++++- 5 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 hub/exports/formats/cesiumjs_tileset.py create mode 100644 hub/exports/formats/glb.py diff --git a/hub/exports/exports_factory.py b/hub/exports/exports_factory.py index 4b7216ea..7a63c1da 100644 --- a/hub/exports/exports_factory.py +++ b/hub/exports/exports_factory.py @@ -7,9 +7,11 @@ Project Coder Guille Gutierrez guillermo.gutierrezmorote@concordia.ca from pathlib import Path +from hub.exports.formats.glb import Glb from hub.exports.formats.obj import Obj from hub.exports.formats.simplified_radiosity_algorithm import SimplifiedRadiosityAlgorithm from hub.exports.formats.stl import Stl +from hub.exports.formats.cesiumjs_tileset import CesiumjsTileset from hub.helpers.utils import validate_import_export_type @@ -17,7 +19,7 @@ class ExportsFactory: """ Exports factory class """ - def __init__(self, handler, city, path, target_buildings=None, adjacent_buildings=None): + def __init__(self, handler, city, path, target_buildings=None, adjacent_buildings=None, base_uri=None): self._city = city self._handler = '_' + handler.lower() validate_import_export_type(ExportsFactory, handler) @@ -26,6 +28,7 @@ class ExportsFactory: self._path = path self._target_buildings = target_buildings self._adjacent_buildings = adjacent_buildings + self._base_uri = base_uri @property def _citygml(self): @@ -61,9 +64,26 @@ class ExportsFactory: Export the city to Simplified Radiosity Algorithm xml format :return: None """ - return SimplifiedRadiosityAlgorithm(self._city, - (self._path / f'{self._city.name}_sra.xml'), - target_buildings=self._target_buildings) + return SimplifiedRadiosityAlgorithm( + self._city, (self._path / f'{self._city.name}_sra.xml'), target_buildings=self._target_buildings + ) + + @property + def _cesiumjs_tileset(self): + """ + Export the city to a cesiumJs tileset format + :return: None + """ + return CesiumjsTileset( + self._city, + (self._path / f'{self._city.name}.json'), + target_buildings=self._target_buildings, + base_uri=self._base_uri + ) + + @property + def _glb(self): + return Glb(self._city, self._path, target_buildings=self._target_buildings) def export(self): """ diff --git a/hub/exports/formats/cesiumjs_tileset.py b/hub/exports/formats/cesiumjs_tileset.py new file mode 100644 index 00000000..9589a980 --- /dev/null +++ b/hub/exports/formats/cesiumjs_tileset.py @@ -0,0 +1,137 @@ +import json +import math + +import pyproj +from pyproj import Transformer + +from hub.helpers.geometry_helper import GeometryHelper + + +class CesiumjsTileset: + def __init__(self, city, file_name, target_buildings=None, base_uri=None): + self._city = city + self._file_name = file_name + self._target_buildings = target_buildings + if base_uri is None: + base_uri = '.' + self._base_uri = base_uri + try: + srs_name = self._city.srs_name + if self._city.srs_name in GeometryHelper.srs_transformations: + srs_name = GeometryHelper.srs_transformations[self._city.srs_name] + input_reference = pyproj.CRS(srs_name) # Projected coordinate system from input data + except pyproj.exceptions.CRSError as err: + raise pyproj.exceptions.CRSError from err + self._to_gps = Transformer.from_crs(input_reference, pyproj.CRS('EPSG:4326')) + self._tile_set = { + 'asset': { + 'version': '1.1', + "tilesetVersion": "1.2.3" + }, + 'schema': { + 'id': "building", + 'classes': { + 'building': { + "properties": { + 'name': { + 'type': 'STRING' + }, + 'position': { + 'type': 'SCALAR', + 'array': True, + 'componentType': 'FLOAT32' + }, + 'aliases': { + 'type': 'STRING', + 'array': True, + }, + 'volume': { + 'type': 'SCALAR', + 'componentType': 'FLOAT32' + }, + 'floor_area': { + 'type': 'SCALAR', + 'componentType': 'FLOAT32' + }, + 'max_height': { + 'type': 'SCALAR', + 'componentType': 'INT32' + }, + 'year_of_construction': { + 'type': 'SCALAR', + 'componentType': 'INT32' + }, + 'function': { + 'type': 'STRING' + }, + 'usages_percentage': { + 'type': 'STRING' + } + } + } + } + }, + 'geometricError': 240, + 'root': { + 'boundingVolume': { + 'box': CesiumjsTileset._box_values(self._city.upper_corner, self._city.lower_corner) + }, + 'geometricError': 70, + 'refine': 'ADD', + 'children': [] + } + } + + self._export() + + @staticmethod + def _box_values(upper_corner, lower_corner): + x = (upper_corner[0] - lower_corner[0]) / 2 + y = (upper_corner[1] - lower_corner[1]) / 2 + z = (upper_corner[2] - lower_corner[2]) / 2 + return [x, y, z, x, 0, 0, 0, y, 0, 0, 0, z] + + def _ground_coordinates(self, coordinates): + ground_coordinates = [] + for coordinate in coordinates: + ground_coordinates.append( + (coordinate[0] - self._city.lower_corner[0], coordinate[1] - self._city.lower_corner[1]) + ) + return ground_coordinates + + def _export(self): + for building in self._city.buildings: + upper_corner = [-math.inf, -math.inf, 0] + lower_corner = [math.inf, math.inf, 0] + + for surface in building.grounds: # todo: maybe we should add the terrain? + coordinates = self._ground_coordinates(surface.solid_polygon.coordinates) + lower_corner = [min([c[0] for c in coordinates]), min([c[1] for c in coordinates]), 0] + upper_corner = [max([c[0] for c in coordinates]), max([c[1] for c in coordinates]), building.max_height] + tile = { + 'boundingVolume': { + 'box': CesiumjsTileset._box_values(upper_corner, lower_corner) + }, + 'geometricError': 70, + 'metadata': { + 'class': 'building', + 'properties': { + 'name': building.name, + 'position': self._to_gps.transform(lower_corner[0], lower_corner[1]), + 'aliases': building.aliases, + 'volume': building.volume, + 'floor_area': building.floor_area, + 'max_height': building.max_height, + 'year_of_construction': building.year_of_construction, + 'function': building.function, + 'usages_percentage': building.usages_percentage + } + }, + 'content': { + 'uri': f'{self._base_uri}/{building.name}.glb' + } + } + self._tile_set['root']['children'].append(tile) + + with open(self._file_name, 'w') as f: + json.dump(self._tile_set, f) diff --git a/hub/exports/formats/glb.py b/hub/exports/formats/glb.py new file mode 100644 index 00000000..558705c8 --- /dev/null +++ b/hub/exports/formats/glb.py @@ -0,0 +1,52 @@ +import glob +import os +import shutil +import subprocess + +from hub.city_model_structure.city import City +from hub.exports.formats.obj import Obj + + +class GltExceptionError(Exception): + """ + Glt execution error + """ + + +class Glb: + def __init__(self, city, path, target_buildings=None): + self._city = city + self._path = path + if target_buildings is None: + target_buildings = [b.name for b in self._city.buildings] + self._target_buildings = target_buildings + self._export() + + @property + def _obj2gtl(self): + """ + Get the SRA installation path + :return: str + """ + return shutil.which('obj2gltf') + + def _export(self): + try: + for building in self._city.buildings: + city = City(building.lower_corner, building.upper_corner, self._city.srs_name) + city.name = building.name + city.add_city_object(building) + Obj( city, self._path) + glb = f'{self._path}/{building.name}.glb' + subprocess.run([ + self._obj2gtl, + '-i', f'{self._path}/{building.name}.obj', + '-o', f'{glb}', + '-b', + '--normalTexture', f'{self._path}/{building.name}.mtl' + ]) + os.unlink(f'{self._path}/{building.name}.obj') + os.unlink(f'{self._path}/{building.name}.mtl') + except (subprocess.SubprocessError, subprocess.TimeoutExpired, subprocess.CalledProcessError) as err: + raise GltExceptionError from err + diff --git a/hub/exports/formats/obj.py b/hub/exports/formats/obj.py index 5faa02d3..219bb1ac 100644 --- a/hub/exports/formats/obj.py +++ b/hub/exports/formats/obj.py @@ -55,7 +55,7 @@ class Obj: with open(mtl_file_path, 'w', encoding='utf-8') as mtl: mtl.write("newmtl cerc_base_material\n") mtl.write("Ka 1.0 1.0 1.0 # Ambient color (white)\n") - mtl.write("Kd 0.3 0.8 0.3 # Diffuse color (greenish)\n") + mtl.write("Kd 0.3 0.1 0.3 # Diffuse color (greenish)\n") mtl.write("Ks 1.0 1.0 1.0 # Specular color (white)\n") mtl.write("Ns 400.0 # Specular exponent (defines shininess)\n") vertices = {} diff --git a/tests/test_exports.py b/tests/test_exports.py index ff808968..a617e67d 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -5,7 +5,7 @@ Copyright © 2022 Concordia CERC group Project Coder Guille Gutierrez guillermo.gutierrezmorote@concordia.ca Code contributors: Pilar Monsalvete Alvarez de Uribarri pilar.monsalvete@concordia.ca """ - +import json import logging.handlers from pathlib import Path from unittest import TestCase @@ -66,7 +66,7 @@ class TestExports(TestCase): def _export(self, export_type, from_pickle=False): self._complete_city = self._get_complete_city(from_pickle) - ExportsFactory(export_type, self._complete_city, self._output_path).export() + ExportsFactory(export_type, self._complete_city, self._output_path, base_uri='../glb').export() def _export_building_energy(self, export_type, from_pickle=False): self._complete_city = self._get_complete_city(from_pickle) @@ -78,6 +78,26 @@ class TestExports(TestCase): """ self._export('obj', False) + def test_cesiumjs_tileset_export(self): + """ + export to cesiumjs tileset + """ + self._export('cesiumjs_tileset', False) + tileset = Path(self._output_path / f'{self._city.name}.json') + self.assertTrue(tileset.exists()) + with open(tileset, 'r') as f: + json_tileset = json.load(f) + self.assertEqual(1, len(json_tileset['root']['children']), "Wrong number of children") + + def test_glb_export(self): + """ + export to glb format + """ + self._export('glb', False) + for building in self._city.buildings: + glb_file = Path(self._output_path / f'{building.name}.glb') + self.assertTrue(glb_file.exists(), f'{building.name} Building glb wasn\'t correctly generated') + def test_energy_ade_export(self): """ export to energy ADE