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/__init__.py +20 -0
- floorplan_scene/component-defaults.json +16 -0
- floorplan_scene/component_defaults.py +11 -0
- floorplan_scene/dimensions.py +140 -0
- floorplan_scene/fpi.py +1145 -0
- floorplan_scene/fpi_geometry.py +237 -0
- floorplan_scene/furniture.py +317 -0
- floorplan_scene/geometry.py +119 -0
- floorplan_scene/model.py +1072 -0
- floorplan_scene/presentation.py +146 -0
- floorplan_scene/py.typed +0 -0
- floorplan_scene/render_labels.py +315 -0
- floorplan_scene/render_style.py +126 -0
- floorplan_scene/rich_svg.py +278 -0
- floorplan_scene/shape_geometry.py +185 -0
- floorplan_scene/shapes.py +82 -0
- floorplan_scene/svg.py +978 -0
- floorplan_scene-0.1.0.dist-info/METADATA +26 -0
- floorplan_scene-0.1.0.dist-info/RECORD +21 -0
- floorplan_scene-0.1.0.dist-info/WHEEL +5 -0
- floorplan_scene-0.1.0.dist-info/top_level.txt +1 -0
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)
|