floorplan-scene 0.1.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.
floorplan_scene/svg.py ADDED
@@ -0,0 +1,978 @@
1
+ """Compact semantic SVG translated from the editable Scene geometry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ import xml.etree.ElementTree as ET
9
+ from typing import Any, cast
10
+
11
+ from defusedxml.common import DefusedXmlException # type: ignore[import-untyped]
12
+ from defusedxml.ElementTree import fromstring # type: ignore[import-untyped]
13
+ from shapely.geometry import LineString # type: ignore[import-untyped]
14
+ from svg.path import Arc, Close, CubicBezier, Move, QuadraticBezier, parse_path
15
+ from svg.path import Line as SvgLine
16
+
17
+ from .model import (
18
+ BayWindowParameters,
19
+ Building,
20
+ DimensionAnnotation,
21
+ FloorOpening,
22
+ FloorplanScene,
23
+ Furniture,
24
+ FurniturePlacement,
25
+ InspectionHatchParameters,
26
+ Opening,
27
+ OpeningSegment,
28
+ Room,
29
+ Separator,
30
+ Wall,
31
+ Zone,
32
+ read_json,
33
+ scene_from_dict,
34
+ scene_to_dict,
35
+ )
36
+ from .presentation import PAINT, FurniturePresentation, ScenePresentation, SvgDecoration
37
+ from .shape_geometry import geometry_shape, object_shape
38
+ from .shapes import (
39
+ ArcGeometry,
40
+ BayWindowGeometry,
41
+ CircleGeometry,
42
+ Curve,
43
+ CurveRing,
44
+ Geometry,
45
+ LineGeometry,
46
+ PathGeometry,
47
+ PipeGeometry,
48
+ Point,
49
+ PolygonGeometry,
50
+ QuadraticGeometry,
51
+ Ring,
52
+ )
53
+
54
+ NS = "http://www.w3.org/2000/svg"
55
+ BAY_FIELDS = {"beamThickness": "beam-thickness", "upperFloorThickness": "upper-floor-thickness",
56
+ "lowerFloorThickness": "lower-floor-thickness", "wallThickness": "wall-thickness",
57
+ "offset": "offset", "windowHeight": "window-height", "platformType": "platform-type"}
58
+ # data-* carries inert metadata; only the fields consumed below affect the Scene.
59
+ ATTRIBUTES = {
60
+ "svg": {"viewBox"},
61
+ "metadata": set(),
62
+ "g": {"id", "transform"} | PAINT,
63
+ "line": {"id", "x1", "y1", "x2", "y2"} | PAINT,
64
+ "path": {"id", "d"} | PAINT,
65
+ "polygon": {"id", "points"} | PAINT,
66
+ "polyline": {"points"} | PAINT,
67
+ "circle": {"id", "cx", "cy", "r"} | PAINT,
68
+ }
69
+ CURVES = (LineGeometry, ArcGeometry, QuadraticGeometry)
70
+
71
+
72
+ def _number(value: float) -> str:
73
+ number = float(value)
74
+ if not number:
75
+ return "0"
76
+ return str(int(number)) if number.is_integer() else repr(number)
77
+
78
+
79
+ def _area(value: float) -> str:
80
+ return f"{value:.2f}"
81
+
82
+
83
+ def _point_text(point: Point) -> str:
84
+ return f"{_number(point[0])},{_number(point[1])}"
85
+
86
+
87
+ def _path(rings: tuple[Ring, ...]) -> str:
88
+ return " ".join("M " + " L ".join(_point_text(point) for point in ring[:-1]) + " Z" for ring in rings)
89
+
90
+
91
+ def _curve_command(curve: Curve) -> str:
92
+ if isinstance(curve, LineGeometry):
93
+ return "L " + _point_text(curve.end)
94
+ if isinstance(curve, QuadraticGeometry):
95
+ return "Q " + _point_text(curve.control) + " " + _point_text(curve.end)
96
+ if curve.bulge == 0:
97
+ return "A 0,0 0 0,0 " + _point_text(curve.end)
98
+ chord = math.dist(curve.start, curve.end)
99
+ radius = chord * (1 + curve.bulge ** 2) / (4 * abs(curve.bulge))
100
+ return (f"A {_number(radius)},{_number(radius)} 0 {int(abs(curve.bulge) > 1)},"
101
+ f"{int(curve.bulge > 0)} " + _point_text(curve.end))
102
+
103
+
104
+ def _curve_path(rings: tuple[CurveRing, ...]) -> str:
105
+ return " ".join("M " + _point_text(ring[0].start) + " "
106
+ + " ".join(_curve_command(curve) for curve in ring) + " Z" for ring in rings)
107
+
108
+
109
+ def _curve_node(parent: ET.Element, curve: Curve, attrs: dict[str, str]) -> ET.Element:
110
+ if isinstance(curve, LineGeometry):
111
+ return ET.SubElement(parent, "line", {"x1": _number(curve.start[0]), "y1": _number(curve.start[1]),
112
+ "x2": _number(curve.end[0]), "y2": _number(curve.end[1]), **attrs})
113
+ return ET.SubElement(parent, "path", {"d": "M " + _point_text(curve.start) + " " + _curve_command(curve),
114
+ "fill": "none", **attrs})
115
+
116
+
117
+ def _polygon_nodes(parent: ET.Element, geometry: PolygonGeometry) -> None:
118
+ for rings in geometry.polygons:
119
+ if len(rings) == 1:
120
+ ET.SubElement(parent, "polygon", {"points": " ".join(_point_text(p) for p in rings[0][:-1])})
121
+ else:
122
+ ET.SubElement(parent, "path", {"d": _path(rings), "fill-rule": "evenodd"})
123
+
124
+
125
+ def _geometry_nodes(
126
+ parent: ET.Element,
127
+ geometry: Geometry,
128
+ *,
129
+ thickness: float | None = None,
130
+ ) -> None:
131
+ if isinstance(geometry, CURVES):
132
+ _curve_node(parent, geometry, {"stroke-width": _number(thickness or 10), "stroke-linecap": "butt"})
133
+ elif isinstance(geometry, PolygonGeometry):
134
+ _polygon_nodes(parent, geometry)
135
+ elif isinstance(geometry, PathGeometry):
136
+ for rings in geometry.polygons:
137
+ ET.SubElement(parent, "path", {"d": _curve_path(rings), "fill-rule": "evenodd"})
138
+ elif isinstance(geometry, CircleGeometry):
139
+ ET.SubElement(parent, "circle", {"cx": _number(geometry.center[0]), "cy": _number(geometry.center[1]),
140
+ "r": _number(geometry.radius)})
141
+ elif isinstance(geometry, PipeGeometry):
142
+ parent.attrib.update({"data-center": _point_text(geometry.center),
143
+ "data-width": _number(geometry.width),
144
+ "data-depth": _number(geometry.depth), "data-angle": _number(geometry.angle),
145
+ "data-profile": geometry.profile})
146
+ for index, value in enumerate(geometry.wallThickness, 1):
147
+ parent.set(f"data-wall-thickness-{index}", _number(value))
148
+ shape = geometry_shape(geometry)
149
+ polygons = [shape] if shape.geom_type == "Polygon" else list(shape.geoms)
150
+ _polygon_nodes(parent, PolygonGeometry(tuple(tuple(tuple((float(x), float(y)) for x, y in ring.coords)
151
+ for ring in (poly.exterior, *poly.interiors)) for poly in polygons)))
152
+ elif isinstance(geometry, BayWindowGeometry):
153
+ parent.set("data-profile", geometry.profile)
154
+ ET.SubElement(parent, "polygon", {"data-part": "outline",
155
+ "points": " ".join(_point_text(p) for p in geometry.outline[:-1])})
156
+ ET.SubElement(parent, "polyline", {"data-part": "wall-path",
157
+ "points": " ".join(_point_text(p) for p in geometry.wallPath),
158
+ "fill": "none", "stroke-width": "10", "stroke-linecap": "butt"})
159
+
160
+
161
+ def _geometry_entity(
162
+ parent: ET.Element,
163
+ geometry: Geometry,
164
+ attrs: dict[str, str],
165
+ *,
166
+ thickness: float | None = None,
167
+ ) -> ET.Element:
168
+ node = ET.SubElement(parent, "g", attrs)
169
+ if isinstance(geometry, (PathGeometry, PipeGeometry, BayWindowGeometry)) or (
170
+ isinstance(geometry, PolygonGeometry)
171
+ and (len(geometry.polygons) != 1 or len(geometry.polygons[0]) != 1)
172
+ ):
173
+ node.set("data-shape", geometry.type.replace("bay_window", "bayWindow"))
174
+ _geometry_nodes(node, geometry, thickness=thickness)
175
+ return node
176
+
177
+
178
+ def _attribute_name(name: str) -> str:
179
+ return "data-" + re.sub(r"([A-Z])", r"-\1", name).lower()
180
+
181
+
182
+ def _optional(node: ET.Element, obj: Any, names: tuple[str, ...]) -> None:
183
+ for name in names:
184
+ value = getattr(obj, name)
185
+ if value is not None:
186
+ node.set(_attribute_name(name), str(value).lower() if isinstance(value, bool) else
187
+ _number(value) if isinstance(value, (int, float)) else str(value))
188
+
189
+
190
+ def opening_width(opening: Opening, host: Wall | None) -> float:
191
+ if opening.kind == "niche":
192
+ return 10 if opening.depth is None else opening.depth
193
+ return (host.thickness or 100) if host else 100
194
+
195
+
196
+ def scene_to_svg(scene: FloorplanScene) -> str:
197
+ """Export visible geometry with collection defaults inherited from semantic layers."""
198
+ scene = scene_from_dict(scene_to_dict(scene))
199
+ shapes = [geometry_shape(wall.geometry, wall.thickness) for wall in scene.walls]
200
+ shapes += [geometry_shape(item.geometry, item.thickness) for item in scene.buildings]
201
+ shapes += [geometry_shape(item.geometry) for item in scene.floorOpenings]
202
+ shapes += [object_shape(item) for item in scene.furniture]
203
+ shapes += [geometry_shape(zone.geometry) for zone in scene.zones]
204
+ shapes += [geometry_shape(separator.geometry) for separator in scene.separators]
205
+ shapes += [geometry_shape(room.geometry) for room in scene.rooms]
206
+ shapes += [geometry_shape(segment.geometry) for opening in scene.openings for segment in opening.segments]
207
+ points = [point for shape in shapes if not shape.is_empty for point in
208
+ ((shape.bounds[0], shape.bounds[1]), (shape.bounds[2], shape.bounds[3]))]
209
+ points += [point for item in scene.dimensionAnnotations
210
+ for point in (item.geometry.start, item.geometry.end)]
211
+ if not points:
212
+ raise ValueError("cannot render an empty scene")
213
+ xs, ys = zip(*points)
214
+ padding = max([200.0, *(wall.thickness or 0 for wall in scene.walls)])
215
+ if scene.presentation is not None:
216
+ viewbox = scene.presentation.viewBox
217
+ elif scene.metadata is None or scene.metadata.originImage is None:
218
+ viewbox = (min(xs) - padding, -max(ys) - padding,
219
+ max(xs) - min(xs) + 2 * padding, max(ys) - min(ys) + 2 * padding)
220
+ else:
221
+ x, y, width, height = scene.metadata.originImage.sceneBounds
222
+ viewbox = x, -y - height, width, height
223
+ root = ET.Element("svg", {"xmlns": NS, "data-length-unit": scene.unit, "data-area-unit": "m2",
224
+ "data-angle-unit": "deg",
225
+ "viewBox": " ".join(_number(v) for v in viewbox)})
226
+ _optional(root, scene, ("height",))
227
+ if scene.metadata is not None:
228
+ metadata = ET.SubElement(root, "metadata")
229
+ metadata.text = json.dumps(scene_to_dict(scene)["metadata"], ensure_ascii=False,
230
+ separators=(",", ":"), sort_keys=True)
231
+ world = ET.SubElement(root, "g", {"transform": "scale(1 -1)"})
232
+
233
+ if scene.zones:
234
+ layer = ET.SubElement(world, "g", {"data-layer": "zones"})
235
+ for zone in scene.zones:
236
+ attrs = {"id": zone.id}
237
+ if zone.name is not None:
238
+ attrs["data-name"] = zone.name
239
+ _geometry_entity(layer, zone.geometry, attrs)
240
+
241
+ if scene.rooms:
242
+ layer = ET.SubElement(world, "g", {"data-layer": "rooms"})
243
+ for room in scene.rooms:
244
+ attrs = {"id": room.id, "data-type": room.type,
245
+ "data-label-point": _point_text(room.labelPoint)}
246
+ if room.roomTypeId is not None:
247
+ attrs["data-room-type-id"] = str(room.roomTypeId)
248
+ if room.name is not None:
249
+ attrs["data-name"] = room.name
250
+ if room.area is not None:
251
+ attrs["data-area"] = _area(room.area)
252
+ _geometry_entity(layer, room.geometry, attrs)
253
+
254
+ if scene.floorOpenings:
255
+ layer = ET.SubElement(world, "g", {"data-layer": "floorOpenings"})
256
+ for floor_opening in scene.floorOpenings:
257
+ node = _geometry_entity(layer, floor_opening.geometry, {"id": floor_opening.id})
258
+ _optional(node, floor_opening, ("name", "bottomElevation", "thickness"))
259
+
260
+ def add_object(parent: ET.Element, item: Building | Furniture) -> None:
261
+ attrs = {"id": item.id, "data-type": item.type}
262
+ if isinstance(item, Furniture):
263
+ center_x, center_y = item.placement.center
264
+ attrs.update({
265
+ "data-width": _number(item.placement.width),
266
+ "data-depth": _number(item.placement.depth),
267
+ "transform": (
268
+ f"translate({_number(center_x)} {_number(center_y)}) "
269
+ f"rotate({_number(item.placement.rotation)})"
270
+ ),
271
+ })
272
+ if isinstance(item, Furniture):
273
+ if item.shape is not None:
274
+ attrs["data-legend-shape"] = item.shape
275
+ for field in ("upStairDirections", "path", "attributes"):
276
+ value = getattr(item, field)
277
+ if value is not None:
278
+ attrs[_attribute_name(field)] = json.dumps(value, ensure_ascii=False, allow_nan=False,
279
+ separators=(",", ":"))
280
+ node = _geometry_entity(parent, item.geometry, attrs, thickness=item.thickness)
281
+ if isinstance(item, Furniture) and item.presentation is not None:
282
+ if len(node) == 1:
283
+ footprint = node[0]
284
+ else:
285
+ # A compound physical footprint is still one object, not decoration fragments.
286
+ footprint = ET.Element("g", {"data-shape": "polygon"})
287
+ for child in list(node):
288
+ node.remove(child)
289
+ footprint.append(child)
290
+ node.append(footprint)
291
+ footprint.set("data-role", "footprint")
292
+ footprint.attrib.update(item.presentation.footprintStyle)
293
+ node.remove(footprint)
294
+ for decoration in item.presentation.decorations:
295
+ ET.SubElement(node, decoration.tag, {**decoration.attributes, "data-role": "decoration"})
296
+ node.insert(item.presentation.footprintIndex, footprint)
297
+ if isinstance(item.geometry, CURVES):
298
+ node.set("data-thickness", "unknown" if item.thickness is None else _number(item.thickness))
299
+ else:
300
+ _optional(node, item, ("thickness",))
301
+ _optional(node, item, ("height", "name"))
302
+ if item.bottomElevation is not None:
303
+ node.set("data-bottom-elevation", _number(item.bottomElevation))
304
+ if isinstance(item, Building) and item.type == "roof":
305
+ node[0].set("data-role", "footprint")
306
+ for field in ("height", "bottomElevation", "thickness"):
307
+ value = getattr(item, field)
308
+ node.set(_attribute_name(field), "unknown" if value is None else _number(value))
309
+ for ridge in item.ridgeLines:
310
+ _curve_node(node, ridge, {"data-role": "ridge"})
311
+ if isinstance(item, Building) and item.direction is not None:
312
+ node.set("data-direction", _point_text(item.direction))
313
+ if isinstance(item, Building) and item.inspectionHatch is not None:
314
+ node.set("data-hatch-surface", item.inspectionHatch.surface)
315
+ if item.inspectionHatch.hasStorage is not None:
316
+ node.set("data-hatch-storage", str(item.inspectionHatch.hasStorage).lower())
317
+ if isinstance(item, Building) and item.bayWindow is not None:
318
+ for field, key in BAY_FIELDS.items():
319
+ value = getattr(item.bayWindow, field)
320
+ if value is not None:
321
+ node.set("data-bay-" + key,
322
+ _number(value) if isinstance(value, (int, float)) else str(value))
323
+
324
+ if scene.buildings:
325
+ layer = ET.SubElement(world, "g", {"data-layer": "buildings"})
326
+ for building in scene.buildings:
327
+ add_object(layer, building)
328
+ if scene.furniture:
329
+ layer = ET.SubElement(world, "g", {"data-layer": "furniture"})
330
+ for item in scene.furniture:
331
+ add_object(layer, item)
332
+
333
+ if scene.walls:
334
+ layer = ET.SubElement(world, "g", {"data-layer": "walls"})
335
+ for wall in scene.walls:
336
+ attrs = {"id": wall.id}
337
+ if wall.loadBearing:
338
+ attrs["data-load-bearing"] = "true"
339
+ node = _geometry_entity(layer, wall.geometry, attrs, thickness=wall.thickness)
340
+ node.set("data-thickness", "unknown" if wall.thickness is None else _number(wall.thickness))
341
+ if wall.height is None and scene.height is not None:
342
+ node.set("data-height", "unknown")
343
+ elif wall.height != scene.height:
344
+ _optional(node, wall, ("height",))
345
+
346
+ if scene.separators:
347
+ layer = ET.SubElement(world, "g", {"data-layer": "separators"})
348
+ for separator in scene.separators:
349
+ _geometry_entity(layer, separator.geometry, {"id": separator.id})
350
+
351
+ wall_by_id = {wall.id: wall for wall in scene.walls}
352
+ if scene.openings:
353
+ layer = ET.SubElement(world, "g", {"data-layer": "openings"})
354
+ for opening in scene.openings:
355
+ attrs = {"id": opening.id}
356
+ if opening.type is not None:
357
+ attrs["data-type"] = opening.type
358
+ holder = ET.SubElement(layer, "g", attrs)
359
+ _optional(holder, opening, ("height", "depth"))
360
+ if opening.bottomElevation is not None:
361
+ holder.set("data-bottom-elevation", _number(opening.bottomElevation))
362
+ for segment in opening.segments:
363
+ host = wall_by_id.get(segment.wallId or "")
364
+ segment_attrs = {"stroke-width": _number(opening_width(opening, host))}
365
+ if segment.wallId is not None:
366
+ segment_attrs["data-wall-id"] = segment.wallId
367
+ node = _curve_node(holder, segment.geometry, segment_attrs)
368
+ _optional(node, segment, ("openSide", "outside"))
369
+ if scene.dimensionAnnotations:
370
+ layer = ET.SubElement(world, "g", {"data-layer": "dimensions"})
371
+ for dimension in scene.dimensionAnnotations:
372
+ attrs = {
373
+ "id": dimension.id,
374
+ "data-kind": dimension.kind,
375
+ "data-value": str(dimension.value),
376
+ }
377
+ if dimension.refId is not None:
378
+ attrs["data-ref-id"] = dimension.refId
379
+ holder = ET.SubElement(layer, "g", attrs)
380
+ _curve_node(holder, dimension.geometry, {"data-role": "measurement-span"})
381
+ entity_nodes = {node.get("id"): node for layer in world for node in layer}
382
+ for collection in ("rooms", "walls", "separators", "openings", "floorOpenings", "buildings",
383
+ "furniture", "zones", "dimensionAnnotations"):
384
+ for item in getattr(scene, collection):
385
+ if item.attributes is not None:
386
+ entity_nodes[item.id].set("data-attributes", json.dumps(
387
+ item.attributes, ensure_ascii=False, allow_nan=False, separators=(",", ":")))
388
+ return ET.tostring(root, encoding="unicode") + "\n"
389
+
390
+
391
+ def _finite(text: str | None, *, bounded: bool = True) -> float:
392
+ value = float(text or "nan")
393
+ if not math.isfinite(value) or bounded and abs(value) > 1_000_000:
394
+ raise ValueError("SVG coordinates must be finite millimetres within 1 km")
395
+ return value
396
+
397
+
398
+ def _tag(node: ET.Element) -> str:
399
+ return node.tag.removeprefix("{" + NS + "}")
400
+
401
+
402
+ def _point(value: complex) -> Point:
403
+ return _finite(str(value.real)), _finite(str(value.imag))
404
+
405
+
406
+ def _points(node: ET.Element) -> tuple[Point, ...]:
407
+ values = [_finite(token) for token in re.split(r"[\s,]+", node.attrib["points"].strip())]
408
+ if len(values) % 2:
409
+ raise ValueError("points require coordinate pairs")
410
+ return tuple((values[i], values[i + 1]) for i in range(0, len(values), 2))
411
+
412
+
413
+ def _read_path(node: ET.Element, *, closed: bool) -> tuple[CurveRing, ...]:
414
+ if closed and (node.get("fill") == "none" or "stroke" in node.attrib or "stroke-width" in node.attrib):
415
+ raise ValueError("closed geometry must use its filled outline without an extra stroke")
416
+ rings: list[CurveRing] = []
417
+ ring: list[Curve] = []
418
+ start: Point | None = None
419
+ try:
420
+ parts = parse_path(node.attrib["d"])
421
+ except (ValueError, IndexError, KeyError, ZeroDivisionError) as exc:
422
+ raise ValueError("invalid SVG path") from exc
423
+ if len(parts) > 10000:
424
+ raise ValueError("SVG path contains too many segments")
425
+ for part in parts:
426
+ end = _point(part.end)
427
+ if isinstance(part, Move):
428
+ if start is not None:
429
+ raise ValueError("path subpaths must close")
430
+ start = end
431
+ elif isinstance(part, Close):
432
+ if not closed or start is None or not ring or end != start:
433
+ raise ValueError("invalid closed path")
434
+ if ring[-1].end != start:
435
+ ring.append(LineGeometry(ring[-1].end, start))
436
+ rings.append(tuple(ring))
437
+ ring, start = [], None
438
+ elif start is None:
439
+ raise ValueError("path must start with M")
440
+ elif isinstance(part, SvgLine):
441
+ ring.append(LineGeometry(_point(part.start), end))
442
+ elif isinstance(part, QuadraticBezier):
443
+ ring.append(QuadraticGeometry(_point(part.start), _point(part.control), end))
444
+ elif isinstance(part, Arc):
445
+ rx, ry = _finite(str(part.radius.real), bounded=False), _finite(str(part.radius.imag), bounded=False)
446
+ if rx < 0 or not math.isclose(rx, ry, abs_tol=1e-6) or part.start == part.end:
447
+ raise ValueError("only non-degenerate circular A arcs are supported")
448
+ _finite(str(part.rotation))
449
+ bulge = 0.0
450
+ if rx:
451
+ # The half-angle identity avoids acos cancellation for almost-straight arcs.
452
+ ratio = min(1.0, math.dist(_point(part.start), end) / rx / part.radius_scale / 2)
453
+ bulge = ratio / (1 + math.sqrt(max(0.0, 1 - ratio * ratio)))
454
+ if part.arc:
455
+ bulge = 1 / bulge
456
+ if not part.sweep:
457
+ bulge = -bulge
458
+ ring.append(ArcGeometry(_point(part.start), end, bulge))
459
+ else:
460
+ raise ValueError("only L, circular A and Q path segments are supported")
461
+ if closed:
462
+ if start is not None or not rings:
463
+ raise ValueError("outline needs closed rings")
464
+ if len(rings) > 1 and node.get("fill-rule") != "evenodd":
465
+ raise ValueError("outlines with holes require fill-rule=evenodd")
466
+ elif start is None or len(ring) != 1:
467
+ raise ValueError("a boundary curve must contain exactly one L, A or Q segment")
468
+ else:
469
+ rings.append(tuple(ring))
470
+ return tuple(rings)
471
+
472
+
473
+ def _rings(node: ET.Element) -> tuple[Ring, ...]:
474
+ if _tag(node) == "polygon":
475
+ points = _points(node)
476
+ if len(points) < 3:
477
+ raise ValueError("polygon needs at least three coordinate pairs")
478
+ return (points if points[0] == points[-1] else (*points, points[0]),)
479
+ if _tag(node) != "path":
480
+ raise ValueError("outline requires path or polygon")
481
+ curves = _read_path(node, closed=True)
482
+ if any(not isinstance(curve, LineGeometry) for ring in curves for curve in ring):
483
+ raise ValueError("polygon outlines require straight segments")
484
+ return tuple((ring[0].start, *(curve.end for curve in ring)) for ring in curves)
485
+
486
+
487
+ def _read_curve(node: ET.Element) -> Curve:
488
+ if _tag(node) == "line":
489
+ return LineGeometry((_finite(node.get("x1")), _finite(node.get("y1"))),
490
+ (_finite(node.get("x2")), _finite(node.get("y2"))))
491
+ if _tag(node) == "path":
492
+ if node.get("fill") != "none":
493
+ raise ValueError("open curves require fill=none")
494
+ return _read_path(node, closed=False)[0][0]
495
+ raise ValueError("curve requires line or path")
496
+
497
+
498
+ def _read_compound_wall_path(node: ET.Element) -> PolygonGeometry:
499
+ """Preserve a browser-valid compound wall centerline as its deterministic footprint."""
500
+ if _tag(node) != "g" or len(node) != 1 or _tag(node[0]) != "path" or node[0].get("fill") != "none":
501
+ raise ValueError("compound wall centerlines require one open path")
502
+ path = node[0]
503
+ try:
504
+ parts = parse_path(path.attrib["d"])
505
+ except (ValueError, IndexError, KeyError, ZeroDivisionError) as exc:
506
+ raise ValueError("invalid SVG path") from exc
507
+ if len(parts) > 10000:
508
+ raise ValueError("SVG path contains too many segments")
509
+ points: list[Point] = []
510
+ started = False
511
+ sample_total = 0
512
+ for part in parts:
513
+ if isinstance(part, Move):
514
+ if started:
515
+ raise ValueError("wall paths require one open subpath")
516
+ points.append(_point(part.end))
517
+ started = True
518
+ continue
519
+ if not started or isinstance(part, Close):
520
+ raise ValueError("wall paths require one open subpath")
521
+ if not isinstance(part, (SvgLine, QuadraticBezier, CubicBezier, Arc)):
522
+ raise TypeError("unsupported wall path segment")
523
+ for name in ("start", "end", "control", "control1", "control2"):
524
+ value = getattr(part, name, None)
525
+ if value is not None:
526
+ _point(value)
527
+ if isinstance(part, Arc):
528
+ _finite(str(part.radius.real), bounded=False)
529
+ _finite(str(part.radius.imag), bounded=False)
530
+ _finite(str(part.rotation))
531
+ samples = 1 if isinstance(part, SvgLine) else 64
532
+ sample_total += samples
533
+ if sample_total > 100000:
534
+ raise ValueError("wall path exceeds projection size limit")
535
+ points.extend(_point(part.point(index / samples)) for index in range(1, samples + 1))
536
+ if len(points) < 2:
537
+ raise ValueError("wall path requires at least one segment")
538
+ marker = node.get("data-thickness")
539
+ thickness = _finite(path.get("stroke-width")) if marker in {None, "unknown"} else _finite(marker)
540
+ if thickness <= 0:
541
+ raise ValueError("wall thickness must be positive")
542
+ footprint = LineString(points).buffer(thickness / 2, cap_style="flat", join_style="mitre")
543
+ if footprint.is_empty or not footprint.is_valid:
544
+ raise ValueError("wall path does not produce a valid footprint")
545
+ polygons = [footprint] if footprint.geom_type == "Polygon" else list(footprint.geoms)
546
+ return PolygonGeometry(tuple(tuple(
547
+ tuple((_finite(str(x)), _finite(str(y))) for x, y in ring.coords)
548
+ for ring in (polygon.exterior, *polygon.interiors)
549
+ ) for polygon in polygons))
550
+
551
+
552
+ def _read_wall_geometry(node: ET.Element) -> Geometry:
553
+ if (
554
+ node.get("data-shape") == "path"
555
+ and _tag(node) == "g"
556
+ and len(node) == 1
557
+ and _tag(node[0]) == "path"
558
+ and node[0].get("fill") == "none"
559
+ ):
560
+ return _read_compound_wall_path(node)
561
+ try:
562
+ return _read_geometry(node)
563
+ except ValueError as exc:
564
+ if str(exc) not in {
565
+ "a boundary curve must contain exactly one L, A or Q segment",
566
+ "only L, circular A and Q path segments are supported",
567
+ }:
568
+ raise
569
+ return _read_compound_wall_path(node)
570
+
571
+
572
+ def _read_geometry(node: ET.Element) -> Geometry:
573
+ tag = _tag(node)
574
+ if tag == "line":
575
+ return _read_curve(node)
576
+ if tag == "polygon":
577
+ return PolygonGeometry(((_rings(node)),))
578
+ if tag == "circle":
579
+ return CircleGeometry((_finite(node.get("cx")), _finite(node.get("cy"))), _finite(node.get("r")))
580
+ if tag == "path":
581
+ if node.get("data-shape") == "path":
582
+ return PathGeometry((_read_path(node, closed=True),))
583
+ try:
584
+ closed = any(isinstance(part, Close) for part in parse_path(node.attrib["d"]))
585
+ except (ValueError, IndexError, KeyError, ZeroDivisionError) as exc:
586
+ raise ValueError("invalid SVG path") from exc
587
+ return PolygonGeometry(((_rings(node)),)) if closed else _read_curve(node)
588
+ if tag != "g":
589
+ raise ValueError("unsupported geometry element")
590
+ kind = node.get("data-shape")
591
+ if kind is None and len(node) == 1:
592
+ return _read_geometry(node[0])
593
+ if kind == "polygon":
594
+ return PolygonGeometry(tuple(_rings(child) for child in node))
595
+ if kind == "path":
596
+ if not len(node) or any(_tag(child) != "path" for child in node):
597
+ raise ValueError("path geometry requires native paths")
598
+ return PathGeometry(tuple(_read_path(child, closed=True) for child in node))
599
+ if kind == "pipe":
600
+ profile = node.get("data-profile", "")
601
+ if profile not in {"L", "U", "RECT"}:
602
+ raise ValueError("unsupported pipe profile")
603
+ count = {"L": 2, "U": 3, "RECT": 4}[profile]
604
+ center = node.get("data-center", "").split(",")
605
+ if len(center) != 2:
606
+ raise ValueError("pipe center requires x,y")
607
+ for child in node:
608
+ _rings(child)
609
+ if not len(node):
610
+ raise ValueError("pipe needs a visible outline")
611
+ return PipeGeometry((_finite(center[0]), _finite(center[1])), _finite(node.get("data-width")),
612
+ _finite(node.get("data-depth")), _finite(node.get("data-angle")),
613
+ cast(Any, profile),
614
+ tuple(_finite(node.get(f"data-wall-thickness-{i}"))
615
+ for i in range(1, count + 1)))
616
+ if kind == "bayWindow":
617
+ if len(node) != 2 or _tag(node[0]) != "polygon" or _tag(node[1]) != "polyline" or (
618
+ node[0].get("data-part"), node[1].get("data-part")) != ("outline", "wall-path"):
619
+ raise ValueError("bay window needs a polygon outline and open wall-path polyline")
620
+ wall_path = _points(node[1])
621
+ if len(wall_path) < 2:
622
+ raise ValueError("bay window wall path needs at least two points")
623
+ return BayWindowGeometry(_rings(node[0])[0], wall_path, node.attrib["data-profile"])
624
+ raise ValueError(f"unsupported geometry type: {kind}")
625
+
626
+
627
+ def _entity_id(node: ET.Element) -> str:
628
+ value = node.get("id", "")
629
+ if not value.strip():
630
+ raise ValueError("entities require an id")
631
+ return value
632
+
633
+
634
+ def _thickness(node: ET.Element, geometry: Geometry) -> float | None:
635
+ marker = node.get("data-thickness")
636
+ if marker == "unknown":
637
+ return None
638
+ if marker is not None:
639
+ return _finite(marker)
640
+ if isinstance(geometry, CURVES):
641
+ return _finite(node.get("stroke-width"))
642
+ return None
643
+
644
+
645
+ def _height(node: ET.Element, default: float | None) -> float | None:
646
+ value = node.get("data-height")
647
+ if value is None:
648
+ return default
649
+ return None if value == "unknown" else _finite(value)
650
+
651
+
652
+ def _room_label_point(node: ET.Element, geometry: Geometry) -> Point:
653
+ marker = node.get("data-label-point")
654
+ if marker is None or not marker.strip():
655
+ point = geometry_shape(geometry).representative_point()
656
+ if point.is_empty:
657
+ raise ValueError("room geometry cannot provide a label point")
658
+ return _finite(str(point.x)), _finite(str(point.y))
659
+ values = marker.split(",")
660
+ if len(values) != 2:
661
+ raise ValueError("data-label-point requires x,y")
662
+ return _finite(values[0]), _finite(values[1])
663
+
664
+
665
+ _SVG_NUMBER = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
666
+ _FURNITURE_TRANSFORM = re.compile(
667
+ rf"^translate\(\s*({_SVG_NUMBER})(?:\s+|\s*,\s*)({_SVG_NUMBER})\s*\)\s+"
668
+ rf"rotate\(\s*({_SVG_NUMBER})\s*\)$"
669
+ )
670
+
671
+
672
+ def _furniture_placement(node: ET.Element) -> FurniturePlacement:
673
+ match = _FURNITURE_TRANSFORM.fullmatch(node.get("transform", ""))
674
+ if match is None:
675
+ raise ValueError("furniture transform must be translate(x y) rotate(angle)")
676
+ center_x, center_y, rotation = (_finite(value) for value in match.groups())
677
+ width, depth = _finite(node.get("data-width")), _finite(node.get("data-depth"))
678
+ if width <= 0 or depth <= 0:
679
+ raise ValueError("furniture data-width and data-depth must be positive")
680
+ rotation %= 360
681
+ return FurniturePlacement((center_x, center_y), width, depth, rotation)
682
+
683
+
684
+ def svg_to_scene(text: str, *, scene_id: str = "plan") -> FloorplanScene:
685
+ """Read native SVG geometry; layer membership supplies repeated entity categories."""
686
+ if len(text.encode()) > 1_000_000:
687
+ raise ValueError("SVG exceeds 1 MB")
688
+ try:
689
+ root = fromstring(text, forbid_dtd=True, forbid_entities=True, forbid_external=True)
690
+ except (ET.ParseError, DefusedXmlException) as exc:
691
+ raise ValueError(f"invalid or unsafe SVG XML: {exc}") from exc
692
+ if (
693
+ root.tag != "{" + NS + "}svg"
694
+ or root.get("data-length-unit") != "mm"
695
+ or root.get("data-area-unit") != "m2"
696
+ or root.get("data-angle-unit") != "deg"
697
+ ):
698
+ raise ValueError("expected SVG units in millimetres, square metres and degrees")
699
+ nodes = list(root.iter())
700
+ if len(nodes) > 10000:
701
+ raise ValueError("SVG contains too many elements")
702
+ for node in nodes:
703
+ tag = _tag(node)
704
+ if node.tag != "{" + NS + "}" + tag or tag not in ATTRIBUTES:
705
+ raise ValueError(f"unsupported SVG element: {node.tag}")
706
+ for key, value in node.attrib.items():
707
+ if key not in ATTRIBUTES[tag] and not key.startswith("data-"):
708
+ raise ValueError(f"unsupported SVG attribute: {key}")
709
+ if key in {"fill", "stroke"} and value != "none" and not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
710
+ raise ValueError("SVG colours must be none or opaque six-digit hex values")
711
+ if key == "stroke-linecap" and value != "butt":
712
+ raise ValueError("lines require stroke-linecap=butt")
713
+ viewbox = [_finite(token) for token in re.split(r"[\s,]+", root.get("viewBox", "").strip())]
714
+ if len(viewbox) != 4 or min(viewbox[2:]) <= 0:
715
+ raise ValueError("viewBox requires x, y and positive width, height")
716
+ ids = [node.attrib["id"] for node in nodes if "id" in node.attrib]
717
+ if len(ids) != len(set(ids)):
718
+ raise ValueError("duplicate SVG IDs")
719
+
720
+ scene_height = _finite(root.get("data-height")) if "data-height" in root.attrib else None
721
+ children = list(root)
722
+ metadata_payload = None
723
+ if children and _tag(children[0]) == "metadata":
724
+ metadata_node = children.pop(0)
725
+ if len(metadata_node) or not (metadata_node.text or "").strip():
726
+ raise ValueError("SVG metadata must contain one JSON object")
727
+ metadata_payload = read_json(metadata_node.text or "")
728
+ if (len(children) != 1 or _tag(children[0]) != "g"
729
+ or {key: value for key, value in children[0].attrib.items() if not key.startswith("data-")}
730
+ != {"transform": "scale(1 -1)"}):
731
+ raise ValueError("SVG requires optional metadata followed by one fixed world transform: scale(1 -1)")
732
+ world = children[0]
733
+ layers: dict[str, ET.Element] = {}
734
+ known_layers = {
735
+ "zones", "rooms", "floorOpenings", "buildings", "furniture", "walls", "separators", "openings", "dimensions",
736
+ }
737
+ for layer in world:
738
+ if _tag(layer) != "g" or layer.get("data-layer") not in known_layers:
739
+ raise ValueError("SVG root may only contain known semantic layers")
740
+ name = cast(str, layer.get("data-layer"))
741
+ if name in layers:
742
+ raise ValueError(f"duplicate SVG layer: {name}")
743
+ layers[name] = layer
744
+ furniture_nodes = {id(node) for node in layers.get("furniture", ())}
745
+ if any("transform" in node.attrib and node is not world and id(node) not in furniture_nodes for node in nodes):
746
+ raise ValueError("transform is allowed only on the world group and furniture entity groups")
747
+
748
+ rooms: list[Room] = []
749
+ walls: list[Wall] = []
750
+ separators: list[Separator] = []
751
+ openings: list[Opening] = []
752
+ floor_openings: list[FloorOpening] = []
753
+ buildings: list[Building] = []
754
+ furniture: list[Furniture] = []
755
+ zones: list[Zone] = []
756
+ dimension_annotations: list[DimensionAnnotation] = []
757
+
758
+ def furniture_geometry(node: ET.Element) -> Geometry:
759
+ roles = [child.get("data-role") for child in node]
760
+ if not any(role is not None for role in roles):
761
+ return _read_geometry(node)
762
+ if roles.count("footprint") != 1 or any(role not in {"footprint", "decoration"} for role in roles):
763
+ raise ValueError("furniture details require one footprint and decoration roles")
764
+ return _read_geometry(node[roles.index("footprint")])
765
+
766
+ def furniture_presentation(node: ET.Element) -> FurniturePresentation | None:
767
+ # Flatten only explicit inherited paint; geometry/placement stay in the semantic fields.
768
+ if len(node) > 1 and not any(child.get("data-role") for child in node):
769
+ # Legacy compound footprints have no per-piece presentation contract.
770
+ return None
771
+ inherited = {key: value for parent in (layers["furniture"], node)
772
+ for key, value in parent.attrib.items() if key in PAINT}
773
+ decorations = []
774
+ footprint_style = {}
775
+ footprint_index = 0
776
+ for index, child in enumerate(node):
777
+ paint = {**inherited, **{key: value for key, value in child.attrib.items() if key in PAINT}}
778
+ if child.get("data-role") == "decoration":
779
+ if len(child):
780
+ raise ValueError("decoration primitives cannot have children")
781
+ decorations.append(SvgDecoration(cast(Any, _tag(child)), {
782
+ **paint, **{key: value for key, value in child.attrib.items() if key != "id" and not key.startswith("data-")},
783
+ }))
784
+ else:
785
+ footprint_style, footprint_index = paint, index
786
+ if not decorations and not footprint_style and not any(child.get("data-role") for child in node):
787
+ return None
788
+ return FurniturePresentation(tuple(decorations), footprint_style, footprint_index)
789
+
790
+ for node in layers.get("rooms", ()):
791
+ if _tag(node) != "g":
792
+ raise ValueError("rooms require entity groups")
793
+ geometry = _read_geometry(node)
794
+ if not isinstance(geometry, (PolygonGeometry, PathGeometry)) or len(geometry.polygons) != 1:
795
+ raise TypeError("room geometry must contain one polygon or curved footprint")
796
+ label_point = _room_label_point(node, geometry)
797
+ area = _finite(node.get("data-area"), bounded=False) if "data-area" in node.attrib else None
798
+ rooms.append(
799
+ Room(
800
+ _entity_id(node),
801
+ node.attrib["data-type"],
802
+ label_point,
803
+ geometry,
804
+ node.get("data-name"),
805
+ area,
806
+ int(node.attrib["data-room-type-id"]) if "data-room-type-id" in node.attrib else None,
807
+ )
808
+ )
809
+
810
+ for node in layers.get("zones", ()):
811
+ geometry = _read_geometry(node)
812
+ if not isinstance(geometry, PolygonGeometry):
813
+ raise TypeError("zones require polygon geometry")
814
+ zones.append(Zone(_entity_id(node), node.get("data-name"), geometry))
815
+
816
+ def read_object(node: ET.Element, *, is_furniture: bool) -> None:
817
+ roof = not is_furniture and node.get("data-type") == "roof"
818
+ ridges: list[LineGeometry] = []
819
+ if roof:
820
+ if (
821
+ _tag(node) != "g" or not len(node) or _tag(node[0]) != "polygon"
822
+ or node[0].get("data-role") not in {None, "footprint"}
823
+ ):
824
+ raise ValueError("roof requires a polygon footprint followed by ridge lines")
825
+ geometry = _read_geometry(node[0])
826
+ for child in list(node)[1:]:
827
+ if _tag(child) != "line" or child.get("data-role") != "ridge":
828
+ raise ValueError("roof ridges require line elements with data-role=ridge")
829
+ ridges.append(cast(LineGeometry, _read_curve(child)))
830
+ else:
831
+ geometry = furniture_geometry(node) if is_furniture else _read_geometry(node)
832
+ if is_furniture:
833
+ if not isinstance(geometry, PolygonGeometry):
834
+ raise TypeError("furniture geometry must be a placement-local polygon")
835
+ furniture.append(Furniture(
836
+ _entity_id(node),
837
+ node.attrib["data-type"],
838
+ _furniture_placement(node),
839
+ geometry,
840
+ _finite(node.get("data-height")) if "data-height" in node.attrib else None,
841
+ _finite(node.get("data-bottom-elevation"))
842
+ if "data-bottom-elevation" in node.attrib else None,
843
+ _thickness(node, geometry),
844
+ node.get("data-name"),
845
+ node.get("data-legend-shape"),
846
+ json.loads(node.attrib["data-up-stair-directions"])
847
+ if "data-up-stair-directions" in node.attrib else None,
848
+ json.loads(node.attrib["data-path"]) if "data-path" in node.attrib else None,
849
+ json.loads(node.attrib["data-attributes"]) if "data-attributes" in node.attrib else None,
850
+ furniture_presentation(node),
851
+ ))
852
+ return
853
+ direction = None
854
+ if "data-direction" in node.attrib:
855
+ values = node.attrib["data-direction"].split(",")
856
+ if len(values) != 2:
857
+ raise ValueError("direction requires x,y")
858
+ direction = (_finite(values[0]), _finite(values[1]))
859
+ bay: dict[str, Any] = {}
860
+ for field, key in BAY_FIELDS.items():
861
+ if "data-bay-" + key in node.attrib:
862
+ value = node.attrib["data-bay-" + key]
863
+ bay[field] = value if field == "platformType" else _finite(value)
864
+ common = (
865
+ _entity_id(node),
866
+ node.attrib["data-type"],
867
+ geometry,
868
+ _height(node, None) if roof else (
869
+ _finite(node.get("data-height")) if "data-height" in node.attrib else None),
870
+ None if roof and node.get("data-bottom-elevation") == "unknown" else (
871
+ _finite(node.get("data-bottom-elevation")) if "data-bottom-elevation" in node.attrib else None),
872
+ _thickness(node, geometry),
873
+ direction,
874
+ node.get("data-name"),
875
+ )
876
+ hatch = None
877
+ if node.get("data-type") == "inspection_hatch" or any(
878
+ key in node.attrib for key in ("data-hatch-surface", "data-hatch-storage")
879
+ ):
880
+ storage = node.get("data-hatch-storage")
881
+ if storage is not None and storage not in {"true", "false"}:
882
+ raise ValueError("data-hatch-storage must be true or false")
883
+ hatch = InspectionHatchParameters(
884
+ node.get("data-hatch-surface", "unknown"), None if storage is None else storage == "true",
885
+ )
886
+ buildings.append(Building(
887
+ *common, BayWindowParameters(**bay) if bay else None, tuple(ridges), inspectionHatch=hatch,
888
+ ))
889
+
890
+ for node in layers.get("floorOpenings", ()):
891
+ geometry = _read_geometry(node)
892
+ if not isinstance(geometry, (PolygonGeometry, PathGeometry, CircleGeometry)):
893
+ raise TypeError("floor opening requires a polygon, closed path or circle")
894
+ floor_openings.append(FloorOpening(
895
+ _entity_id(node), geometry, node.get("data-name"),
896
+ _finite(node.get("data-bottom-elevation")) if "data-bottom-elevation" in node.attrib else None,
897
+ _thickness(node, geometry),
898
+ ))
899
+
900
+ for node in layers.get("buildings", ()):
901
+ read_object(node, is_furniture=False)
902
+ for node in layers.get("furniture", ()):
903
+ read_object(node, is_furniture=True)
904
+
905
+ for node in layers.get("walls", ()):
906
+ geometry = _read_wall_geometry(node)
907
+ bearing = node.get("data-load-bearing")
908
+ if bearing not in {None, "true", "false"}:
909
+ raise ValueError("data-load-bearing must be true or false")
910
+ walls.append(Wall(_entity_id(node), geometry, _thickness(node, geometry),
911
+ bearing == "true", _height(node, scene_height)))
912
+ for node in layers.get("separators", ()):
913
+ geometry = _read_geometry(node)
914
+ if not isinstance(geometry, (LineGeometry, ArcGeometry)):
915
+ raise TypeError("virtual separators require a line or circular arc")
916
+ separators.append(Separator(_entity_id(node), geometry))
917
+
918
+ opening_layer = layers.get("openings")
919
+ if opening_layer is not None:
920
+ for node in opening_layer:
921
+ if _tag(node) != "g":
922
+ raise ValueError("openings require entity groups")
923
+ segment_nodes = tuple(node)
924
+ if not segment_nodes or any(_tag(child) not in {"line", "path"} for child in segment_nodes):
925
+ raise ValueError("opening geometry requires one or more lines or circular arcs")
926
+ segments = tuple(OpeningSegment(
927
+ cast(LineGeometry | ArcGeometry, _read_geometry(child)), child.get("data-wall-id"),
928
+ child.get("data-open-side"),
929
+ _finite(child.get("data-outside")) if "data-outside" in child.attrib else None,
930
+ ) for child in segment_nodes)
931
+ source_type = node.get("data-type")
932
+ openings.append(Opening(
933
+ _entity_id(node), source_type, segments,
934
+ _finite(node.get("data-height")) if "data-height" in node.attrib else None,
935
+ _finite(node.get("data-bottom-elevation")) if "data-bottom-elevation" in node.attrib else None,
936
+ _finite(node.get("data-depth")) if "data-depth" in node.attrib else None,
937
+ ))
938
+
939
+ for node in layers.get("dimensions", ()):
940
+ if _tag(node) != "g" or len(node) != 1 or _tag(node[0]) != "line":
941
+ raise ValueError("dimensions require entity groups with one measurement span")
942
+ if node.get("data-kind") != "linear" or node[0].get("data-role") != "measurement-span":
943
+ raise ValueError("dimensions require kind=linear and role=measurement-span")
944
+ value = _finite(node.get("data-value"), bounded=False)
945
+ if value <= 0 or not value.is_integer():
946
+ raise ValueError("dimension data-value must be a positive integer")
947
+ geometry = _read_geometry(node[0])
948
+ if not isinstance(geometry, LineGeometry):
949
+ raise TypeError("dimension annotation geometry must be a line")
950
+ dimension_annotations.append(DimensionAnnotation(
951
+ _entity_id(node), int(value), geometry, node.get("data-ref-id"),
952
+ ))
953
+
954
+ scene = FloorplanScene(
955
+ scene_id,
956
+ rooms=tuple(rooms),
957
+ walls=tuple(walls),
958
+ separators=tuple(separators),
959
+ openings=tuple(openings),
960
+ floorOpenings=tuple(floor_openings),
961
+ buildings=tuple(buildings),
962
+ furniture=tuple(furniture),
963
+ height=scene_height,
964
+ zones=tuple(zones),
965
+ dimensionAnnotations=tuple(dimension_annotations),
966
+ presentation=ScenePresentation(cast(tuple[float, float, float, float], tuple(viewbox))),
967
+ )
968
+ payload = scene_to_dict(scene)
969
+ attributes = {node.get("id"): read_json(node.attrib["data-attributes"])
970
+ for layer in layers.values() for node in layer if "data-attributes" in node.attrib}
971
+ for collection in ("rooms", "walls", "separators", "openings", "floorOpenings", "buildings",
972
+ "furniture", "zones", "dimensionAnnotations"):
973
+ for entity in payload.get(collection, []):
974
+ if entity["id"] in attributes:
975
+ entity["attributes"] = attributes[entity["id"]]
976
+ if metadata_payload is not None:
977
+ payload["metadata"] = metadata_payload
978
+ return scene_from_dict(payload)