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.
@@ -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)