pyramids-citymodels 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyramids_citymodels/__init__.py +54 -0
- pyramids_citymodels/drivers/__init__.py +13 -0
- pyramids_citymodels/drivers/citygml.py +84 -0
- pyramids_citymodels/drivers/cityjson.py +80 -0
- pyramids_citymodels/flatten.py +290 -0
- pyramids_citymodels/ir.py +258 -0
- pyramids_citymodels/options.py +109 -0
- pyramids_citymodels/readers/__init__.py +9 -0
- pyramids_citymodels/readers/citygml.py +420 -0
- pyramids_citymodels/readers/cityjson.py +222 -0
- pyramids_citymodels-0.2.0.dist-info/METADATA +121 -0
- pyramids_citymodels-0.2.0.dist-info/RECORD +16 -0
- pyramids_citymodels-0.2.0.dist-info/WHEEL +5 -0
- pyramids_citymodels-0.2.0.dist-info/entry_points.txt +3 -0
- pyramids_citymodels-0.2.0.dist-info/licenses/LICENSE +674 -0
- pyramids_citymodels-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""3D city-model file-format drivers for the pyramids stack.
|
|
2
|
+
|
|
3
|
+
`pyramids-citymodels` reads and writes the file formats of semantic 3D city models —
|
|
4
|
+
CityGML, CityJSON and the related interchange formats — as `pyramids` objects.
|
|
5
|
+
`pyramids-core` owns the GIS data model (the raster `Dataset`, the vector
|
|
6
|
+
`FeatureCollection`, the datacube), its GDAL/OGR-backed I/O, and every operation that
|
|
7
|
+
follows once features are in hand — reprojection, overlays, spatial joins, conversion
|
|
8
|
+
and spatial indexing. What it does not do is open the city-model exchange formats;
|
|
9
|
+
that is this package's job, and its only job.
|
|
10
|
+
|
|
11
|
+
Drivers are discovered by `pyramids-core` through the `pyramids.vector_drivers`
|
|
12
|
+
entry-point group (the group `FeatureCollection.read_file` consults — city models are
|
|
13
|
+
vector features), so a read of a city-model file resolves to a driver here once this
|
|
14
|
+
package is installed. A dependency-backed driver names its optional dependency via a
|
|
15
|
+
`VectorDriver.extra` attribute and is skipped silently at discovery when that package
|
|
16
|
+
is not importable. See ADR-0003 for the scope decision.
|
|
17
|
+
|
|
18
|
+
The package root deliberately re-exports nothing: it carries `__version__` and the
|
|
19
|
+
import contract that activates the vendored osgeo bindings, and that is all. Installing
|
|
20
|
+
the package is the whole integration, so nothing here needs importing. A driver class is
|
|
21
|
+
still reachable by its module path for the rare case of wanting one directly.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
- The root carries the installed version and nothing else. `__all__` is empty
|
|
25
|
+
because there is deliberately nothing to re-export:
|
|
26
|
+
|
|
27
|
+
>>> import pyramids_citymodels
|
|
28
|
+
>>> pyramids_citymodels.__version__.split(".")[0].isdigit()
|
|
29
|
+
True
|
|
30
|
+
>>> pyramids_citymodels.__all__
|
|
31
|
+
[]
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Import pyramids FIRST — before anything else, and keep it first. pyramids-core
|
|
35
|
+
# bundles GDAL's osgeo SWIG bindings and native libs inside its platform wheel
|
|
36
|
+
# and activates them on import (puts `_vendor/osgeo` on sys.path, sets
|
|
37
|
+
# GDAL_DATA / PROJ_DATA, registers the Windows DLL directory). Any module here
|
|
38
|
+
# that does `from osgeo import gdal` at load time needs this line to have run
|
|
39
|
+
# first, or that import fails wherever no separate GDAL is installed.
|
|
40
|
+
import pyramids # noqa: F401 # isort:skip (keep first — see above; ruff must not reorder it)
|
|
41
|
+
|
|
42
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
43
|
+
|
|
44
|
+
# The distribution name, not `__name__`: the import name is `pyramids_citymodels`
|
|
45
|
+
# and the distribution is `pyramids-citymodels`. importlib.metadata normalizes `_`
|
|
46
|
+
# to `-` so either spelling resolves today, but the metadata lookup is a
|
|
47
|
+
# distribution lookup — spell it the way the metadata does.
|
|
48
|
+
try:
|
|
49
|
+
__version__ = version("pyramids-citymodels")
|
|
50
|
+
except PackageNotFoundError: # pragma: no cover
|
|
51
|
+
__version__ = "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Format drivers, one module per city-model format family.
|
|
2
|
+
|
|
3
|
+
Grouped by format family rather than by file suffix, because related encodings of one
|
|
4
|
+
standard share a parser (CityGML and its CityJSON sibling, for instance).
|
|
5
|
+
|
|
6
|
+
Each driver is a ``VectorDriver`` class registered into the ``pyramids.vector_drivers``
|
|
7
|
+
entry-point group. Keep this subpackage -- and each driver module -- light at import:
|
|
8
|
+
pyramids reads a driver's claims as class attributes without constructing it, so a driver's
|
|
9
|
+
heavy backend belongs inside its ``read`` method, not at module top, and the optional
|
|
10
|
+
dependency is named by the driver's ``extra`` attribute (the registry skips the driver
|
|
11
|
+
silently at discovery when that package is absent). An eager backend import here would
|
|
12
|
+
break the whole package import instead.
|
|
13
|
+
"""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The CityGML vector driver (issue #11).
|
|
2
|
+
|
|
3
|
+
Registered into the ``pyramids.vector_drivers`` entry-point group, this driver is what
|
|
4
|
+
``FeatureCollection.read_file`` dispatches a CityGML read into. Like the CityJSON driver it
|
|
5
|
+
follows the class-attribute contract — claims are class attributes, :meth:`can_open` is a
|
|
6
|
+
classmethod — so discovery reads the class without importing the parser or shapely/geopandas;
|
|
7
|
+
those load only inside :meth:`read`.
|
|
8
|
+
|
|
9
|
+
``.gml`` / ``.xml`` are shared with plenty of non-CityGML data, so :meth:`can_open` sniffs for a
|
|
10
|
+
CityGML namespace or a ``CityModel`` element rather than trusting the suffix.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pyramids # noqa: F401 # isort:skip (keep first — activates vendored osgeo; see __init__)
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CityGmlDriver:
|
|
19
|
+
"""Read CityGML LOD2 (``.gml`` / ``.xml`` / ``.citygml``) as a ``FeatureCollection``."""
|
|
20
|
+
|
|
21
|
+
name = "citygml"
|
|
22
|
+
long_name = "CityGML (3D city model, LOD2 semantic surfaces)"
|
|
23
|
+
api_version = 1
|
|
24
|
+
suffixes = (".gml", ".citygml", ".xml")
|
|
25
|
+
schemes: tuple[str, ...] = ()
|
|
26
|
+
capabilities = frozenset({"read"})
|
|
27
|
+
#: Parsing uses only the standard-library ``xml.etree.ElementTree``, so there is no backend.
|
|
28
|
+
extra: str | None = None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def can_open(cls, source: Any) -> bool:
|
|
32
|
+
"""Return ``True`` only when ``source`` looks like CityGML (namespace / ``CityModel``).
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
- A CityGML-namespaced document is claimed; plain XML is declined:
|
|
36
|
+
```python
|
|
37
|
+
>>> CityGmlDriver.can_open('<core:CityModel xmlns:core="http://www.opengis.net/citygml/2.0"/>')
|
|
38
|
+
True
|
|
39
|
+
>>> CityGmlDriver.can_open("<root/>")
|
|
40
|
+
False
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
from pyramids_citymodels.readers.citygml import looks_like_citygml
|
|
45
|
+
|
|
46
|
+
return looks_like_citygml(source)
|
|
47
|
+
|
|
48
|
+
def read(self, source: Any, **kwargs: Any) -> Any:
|
|
49
|
+
"""Parse ``source``'s LOD2 buildings into a :class:`pyramids.feature.FeatureCollection`.
|
|
50
|
+
|
|
51
|
+
Keyword Args:
|
|
52
|
+
strategy: ``"footprint"`` or ``"semantic_surfaces"`` (default).
|
|
53
|
+
lod: Level of detail to select; ``None`` takes the highest present.
|
|
54
|
+
geometry_dimension: ``"xyz"`` (default) keeps Z, ``"xy"`` drops it.
|
|
55
|
+
child_inherits_parent_attributes: Whether child rows carry parent attributes.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
- Read an in-memory CityGML building into a FeatureCollection:
|
|
59
|
+
```python
|
|
60
|
+
>>> xml = '''<core:CityModel
|
|
61
|
+
... xmlns:core="http://www.opengis.net/citygml/2.0"
|
|
62
|
+
... xmlns:bldg="http://www.opengis.net/citygml/building/2.0"
|
|
63
|
+
... xmlns:gml="http://www.opengis.net/gml">
|
|
64
|
+
... <core:cityObjectMember><bldg:Building gml:id="B">
|
|
65
|
+
... <bldg:boundedBy><bldg:RoofSurface>
|
|
66
|
+
... <bldg:lod2MultiSurface><gml:MultiSurface><gml:surfaceMember>
|
|
67
|
+
... <gml:Polygon><gml:exterior><gml:LinearRing>
|
|
68
|
+
... <gml:posList srsDimension="3">0 0 1 1 0 1 1 1 1</gml:posList>
|
|
69
|
+
... </gml:LinearRing></gml:exterior></gml:Polygon>
|
|
70
|
+
... </gml:surfaceMember></gml:MultiSurface></bldg:lod2MultiSurface>
|
|
71
|
+
... </bldg:RoofSurface></bldg:boundedBy>
|
|
72
|
+
... </bldg:Building></core:cityObjectMember>
|
|
73
|
+
... </core:CityModel>'''
|
|
74
|
+
>>> frame = CityGmlDriver().read(xml)
|
|
75
|
+
>>> frame["surface_type"].iloc[0]
|
|
76
|
+
'RoofSurface'
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
"""
|
|
80
|
+
from pyramids_citymodels.flatten import to_feature_collection
|
|
81
|
+
from pyramids_citymodels.readers.citygml import parse_citygml
|
|
82
|
+
|
|
83
|
+
model = parse_citygml(source)
|
|
84
|
+
return to_feature_collection(model, **kwargs)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The CityJSON vector driver (issue #11).
|
|
2
|
+
|
|
3
|
+
Registered into the ``pyramids.vector_drivers`` entry-point group, this driver is what
|
|
4
|
+
``FeatureCollection.read_file`` dispatches a CityJSON read into. It follows the class-attribute
|
|
5
|
+
contract pyramids' driver registry reads without constructing the driver: the claims
|
|
6
|
+
(``name``/``long_name``/``suffixes``/…) are class attributes and :meth:`can_open` is a
|
|
7
|
+
classmethod, so discovery never imports the parser or shapely/geopandas — those load only inside
|
|
8
|
+
:meth:`read`, when a read is actually dispatched here.
|
|
9
|
+
|
|
10
|
+
The ``.json`` suffix is shared with GeoJSON, so a suffix alone cannot be trusted: :meth:`can_open`
|
|
11
|
+
**content-sniffs** ``"type": "CityJSON"`` to claim a file, which cleanly separates CityJSON from a
|
|
12
|
+
GeoJSON ``FeatureCollection`` (decision #3 in the ecosystem report).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import pyramids # noqa: F401 # isort:skip (keep first — activates vendored osgeo; see __init__)
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CityJsonDriver:
|
|
21
|
+
"""Read CityJSON (``.city.json`` / ``.json`` / ``.cityjson``) as a ``FeatureCollection``."""
|
|
22
|
+
|
|
23
|
+
name = "cityjson"
|
|
24
|
+
long_name = "CityJSON (3D city model)"
|
|
25
|
+
api_version = 1
|
|
26
|
+
suffixes = (".city.json", ".cityjson", ".json")
|
|
27
|
+
schemes: tuple[str, ...] = ()
|
|
28
|
+
capabilities = frozenset({"read"})
|
|
29
|
+
#: Parsing uses only the standard-library ``json`` module, so there is no optional backend.
|
|
30
|
+
extra: str | None = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def can_open(cls, source: Any) -> bool:
|
|
34
|
+
"""Return ``True`` only when ``source`` is a CityJSON document.
|
|
35
|
+
|
|
36
|
+
Content-sniffs ``"type": "CityJSON"`` rather than trusting the ``.json`` suffix, so a
|
|
37
|
+
GeoJSON file sharing that suffix is declined. Never raises.
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
- CityJSON is claimed; GeoJSON sharing the ``.json`` suffix is declined:
|
|
41
|
+
```python
|
|
42
|
+
>>> CityJsonDriver.can_open('{"type": "CityJSON", "CityObjects": {}, "vertices": []}')
|
|
43
|
+
True
|
|
44
|
+
>>> CityJsonDriver.can_open('{"type": "FeatureCollection", "features": []}')
|
|
45
|
+
False
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
"""
|
|
49
|
+
from pyramids_citymodels.readers.cityjson import looks_like_cityjson
|
|
50
|
+
|
|
51
|
+
return looks_like_cityjson(source)
|
|
52
|
+
|
|
53
|
+
def read(self, source: Any, **kwargs: Any) -> Any:
|
|
54
|
+
"""Parse ``source`` and flatten it into a :class:`pyramids.feature.FeatureCollection`.
|
|
55
|
+
|
|
56
|
+
Keyword Args:
|
|
57
|
+
strategy: ``"footprint"`` or ``"semantic_surfaces"`` (default).
|
|
58
|
+
lod: Level of detail to select; ``None`` takes the highest present.
|
|
59
|
+
geometry_dimension: ``"xyz"`` (default) keeps Z, ``"xy"`` drops it.
|
|
60
|
+
child_inherits_parent_attributes: Whether child rows carry parent attributes.
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
- Read an in-memory CityJSON document into a FeatureCollection:
|
|
64
|
+
```python
|
|
65
|
+
>>> doc = {
|
|
66
|
+
... "type": "CityJSON", "version": "2.0",
|
|
67
|
+
... "CityObjects": {"b": {"type": "Building", "geometry": [
|
|
68
|
+
... {"type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2]]]}]}},
|
|
69
|
+
... "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0]]}
|
|
70
|
+
>>> frame = CityJsonDriver().read(doc)
|
|
71
|
+
>>> len(frame)
|
|
72
|
+
1
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
"""
|
|
76
|
+
from pyramids_citymodels.flatten import to_feature_collection
|
|
77
|
+
from pyramids_citymodels.readers.cityjson import parse_cityjson
|
|
78
|
+
|
|
79
|
+
model = parse_cityjson(source)
|
|
80
|
+
return to_feature_collection(model, **kwargs)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Flatten the IR into a ``FeatureCollection`` row model (issue #8).
|
|
2
|
+
|
|
3
|
+
The 3D, hierarchical city model becomes a flat feature table using the pattern 3DCityDB and the
|
|
4
|
+
QGIS Loader both converge on: **one flat table, a ``surface_type`` discriminator column and a
|
|
5
|
+
``parent_id`` foreign key**, with object attributes passed through. That single shape serves both
|
|
6
|
+
load strategies (:class:`~pyramids_citymodels.options.LoadStrategy`):
|
|
7
|
+
|
|
8
|
+
- ``semantic_surfaces`` — one row per boundary surface, carrying ``surface_type`` (RoofSurface,
|
|
9
|
+
WallSurface, …), the owning object's id/type, its ``parent_id`` and its attributes.
|
|
10
|
+
- ``footprint`` — one row per city object, geometry = the 2D union of its ground surfaces (or,
|
|
11
|
+
when a model tags no ground, the 2D union of all its surfaces).
|
|
12
|
+
|
|
13
|
+
Z is kept or dropped per :class:`~pyramids_citymodels.options.GeometryDimension` (a footprint is
|
|
14
|
+
always 2D). Identical polygons within one object are de-duplicated, which collapses the common
|
|
15
|
+
XLink case where one polygon is referenced by both a semantic surface and a solid shell.
|
|
16
|
+
|
|
17
|
+
**Surface geometries are planar polygons in 3D (2.5D).** A building's *vertical* surface — a wall —
|
|
18
|
+
is a real polygon in 3D space whose projection onto the XY plane is a straight line. Shapely
|
|
19
|
+
measures validity and area on that XY footprint only, so a wall row is deliberately **non-areal**:
|
|
20
|
+
``is_valid`` is ``False`` and ``area`` is ``0`` for it, while roofs and ground (near-horizontal) are
|
|
21
|
+
valid with positive area. This is the documented ``semantic_surfaces`` contract, not a defect —
|
|
22
|
+
operate on the surfaces in 3D (the Z is preserved in ``xyz`` mode), and treat 2D-areal operations
|
|
23
|
+
(overlay, area, ``to_file`` of a 2D driver) as meaningful only for the horizontal surfaces or for a
|
|
24
|
+
``footprint`` load. The footprint load, being a plan projection, excludes these non-areal surfaces.
|
|
25
|
+
|
|
26
|
+
The result is a :class:`pyramids.feature.FeatureCollection` — a GeoDataFrame subclass — so it
|
|
27
|
+
flows straight into the rest of the pyramids stack; this module builds no GIS of its own.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import pyramids # noqa: F401 # isort:skip (keep first — activates vendored osgeo; see __init__)
|
|
31
|
+
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import geopandas as gpd
|
|
35
|
+
from pyramids.feature import FeatureCollection
|
|
36
|
+
from shapely.geometry import MultiPolygon, Polygon
|
|
37
|
+
from shapely.ops import unary_union
|
|
38
|
+
|
|
39
|
+
from pyramids_citymodels.ir import (
|
|
40
|
+
CityModel,
|
|
41
|
+
CityObject,
|
|
42
|
+
Geometry,
|
|
43
|
+
iter_surfaces,
|
|
44
|
+
normalize_lod,
|
|
45
|
+
)
|
|
46
|
+
from pyramids_citymodels.options import GeometryDimension, LoadStrategy, ReadOptions
|
|
47
|
+
|
|
48
|
+
__all__ = ["to_feature_collection"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ring_coordinates(ring: list[Any], keep_z: bool) -> list[tuple[float, ...]]:
|
|
52
|
+
"""Coerce a ring's coordinates to 2D or 3D tuples."""
|
|
53
|
+
if keep_z:
|
|
54
|
+
return [
|
|
55
|
+
(coord[0], coord[1], coord[2])
|
|
56
|
+
if len(coord) >= 3
|
|
57
|
+
else (coord[0], coord[1], 0.0)
|
|
58
|
+
for coord in ring
|
|
59
|
+
]
|
|
60
|
+
return [(coord[0], coord[1]) for coord in ring]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _polygon(surface: list[Any], keep_z: bool) -> Polygon | None:
|
|
64
|
+
"""Build a shapely :class:`Polygon` from a surface (exterior ring first, then holes)."""
|
|
65
|
+
rings = [ring for ring in surface if ring]
|
|
66
|
+
if not rings:
|
|
67
|
+
return None
|
|
68
|
+
exterior = _ring_coordinates(rings[0], keep_z)
|
|
69
|
+
if len(exterior) < 3:
|
|
70
|
+
return None
|
|
71
|
+
holes = [_ring_coordinates(ring, keep_z) for ring in rings[1:] if len(ring) >= 3]
|
|
72
|
+
return Polygon(exterior, holes)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _select_geometries(geometries: list[Geometry], lod: str | None) -> list[Geometry]:
|
|
76
|
+
"""Pick the geometries to flatten: those matching ``lod``, else the highest LOD present."""
|
|
77
|
+
if not geometries:
|
|
78
|
+
return []
|
|
79
|
+
if lod is not None:
|
|
80
|
+
wanted = normalize_lod(lod)
|
|
81
|
+
selected = [g for g in geometries if normalize_lod(g.lod) == wanted]
|
|
82
|
+
if selected:
|
|
83
|
+
return selected
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
def lod_value(geometry: Geometry) -> float:
|
|
87
|
+
normalized = normalize_lod(geometry.lod)
|
|
88
|
+
try:
|
|
89
|
+
return float(normalized) if normalized is not None else -1.0
|
|
90
|
+
except ValueError:
|
|
91
|
+
return -1.0
|
|
92
|
+
|
|
93
|
+
highest = max((lod_value(g) for g in geometries), default=-1.0)
|
|
94
|
+
return [g for g in geometries if lod_value(g) == highest]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ring_key(coords: Any) -> tuple:
|
|
98
|
+
"""A hashable, rounded key for a single ring's coordinates (closing point dropped)."""
|
|
99
|
+
return tuple(round(component, 6) for point in coords[:-1] for component in point)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _polygon_key(polygon: Polygon) -> tuple:
|
|
103
|
+
"""A hashable key for de-duplicating identical polygons.
|
|
104
|
+
|
|
105
|
+
Covers the exterior ring **and every interior ring (hole)**, so two surfaces that share an
|
|
106
|
+
exterior boundary but differ in their holes do not collide and get one wrongly dropped (SF4).
|
|
107
|
+
"""
|
|
108
|
+
return (
|
|
109
|
+
_ring_key(polygon.exterior.coords),
|
|
110
|
+
tuple(_ring_key(interior.coords) for interior in polygon.interiors),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _attributes_for(
|
|
115
|
+
city_object: CityObject, model: CityModel, options: ReadOptions
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""The attribute dict for an object's rows, optionally inheriting its parent's attributes."""
|
|
118
|
+
attributes: dict[str, Any] = {}
|
|
119
|
+
if options.child_inherits_parent_attributes:
|
|
120
|
+
parent_id = city_object.parent
|
|
121
|
+
parent = model.objects.get(parent_id) if parent_id else None
|
|
122
|
+
if parent is not None:
|
|
123
|
+
attributes.update(parent.attributes)
|
|
124
|
+
attributes.update(city_object.attributes)
|
|
125
|
+
return attributes
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _semantic_surface_rows(
|
|
129
|
+
city_object: CityObject,
|
|
130
|
+
geometries: list[Geometry],
|
|
131
|
+
model: CityModel,
|
|
132
|
+
options: ReadOptions,
|
|
133
|
+
) -> list[dict[str, Any]]:
|
|
134
|
+
"""One row per boundary surface, with its ``surface_type`` and owner metadata."""
|
|
135
|
+
keep_z = options.geometry_dimension is GeometryDimension.XYZ
|
|
136
|
+
base_attributes = _attributes_for(city_object, model, options)
|
|
137
|
+
rows: list[dict[str, Any]] = []
|
|
138
|
+
seen: set[tuple] = set()
|
|
139
|
+
for geometry in geometries:
|
|
140
|
+
surface_index = 0
|
|
141
|
+
for surface, semantic_value in iter_surfaces(
|
|
142
|
+
geometry.boundaries, geometry.semantic_values
|
|
143
|
+
):
|
|
144
|
+
polygon = _polygon(surface, keep_z)
|
|
145
|
+
if polygon is None:
|
|
146
|
+
continue
|
|
147
|
+
key = _polygon_key(polygon)
|
|
148
|
+
if key in seen:
|
|
149
|
+
continue
|
|
150
|
+
seen.add(key)
|
|
151
|
+
semantic = geometry.semantic_for(semantic_value)
|
|
152
|
+
rows.append(
|
|
153
|
+
{
|
|
154
|
+
**base_attributes,
|
|
155
|
+
"city_object_id": city_object.id,
|
|
156
|
+
"city_object_type": city_object.type,
|
|
157
|
+
"parent_id": city_object.parent,
|
|
158
|
+
"lod": geometry.lod,
|
|
159
|
+
"surface_type": semantic.type if semantic is not None else None,
|
|
160
|
+
"surface_index": surface_index,
|
|
161
|
+
"geometry": polygon,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
surface_index += 1
|
|
165
|
+
return rows
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _footprint_row(
|
|
169
|
+
city_object: CityObject,
|
|
170
|
+
geometries: list[Geometry],
|
|
171
|
+
model: CityModel,
|
|
172
|
+
options: ReadOptions,
|
|
173
|
+
) -> dict[str, Any] | None:
|
|
174
|
+
"""One 2D row per object: the union of its ground surfaces, else of all its surfaces."""
|
|
175
|
+
ground: list[Polygon] = []
|
|
176
|
+
everything: list[Polygon] = []
|
|
177
|
+
for geometry in geometries:
|
|
178
|
+
for surface, semantic_value in iter_surfaces(
|
|
179
|
+
geometry.boundaries, geometry.semantic_values
|
|
180
|
+
):
|
|
181
|
+
polygon = _polygon(surface, keep_z=False)
|
|
182
|
+
if polygon is None or polygon.area == 0.0:
|
|
183
|
+
# A footprint is a plan projection; a non-areal surface (a vertical wall
|
|
184
|
+
# projects to a line, area 0) is not part of it and would only pollute the
|
|
185
|
+
# union with degenerate, invalid geometry (SF1).
|
|
186
|
+
continue
|
|
187
|
+
everything.append(polygon)
|
|
188
|
+
semantic = geometry.semantic_for(semantic_value)
|
|
189
|
+
if semantic is not None and semantic.type == "GroundSurface":
|
|
190
|
+
ground.append(polygon)
|
|
191
|
+
polygons = ground or everything
|
|
192
|
+
if not polygons:
|
|
193
|
+
return None
|
|
194
|
+
merged = unary_union(polygons)
|
|
195
|
+
if merged.is_empty:
|
|
196
|
+
return None
|
|
197
|
+
if merged.geom_type == "Polygon":
|
|
198
|
+
geometry_value: Any = merged
|
|
199
|
+
elif merged.geom_type == "MultiPolygon":
|
|
200
|
+
geometry_value = merged
|
|
201
|
+
else:
|
|
202
|
+
# A union that collapses to lines/points is not a usable footprint; wrap what polygons
|
|
203
|
+
# we have instead of returning a degenerate geometry.
|
|
204
|
+
geometry_value = MultiPolygon([p for p in polygons if p.geom_type == "Polygon"])
|
|
205
|
+
lods = sorted({g.lod for g in geometries if g.lod is not None})
|
|
206
|
+
return {
|
|
207
|
+
**_attributes_for(city_object, model, options),
|
|
208
|
+
"city_object_id": city_object.id,
|
|
209
|
+
"city_object_type": city_object.type,
|
|
210
|
+
"parent_id": city_object.parent,
|
|
211
|
+
"lod": lods[-1] if lods else None,
|
|
212
|
+
"geometry": geometry_value,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def to_feature_collection(
|
|
217
|
+
model: CityModel,
|
|
218
|
+
*,
|
|
219
|
+
strategy: "str | LoadStrategy" = LoadStrategy.SEMANTIC_SURFACES,
|
|
220
|
+
lod: Any = None,
|
|
221
|
+
geometry_dimension: "str | GeometryDimension" = GeometryDimension.XYZ,
|
|
222
|
+
child_inherits_parent_attributes: bool = False,
|
|
223
|
+
) -> FeatureCollection:
|
|
224
|
+
"""Flatten a :class:`~pyramids_citymodels.ir.CityModel` into a ``FeatureCollection``.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
model: The parsed city model.
|
|
228
|
+
strategy: ``"footprint"`` (one row per object) or ``"semantic_surfaces"`` (one row per
|
|
229
|
+
boundary surface). Accepts a :class:`~pyramids_citymodels.options.LoadStrategy`.
|
|
230
|
+
lod: Level of detail to select; ``None`` takes the highest each object offers. An explicit
|
|
231
|
+
``lod`` that no object carries selects nothing and yields an **empty** FeatureCollection
|
|
232
|
+
(no error) — pass ``None`` to always get the best available.
|
|
233
|
+
geometry_dimension: ``"xyz"`` keeps Z, ``"xy"`` drops it. Ignored for ``footprint``.
|
|
234
|
+
child_inherits_parent_attributes: When ``True``, child rows also carry parent attributes.
|
|
235
|
+
|
|
236
|
+
Note:
|
|
237
|
+
The structural columns ``city_object_id``, ``city_object_type``, ``parent_id``, ``lod``,
|
|
238
|
+
``surface_type`` and ``surface_index`` take precedence: a city-object attribute that happens
|
|
239
|
+
to share one of these names is overwritten by the structural value in the output table.
|
|
240
|
+
|
|
241
|
+
A ``footprint`` load omits an object whose surfaces are **all non-areal** (e.g. a wall-only
|
|
242
|
+
object): it has no plan-view area, so it contributes no row. This is omission by design, not
|
|
243
|
+
data loss — use ``semantic_surfaces`` to keep every surface.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
A :class:`pyramids.feature.FeatureCollection` in the model's CRS, with a stable column
|
|
247
|
+
order; empty (but still a valid FeatureCollection) when the model has no usable geometry.
|
|
248
|
+
|
|
249
|
+
Examples:
|
|
250
|
+
- Flatten a one-building model into one row per semantic surface and read it back:
|
|
251
|
+
```python
|
|
252
|
+
>>> from pyramids_citymodels.readers.cityjson import parse_cityjson
|
|
253
|
+
>>> doc = {
|
|
254
|
+
... "type": "CityJSON", "version": "2.0",
|
|
255
|
+
... "CityObjects": {"b": {"type": "Building", "geometry": [
|
|
256
|
+
... {"type": "MultiSurface", "lod": "2", "boundaries": [[[0, 1, 2]]],
|
|
257
|
+
... "semantics": {"surfaces": [{"type": "RoofSurface"}], "values": [0]}}]}},
|
|
258
|
+
... "vertices": [[0, 0, 0], [1, 0, 0], [1, 1, 0]]}
|
|
259
|
+
>>> frame = to_feature_collection(parse_cityjson(doc), strategy="semantic_surfaces")
|
|
260
|
+
>>> len(frame)
|
|
261
|
+
1
|
|
262
|
+
>>> frame["surface_type"].iloc[0]
|
|
263
|
+
'RoofSurface'
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
"""
|
|
267
|
+
options = ReadOptions.coerce(
|
|
268
|
+
strategy=strategy,
|
|
269
|
+
lod=lod,
|
|
270
|
+
geometry_dimension=geometry_dimension,
|
|
271
|
+
child_inherits_parent_attributes=child_inherits_parent_attributes,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
rows: list[dict[str, Any]] = []
|
|
275
|
+
for city_object in model.objects.values():
|
|
276
|
+
geometries = _select_geometries(city_object.geometries, options.lod)
|
|
277
|
+
if not geometries:
|
|
278
|
+
continue
|
|
279
|
+
if options.strategy is LoadStrategy.SEMANTIC_SURFACES:
|
|
280
|
+
rows.extend(_semantic_surface_rows(city_object, geometries, model, options))
|
|
281
|
+
else:
|
|
282
|
+
row = _footprint_row(city_object, geometries, model, options)
|
|
283
|
+
if row is not None:
|
|
284
|
+
rows.append(row)
|
|
285
|
+
|
|
286
|
+
if rows:
|
|
287
|
+
frame = gpd.GeoDataFrame(rows, geometry="geometry", crs=model.crs)
|
|
288
|
+
else:
|
|
289
|
+
frame = gpd.GeoDataFrame({"geometry": []}, geometry="geometry", crs=model.crs)
|
|
290
|
+
return FeatureCollection(frame)
|