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/model.py
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
"""Plan geometry and physical dimensions, plus optional SVG presentation data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from .presentation import FurniturePresentation, ScenePresentation, SvgDecoration
|
|
14
|
+
from .shapes import (
|
|
15
|
+
GEOMETRY_TOLERANCE_MM,
|
|
16
|
+
ArcGeometry,
|
|
17
|
+
BayWindowGeometry,
|
|
18
|
+
CircleGeometry,
|
|
19
|
+
Curve,
|
|
20
|
+
Footprint,
|
|
21
|
+
Geometry,
|
|
22
|
+
Line,
|
|
23
|
+
LineGeometry,
|
|
24
|
+
PathGeometry,
|
|
25
|
+
PipeGeometry,
|
|
26
|
+
Point,
|
|
27
|
+
PolygonGeometry,
|
|
28
|
+
QuadraticGeometry,
|
|
29
|
+
Ring,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = ["ORIGIN_IMAGE_SCALE_REL_TOLERANCE", "SCHEMA", "SPACE_TYPES", "SPACE_TYPE_PATTERN",
|
|
33
|
+
"BayWindowParameters", "Building",
|
|
34
|
+
"DimensionAnnotation",
|
|
35
|
+
"FloorOpening", "FloorplanScene", "Footprint",
|
|
36
|
+
"Furniture", "FurniturePlacement", "InferenceMetadata", "InspectionHatchParameters", "JsonObject", "LevelMetadata", "Line", "Opening", "OpeningKind",
|
|
37
|
+
"OpeningSegment", "OriginImage", "Point", "Ring", "Room", "SceneEntity", "SceneMetadata",
|
|
38
|
+
"Separator", "Wall", "Zone",
|
|
39
|
+
"geometry_from_dict", "read_json", "scene_from_dict", "scene_to_dict"]
|
|
40
|
+
|
|
41
|
+
OpeningKind = Literal["door", "window", "passage", "niche", "unknown"]
|
|
42
|
+
JsonObject = dict[str, Any]
|
|
43
|
+
DEFAULT_MM_DECIMALS = 3
|
|
44
|
+
ORIGIN_IMAGE_SCALE_REL_TOLERANCE = 1e-3
|
|
45
|
+
SCHEMA = "floorplan-scene"
|
|
46
|
+
SPACE_TYPE_PATTERN = r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*"
|
|
47
|
+
SPACE_TYPES = {
|
|
48
|
+
"living",
|
|
49
|
+
"dining",
|
|
50
|
+
"living_dining",
|
|
51
|
+
"bedroom",
|
|
52
|
+
"kitchen",
|
|
53
|
+
"bathroom",
|
|
54
|
+
"balcony",
|
|
55
|
+
"circulation",
|
|
56
|
+
"study",
|
|
57
|
+
"storage",
|
|
58
|
+
"closet",
|
|
59
|
+
"laundry",
|
|
60
|
+
"office",
|
|
61
|
+
"multipurpose",
|
|
62
|
+
"other",
|
|
63
|
+
"unknown",
|
|
64
|
+
}
|
|
65
|
+
DOOR_OPENING_TYPES = {
|
|
66
|
+
"DOOR", "DOUBLE_DOOR", "SLIDE_DOOR", "UNEQUAL_DOUBLE_DOOR", "SHUTTER_DOOR", "ARCHED_DOOR",
|
|
67
|
+
}
|
|
68
|
+
PASSAGE_OPENING_TYPES = {"PASS", "WALL_HOLE"}
|
|
69
|
+
WINDOW_OPENING_TYPES = {
|
|
70
|
+
"WINDOW", "BAY_WINDOW", "ONE_SIDE_BAY_WINDOW", "TWO_SIDE_BAY_WINDOW", "FRENCH_WINDOW",
|
|
71
|
+
"HANDRAIL_WINDOW", "SLIDE_WINDOW", "SHUTTER_WINDOW", "TOP_HUNG_WINDOW", "ARCHED_WINDOW",
|
|
72
|
+
"ARC_WINDOW", "ROUND_WINDOW", "WINDOW_HOLE", "ALUMINUM_WINDOW",
|
|
73
|
+
}
|
|
74
|
+
OPENING_TYPES = DOOR_OPENING_TYPES | PASSAGE_OPENING_TYPES | WINDOW_OPENING_TYPES | {"NICHE"}
|
|
75
|
+
OPEN_SIDES = {"LEFT", "RIGHT", "LEFT_INVERSE", "RIGHT_INVERSE"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def opening_kind(opening_type: str | None) -> OpeningKind:
|
|
79
|
+
if opening_type in DOOR_OPENING_TYPES:
|
|
80
|
+
return "door"
|
|
81
|
+
if opening_type in WINDOW_OPENING_TYPES:
|
|
82
|
+
return "window"
|
|
83
|
+
if opening_type in PASSAGE_OPENING_TYPES:
|
|
84
|
+
return "passage"
|
|
85
|
+
if opening_type == "NICHE":
|
|
86
|
+
return "niche"
|
|
87
|
+
return "unknown"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Room:
|
|
92
|
+
id: str
|
|
93
|
+
type: str
|
|
94
|
+
labelPoint: Point
|
|
95
|
+
geometry: PolygonGeometry | PathGeometry
|
|
96
|
+
name: str | None = None
|
|
97
|
+
area: float | None = None
|
|
98
|
+
roomTypeId: int | None = None
|
|
99
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
100
|
+
attributes: JsonObject | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class Wall:
|
|
105
|
+
id: str
|
|
106
|
+
geometry: Geometry
|
|
107
|
+
thickness: float | None = None
|
|
108
|
+
loadBearing: bool = False
|
|
109
|
+
height: float | None = None
|
|
110
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
111
|
+
attributes: JsonObject | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Separator:
|
|
116
|
+
id: str
|
|
117
|
+
geometry: LineGeometry | ArcGeometry
|
|
118
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
119
|
+
attributes: JsonObject | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class OpeningSegment:
|
|
124
|
+
geometry: LineGeometry | ArcGeometry
|
|
125
|
+
wallId: str | None
|
|
126
|
+
openSide: str | None = None
|
|
127
|
+
outside: float | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Opening:
|
|
132
|
+
id: str
|
|
133
|
+
type: str | None
|
|
134
|
+
segments: tuple[OpeningSegment, ...]
|
|
135
|
+
height: float | None = None
|
|
136
|
+
bottomElevation: float | None = None
|
|
137
|
+
depth: float | None = None
|
|
138
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
139
|
+
attributes: JsonObject | None = None
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def kind(self) -> OpeningKind:
|
|
143
|
+
return opening_kind(self.type)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class FloorOpening:
|
|
148
|
+
"""A through opening in the floor beneath this Scene's level."""
|
|
149
|
+
|
|
150
|
+
id: str
|
|
151
|
+
geometry: PolygonGeometry | PathGeometry | CircleGeometry
|
|
152
|
+
name: str | None = None
|
|
153
|
+
bottomElevation: float | None = None
|
|
154
|
+
thickness: float | None = None
|
|
155
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
156
|
+
attributes: JsonObject | None = None
|
|
157
|
+
|
|
158
|
+
def __post_init__(self) -> None:
|
|
159
|
+
from .shape_geometry import geometry_shape
|
|
160
|
+
|
|
161
|
+
_text(self.id)
|
|
162
|
+
_label(self.name)
|
|
163
|
+
_dimension({"bottomElevation": self.bottomElevation}, "bottomElevation", signed=True)
|
|
164
|
+
_dimension({"thickness": self.thickness}, "thickness", positive=True)
|
|
165
|
+
if not isinstance(self.geometry, (PolygonGeometry, PathGeometry, CircleGeometry)):
|
|
166
|
+
raise TypeError("floor opening requires a polygon, closed path or circle")
|
|
167
|
+
if isinstance(self.geometry, CircleGeometry):
|
|
168
|
+
_point(list(self.geometry.center))
|
|
169
|
+
if _number(self.geometry.radius) <= 0:
|
|
170
|
+
raise ValueError("floor opening radius must be positive")
|
|
171
|
+
elif len(self.geometry.polygons) != 1 or not self.geometry.polygons[0]:
|
|
172
|
+
raise ValueError("floor opening requires one connected footprint")
|
|
173
|
+
shape = geometry_shape(self.geometry)
|
|
174
|
+
if shape.is_empty or not shape.is_valid or not math.isfinite(shape.area) or shape.area <= 0:
|
|
175
|
+
raise ValueError("floor opening requires a valid non-degenerate footprint")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class BayWindowParameters:
|
|
180
|
+
beamThickness: float | None = None
|
|
181
|
+
upperFloorThickness: float | None = None
|
|
182
|
+
lowerFloorThickness: float | None = None
|
|
183
|
+
wallThickness: float | None = None
|
|
184
|
+
offset: float | None = None
|
|
185
|
+
windowHeight: float | None = None
|
|
186
|
+
platformType: str | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class InspectionHatchParameters:
|
|
191
|
+
"""检修盖板的安装面与储物标注;不表示永久开洞或独立收纳家具。"""
|
|
192
|
+
|
|
193
|
+
# 未知安装面明确为 unknown;不能由平面投影猜测标高或楼板贯穿关系。
|
|
194
|
+
surface: str = "unknown"
|
|
195
|
+
# 仅在原图明确说明时填写;false 表示“収納庫なし”,None 表示未说明。
|
|
196
|
+
hasStorage: bool | None = None
|
|
197
|
+
|
|
198
|
+
def __post_init__(self) -> None:
|
|
199
|
+
_choice(self.surface, {"floor", "ceiling", "wall", "unknown"})
|
|
200
|
+
if self.hasStorage is not None and not isinstance(self.hasStorage, bool):
|
|
201
|
+
raise TypeError("inspection hatch hasStorage must be boolean or null")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class Building:
|
|
206
|
+
"""A physical building object; pillar shape belongs to geometry, not type."""
|
|
207
|
+
|
|
208
|
+
id: str
|
|
209
|
+
type: str
|
|
210
|
+
geometry: Geometry
|
|
211
|
+
height: float | None = None
|
|
212
|
+
bottomElevation: float | None = None
|
|
213
|
+
thickness: float | None = None
|
|
214
|
+
direction: Point | None = None
|
|
215
|
+
name: str | None = None
|
|
216
|
+
bayWindow: BayWindowParameters | None = None
|
|
217
|
+
ridgeLines: tuple[LineGeometry, ...] = ()
|
|
218
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
219
|
+
attributes: JsonObject | None = None
|
|
220
|
+
# 仅 inspection_hatch 使用。Scene/SVG 保留,FPI/Legend 不支持时显式省略并告警。
|
|
221
|
+
inspectionHatch: InspectionHatchParameters | None = None
|
|
222
|
+
|
|
223
|
+
def __post_init__(self) -> None:
|
|
224
|
+
if self.type == "inspection_hatch":
|
|
225
|
+
if not isinstance(self.geometry, (PolygonGeometry, PathGeometry, CircleGeometry)):
|
|
226
|
+
raise TypeError("inspection hatch requires a closed panel projection")
|
|
227
|
+
if self.inspectionHatch is None:
|
|
228
|
+
object.__setattr__(self, "inspectionHatch", InspectionHatchParameters())
|
|
229
|
+
elif self.inspectionHatch is not None:
|
|
230
|
+
raise ValueError("inspectionHatch parameters belong only to inspection_hatch")
|
|
231
|
+
if self.type == "roof":
|
|
232
|
+
if not isinstance(self.geometry, PolygonGeometry) or len(self.geometry.polygons) != 1 or len(self.geometry.polygons[0]) != 1:
|
|
233
|
+
raise ValueError("roof requires one polygon projection without holes")
|
|
234
|
+
if self.direction is not None or self.bayWindow is not None:
|
|
235
|
+
raise ValueError("roof has no direction or bayWindow parameters")
|
|
236
|
+
elif self.ridgeLines:
|
|
237
|
+
raise ValueError("ridgeLines belong only to roof")
|
|
238
|
+
for ridge in self.ridgeLines:
|
|
239
|
+
if not isinstance(ridge, LineGeometry):
|
|
240
|
+
raise TypeError("roof ridgeLines require straight line segments")
|
|
241
|
+
_line([*ridge.start, *ridge.end])
|
|
242
|
+
# Normalize the previously emitted type at the shared Python/JSON/SVG entry point.
|
|
243
|
+
if self.type == "circle_pillar":
|
|
244
|
+
object.__setattr__(self, "type", "pillar")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class FurniturePlacement:
|
|
249
|
+
center: Point
|
|
250
|
+
width: float
|
|
251
|
+
depth: float
|
|
252
|
+
rotation: float
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass(frozen=True)
|
|
256
|
+
class Furniture:
|
|
257
|
+
id: str
|
|
258
|
+
type: str
|
|
259
|
+
placement: FurniturePlacement
|
|
260
|
+
geometry: PolygonGeometry
|
|
261
|
+
height: float | None = None
|
|
262
|
+
bottomElevation: float | None = None
|
|
263
|
+
thickness: float | None = None
|
|
264
|
+
name: str | None = None
|
|
265
|
+
shape: str | None = None
|
|
266
|
+
upStairDirections: tuple[JsonObject, ...] | None = None
|
|
267
|
+
path: tuple[Point, ...] | None = None
|
|
268
|
+
attributes: JsonObject | None = None
|
|
269
|
+
# SVG 专用局部外观;缺省不生成,FPI/Legend 导出忽略,不能代替 geometry。
|
|
270
|
+
presentation: FurniturePresentation | None = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class Zone:
|
|
275
|
+
id: str
|
|
276
|
+
name: str | None
|
|
277
|
+
geometry: PolygonGeometry
|
|
278
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
279
|
+
attributes: JsonObject | None = None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class DimensionAnnotation:
|
|
284
|
+
id: str
|
|
285
|
+
value: int
|
|
286
|
+
geometry: LineGeometry
|
|
287
|
+
refId: str | None = None
|
|
288
|
+
kind: Literal["linear"] = "linear"
|
|
289
|
+
# 模型额外观察与依据;非权威几何,保留于 Scene/SVG,不进入 FPI。
|
|
290
|
+
attributes: JsonObject | None = None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True)
|
|
294
|
+
class OriginImage:
|
|
295
|
+
url: str
|
|
296
|
+
pixelWidth: int
|
|
297
|
+
pixelHeight: int
|
|
298
|
+
sceneBounds: tuple[float, float, float, float]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class InferenceMetadata:
|
|
303
|
+
model: str
|
|
304
|
+
reasoningEffort: str
|
|
305
|
+
sessionId: str
|
|
306
|
+
skillVersion: str
|
|
307
|
+
sourceAsset: str
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(frozen=True)
|
|
311
|
+
class LevelMetadata:
|
|
312
|
+
name: str
|
|
313
|
+
index: int | None = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass(frozen=True)
|
|
317
|
+
class SceneMetadata:
|
|
318
|
+
originImage: OriginImage | None = None
|
|
319
|
+
inference: InferenceMetadata | None = None
|
|
320
|
+
level: LevelMetadata | None = None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass(frozen=True)
|
|
324
|
+
class FloorplanScene:
|
|
325
|
+
id: str
|
|
326
|
+
rooms: tuple[Room, ...] = ()
|
|
327
|
+
walls: tuple[Wall, ...] = ()
|
|
328
|
+
separators: tuple[Separator, ...] = ()
|
|
329
|
+
openings: tuple[Opening, ...] = ()
|
|
330
|
+
buildings: tuple[Building, ...] = ()
|
|
331
|
+
furniture: tuple[Furniture, ...] = ()
|
|
332
|
+
unit: str = "mm"
|
|
333
|
+
height: float | None = None
|
|
334
|
+
zones: tuple[Zone, ...] = ()
|
|
335
|
+
dimensionAnnotations: tuple[DimensionAnnotation, ...] = ()
|
|
336
|
+
metadata: SceneMetadata | None = None
|
|
337
|
+
floorOpenings: tuple[FloorOpening, ...] = ()
|
|
338
|
+
# SVG 文档画布;不等于原图定位或交互相机,参见 ScenePresentation。
|
|
339
|
+
presentation: ScenePresentation | None = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
SceneEntity = Room | Wall | Separator | Opening | FloorOpening | Building | Furniture | Zone | DimensionAnnotation
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _object(value: Any, required: str, optional: str = "") -> JsonObject:
|
|
346
|
+
if not isinstance(value, dict):
|
|
347
|
+
raise TypeError("expected an object")
|
|
348
|
+
missing = set(required.split()) - value.keys()
|
|
349
|
+
extra = value.keys() - set((required + " " + optional).split())
|
|
350
|
+
if missing or extra:
|
|
351
|
+
raise ValueError(f"missing fields {sorted(missing)}; unexpected fields {sorted(extra)}")
|
|
352
|
+
return value
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _json_attributes(value: Any) -> JsonObject | None:
|
|
356
|
+
if value is None:
|
|
357
|
+
return None
|
|
358
|
+
if not isinstance(value, dict):
|
|
359
|
+
raise TypeError("entity attributes must be an object")
|
|
360
|
+
# JSON only: preserve extra model semantics without admitting non-finite values.
|
|
361
|
+
json.dumps(value, allow_nan=False)
|
|
362
|
+
return deepcopy(value)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _stair_directions(value: Any) -> tuple[JsonObject, ...] | None:
|
|
366
|
+
if value is None:
|
|
367
|
+
return None
|
|
368
|
+
directions = []
|
|
369
|
+
for entry in _array(value):
|
|
370
|
+
row = _object(entry, "rotationAngle", "ascendSide")
|
|
371
|
+
directions.append({"rotationAngle": _number(row["rotationAngle"]),
|
|
372
|
+
**({"ascendSide": _label(row["ascendSide"])} if "ascendSide" in row else {})})
|
|
373
|
+
return tuple(directions)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _array(value: Any) -> list[Any]:
|
|
377
|
+
if not isinstance(value, list):
|
|
378
|
+
raise TypeError("expected an array")
|
|
379
|
+
return value
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _text(value: Any) -> str:
|
|
383
|
+
if not isinstance(value, str) or not value.strip():
|
|
384
|
+
raise ValueError("expected a non-empty string")
|
|
385
|
+
return value
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _label(value: Any) -> str | None:
|
|
389
|
+
if value is not None and not isinstance(value, str):
|
|
390
|
+
raise TypeError("label must be a string or null")
|
|
391
|
+
return value
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _choice(value: Any, options: set[str]) -> str:
|
|
395
|
+
text = _text(value)
|
|
396
|
+
if text not in options:
|
|
397
|
+
raise ValueError(f"expected one of {sorted(options)}, got {text!r}")
|
|
398
|
+
return text
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _space_type(value: Any) -> str:
|
|
402
|
+
text = _text(value)
|
|
403
|
+
if re.fullmatch(SPACE_TYPE_PATTERN, text) is None:
|
|
404
|
+
raise ValueError("room type must use lowercase snake case")
|
|
405
|
+
return text
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _number(value: Any) -> float:
|
|
409
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
410
|
+
raise ValueError("expected a finite number")
|
|
411
|
+
return float(value)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _integer(value: Any) -> int:
|
|
415
|
+
number = _number(value)
|
|
416
|
+
if not number.is_integer():
|
|
417
|
+
raise ValueError("expected an integer")
|
|
418
|
+
return int(number)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _boolean(value: Any) -> bool:
|
|
422
|
+
if not isinstance(value, bool):
|
|
423
|
+
raise TypeError("expected a boolean")
|
|
424
|
+
return value
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _origin_url(value: Any) -> str:
|
|
428
|
+
text = _text(value)
|
|
429
|
+
url = urlsplit(text)
|
|
430
|
+
if url.scheme not in {"http", "https"} or not url.netloc or url.username or url.password:
|
|
431
|
+
raise ValueError("origin image URL must use HTTP(S) without embedded credentials")
|
|
432
|
+
return text
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _point(value: Any) -> Point:
|
|
436
|
+
points = _array(value)
|
|
437
|
+
if len(points) != 2:
|
|
438
|
+
raise ValueError("point must have exactly two coordinates")
|
|
439
|
+
return _number(points[0]), _number(points[1])
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _line(value: Any) -> Line:
|
|
443
|
+
values = _array(value)
|
|
444
|
+
if len(values) != 4:
|
|
445
|
+
raise ValueError("line must contain x1, y1, x2 and y2")
|
|
446
|
+
start, end = (_number(values[0]), _number(values[1])), (_number(values[2]), _number(values[3]))
|
|
447
|
+
if start == end:
|
|
448
|
+
raise ValueError("zero-length segment")
|
|
449
|
+
return start, end
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _ring(value: Any) -> Ring:
|
|
453
|
+
points = tuple(_point(point) for point in _array(value))
|
|
454
|
+
if len(points) < 3 or points[0] == points[-1]:
|
|
455
|
+
raise ValueError("polygon rings use at least three points and close implicitly")
|
|
456
|
+
return (*points, points[0])
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _footprint(value: Any) -> Footprint:
|
|
460
|
+
polygons = tuple(tuple(_ring(ring) for ring in _array(polygon)) for polygon in _array(value))
|
|
461
|
+
if not polygons or any(not poly for poly in polygons):
|
|
462
|
+
raise ValueError("footprint must contain polygons with exterior rings")
|
|
463
|
+
return polygons
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _dimension(row: JsonObject, key: str, *, positive: bool = False, signed: bool = False) -> float | None:
|
|
467
|
+
if row.get(key) is None:
|
|
468
|
+
return None
|
|
469
|
+
value = _number(row[key])
|
|
470
|
+
if (value < 0 and not signed) or (positive and value == 0):
|
|
471
|
+
raise ValueError(f"{key} must be {'positive' if positive else 'non-negative'}")
|
|
472
|
+
return value
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def geometry_from_dict(value: Any) -> Geometry:
|
|
476
|
+
if not isinstance(value, dict):
|
|
477
|
+
raise TypeError("shape must be an object")
|
|
478
|
+
if len(value) != 1:
|
|
479
|
+
raise ValueError("shape must contain exactly one geometry key")
|
|
480
|
+
kind, raw = next(iter(value.items()))
|
|
481
|
+
if kind == "line":
|
|
482
|
+
start, end = _line(raw)
|
|
483
|
+
return LineGeometry(start, end)
|
|
484
|
+
if kind in {"arc", "quadratic"}:
|
|
485
|
+
extra = "bulge" if kind == "arc" else "control"
|
|
486
|
+
row = _object(raw, f"start end {extra}")
|
|
487
|
+
start, end = _point(row["start"]), _point(row["end"])
|
|
488
|
+
if start == end:
|
|
489
|
+
raise ValueError("zero-length segment")
|
|
490
|
+
if kind == "arc":
|
|
491
|
+
return ArcGeometry(start, end, _number(row["bulge"]))
|
|
492
|
+
return QuadraticGeometry(start, _point(row["control"]), end)
|
|
493
|
+
if kind == "polygon":
|
|
494
|
+
return PolygonGeometry(((_ring(raw),),))
|
|
495
|
+
if kind == "polygons":
|
|
496
|
+
return PolygonGeometry(_footprint(raw))
|
|
497
|
+
if kind == "path":
|
|
498
|
+
polygons = []
|
|
499
|
+
for raw_polygon in _array(raw):
|
|
500
|
+
rings = []
|
|
501
|
+
for raw_ring in _array(raw_polygon):
|
|
502
|
+
edges = [geometry_from_dict(edge) for edge in _array(raw_ring)]
|
|
503
|
+
if not edges or any(not isinstance(edge, (LineGeometry, ArcGeometry, QuadraticGeometry))
|
|
504
|
+
for edge in edges):
|
|
505
|
+
raise ValueError("path rings must contain line, arc or quadratic edges")
|
|
506
|
+
curves = cast(list[Curve], edges)
|
|
507
|
+
if any(math.dist(edge.end, curves[(i + 1) % len(curves)].start) > GEOMETRY_TOLERANCE_MM
|
|
508
|
+
for i, edge in enumerate(curves)):
|
|
509
|
+
raise ValueError("path edges must form connected closed rings")
|
|
510
|
+
rings.append(tuple(curves))
|
|
511
|
+
if not rings:
|
|
512
|
+
raise ValueError("path polygon must contain an exterior ring")
|
|
513
|
+
polygons.append(tuple(rings))
|
|
514
|
+
if not polygons:
|
|
515
|
+
raise ValueError("path must contain polygons")
|
|
516
|
+
return PathGeometry(tuple(polygons))
|
|
517
|
+
if kind == "circle":
|
|
518
|
+
row = _object(raw, "center radius")
|
|
519
|
+
radius = _dimension(row, "radius", positive=True)
|
|
520
|
+
if radius is None:
|
|
521
|
+
raise ValueError("circle radius is required")
|
|
522
|
+
return CircleGeometry(_point(row["center"]), radius)
|
|
523
|
+
if kind == "pipe":
|
|
524
|
+
row = _object(raw, "center width depth angle profile wallThickness")
|
|
525
|
+
profile = cast(Literal["L", "U", "RECT"], _choice(row["profile"], {"L", "U", "RECT"}))
|
|
526
|
+
widths = tuple(_number(v) for v in _array(row["wallThickness"]))
|
|
527
|
+
if len(widths) != {"L": 2, "U": 3, "RECT": 4}[profile] or any(v <= 0 for v in widths):
|
|
528
|
+
raise ValueError("pipe wallThickness must contain positive widths for its profile")
|
|
529
|
+
width, depth = _dimension(row, "width", positive=True), _dimension(row, "depth", positive=True)
|
|
530
|
+
if width is None or depth is None:
|
|
531
|
+
raise ValueError("pipe width and depth are required")
|
|
532
|
+
return PipeGeometry(_point(row["center"]), width, depth, _number(row["angle"]), profile, widths)
|
|
533
|
+
if kind == "bayWindow":
|
|
534
|
+
row = _object(raw, "outline wallPath profile")
|
|
535
|
+
outline = _ring(row["outline"])
|
|
536
|
+
wall_path = tuple(_point(p) for p in _array(row["wallPath"]))
|
|
537
|
+
if len(wall_path) < 2:
|
|
538
|
+
raise ValueError("bay window wallPath requires at least two points")
|
|
539
|
+
bay_profile = _choice(row["profile"], {"SHAPE_I", "SHAPE_L", "SHAPE_U", "SHAPE_AUTO"})
|
|
540
|
+
return BayWindowGeometry(outline, wall_path, bay_profile)
|
|
541
|
+
raise ValueError(f"unsupported geometry type: {kind!r}")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _furniture_presentation(value: Any) -> FurniturePresentation | None:
|
|
545
|
+
if value is None:
|
|
546
|
+
return None
|
|
547
|
+
row = _object(value, "", "decorations footprintStyle footprintIndex")
|
|
548
|
+
decorations = []
|
|
549
|
+
for item in _array(row.get("decorations", [])):
|
|
550
|
+
primitive = _object(item, "tag attributes")
|
|
551
|
+
decorations.append(SvgDecoration(primitive["tag"], deepcopy(primitive["attributes"])))
|
|
552
|
+
return FurniturePresentation(tuple(decorations), deepcopy(row.get("footprintStyle", {})),
|
|
553
|
+
row.get("footprintIndex", 0))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def scene_from_dict(payload: Any) -> FloorplanScene:
|
|
557
|
+
"""Reject malformed output, unknown fields and dangling references; do not repair it."""
|
|
558
|
+
data = _object(
|
|
559
|
+
payload,
|
|
560
|
+
"id units rooms",
|
|
561
|
+
"height walls separators openings floorOpenings buildings furniture zones dimensionAnnotations metadata presentation",
|
|
562
|
+
)
|
|
563
|
+
units = _object(data["units"], "length area angle")
|
|
564
|
+
if units != {"length": "mm", "area": "m2", "angle": "deg"}:
|
|
565
|
+
raise ValueError("units must use millimetres, square metres and degrees")
|
|
566
|
+
scene_height = _dimension(data, "height")
|
|
567
|
+
metadata = None
|
|
568
|
+
if data.get("metadata") is not None:
|
|
569
|
+
metadata_row = _object(data["metadata"], "", "originImage inference level")
|
|
570
|
+
origin = None
|
|
571
|
+
if "originImage" in metadata_row:
|
|
572
|
+
origin_row = _object(metadata_row["originImage"], "url pixelWidth pixelHeight sceneBounds")
|
|
573
|
+
pixel_width, pixel_height = _integer(origin_row["pixelWidth"]), _integer(origin_row["pixelHeight"])
|
|
574
|
+
bounds_values = _array(origin_row["sceneBounds"])
|
|
575
|
+
if pixel_width <= 0 or pixel_height <= 0:
|
|
576
|
+
raise ValueError("origin image pixel dimensions must be positive")
|
|
577
|
+
if len(bounds_values) != 4:
|
|
578
|
+
raise ValueError("origin image sceneBounds must contain x, y, width and height")
|
|
579
|
+
bounds = tuple(_number(value) for value in bounds_values)
|
|
580
|
+
if bounds[2] <= 0 or bounds[3] <= 0:
|
|
581
|
+
raise ValueError("origin image Scene width and height must be positive")
|
|
582
|
+
if not math.isclose(
|
|
583
|
+
bounds[2] / pixel_width,
|
|
584
|
+
bounds[3] / pixel_height,
|
|
585
|
+
rel_tol=ORIGIN_IMAGE_SCALE_REL_TOLERANCE,
|
|
586
|
+
):
|
|
587
|
+
raise ValueError("origin image must use one pixel-to-millimetre scale")
|
|
588
|
+
origin = OriginImage(_origin_url(origin_row["url"]), pixel_width, pixel_height,
|
|
589
|
+
cast(tuple[float, float, float, float], bounds))
|
|
590
|
+
inference = None
|
|
591
|
+
if "inference" in metadata_row:
|
|
592
|
+
inference_row = _object(metadata_row["inference"],
|
|
593
|
+
"model reasoningEffort sessionId skillVersion sourceAsset")
|
|
594
|
+
inference = InferenceMetadata(*(_text(inference_row[key]) for key in
|
|
595
|
+
("model", "reasoningEffort", "sessionId", "skillVersion", "sourceAsset")))
|
|
596
|
+
level = None
|
|
597
|
+
if "level" in metadata_row:
|
|
598
|
+
level_row = _object(metadata_row["level"], "name", "index")
|
|
599
|
+
level = LevelMetadata(_text(level_row["name"]),
|
|
600
|
+
_integer(level_row["index"]) if "index" in level_row else None)
|
|
601
|
+
metadata = SceneMetadata(origin, inference, level)
|
|
602
|
+
entity_attributes = {}
|
|
603
|
+
data = deepcopy(data)
|
|
604
|
+
for collection in ("rooms", "walls", "separators", "openings", "floorOpenings", "buildings",
|
|
605
|
+
"furniture", "zones", "dimensionAnnotations"):
|
|
606
|
+
for entity in _array(data.get(collection, [])):
|
|
607
|
+
if isinstance(entity, dict) and "attributes" in entity:
|
|
608
|
+
entity_attributes[entity.get("id")] = _json_attributes(entity.pop("attributes"))
|
|
609
|
+
rooms = []
|
|
610
|
+
for value in _array(data["rooms"]):
|
|
611
|
+
row = _object(value, "id type labelPoint geometry", "name area roomTypeId")
|
|
612
|
+
geometry = geometry_from_dict(row["geometry"])
|
|
613
|
+
if not isinstance(geometry, (PolygonGeometry, PathGeometry)) or len(geometry.polygons) != 1:
|
|
614
|
+
raise TypeError("room geometry must contain one polygon or curved footprint")
|
|
615
|
+
rooms.append(
|
|
616
|
+
Room(
|
|
617
|
+
_text(row["id"]),
|
|
618
|
+
_space_type(row["type"]),
|
|
619
|
+
_point(row["labelPoint"]),
|
|
620
|
+
geometry,
|
|
621
|
+
_label(row.get("name")),
|
|
622
|
+
_dimension(row, "area", positive=True),
|
|
623
|
+
_integer(row["roomTypeId"]) if "roomTypeId" in row else None,
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
walls = []
|
|
627
|
+
for value in _array(data.get("walls", [])):
|
|
628
|
+
row = _object(value, "id geometry", "thickness loadBearing height")
|
|
629
|
+
shape = geometry_from_dict(row["geometry"])
|
|
630
|
+
if isinstance(shape, (PipeGeometry, BayWindowGeometry)):
|
|
631
|
+
raise TypeError("pipe and bay window shapes belong to building obstacles")
|
|
632
|
+
walls.append(
|
|
633
|
+
Wall(
|
|
634
|
+
_text(row["id"]),
|
|
635
|
+
shape,
|
|
636
|
+
_dimension(row, "thickness", positive=True),
|
|
637
|
+
_boolean(row.get("loadBearing", False)),
|
|
638
|
+
_dimension(row, "height") if "height" in row else scene_height,
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
separators = []
|
|
642
|
+
for value in _array(data.get("separators", [])):
|
|
643
|
+
row = _object(value, "id geometry")
|
|
644
|
+
geometry = geometry_from_dict(row["geometry"])
|
|
645
|
+
if not isinstance(geometry, (LineGeometry, ArcGeometry)):
|
|
646
|
+
raise TypeError("separator geometry must be a line or circular arc")
|
|
647
|
+
separators.append(Separator(_text(row["id"]), geometry))
|
|
648
|
+
openings = []
|
|
649
|
+
for value in _array(data.get("openings", [])):
|
|
650
|
+
row = _object(value, "id type segments", "height bottomElevation depth")
|
|
651
|
+
source_type = row["type"]
|
|
652
|
+
if source_type is not None:
|
|
653
|
+
source_type = _choice(source_type, OPENING_TYPES)
|
|
654
|
+
segments = []
|
|
655
|
+
for value in _array(row["segments"]):
|
|
656
|
+
segment = _object(value, "geometry wallId", "openSide outside")
|
|
657
|
+
geometry = geometry_from_dict(segment["geometry"])
|
|
658
|
+
if not isinstance(geometry, (LineGeometry, ArcGeometry)):
|
|
659
|
+
raise TypeError("opening segment geometry must be a line or circular arc")
|
|
660
|
+
host = segment["wallId"]
|
|
661
|
+
open_side = segment.get("openSide")
|
|
662
|
+
segments.append(
|
|
663
|
+
OpeningSegment(
|
|
664
|
+
geometry,
|
|
665
|
+
None if host is None else _text(host),
|
|
666
|
+
_choice(open_side, OPEN_SIDES) if open_side is not None else None,
|
|
667
|
+
_number(segment["outside"]) if segment.get("outside") is not None else None,
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
if not 1 <= len(segments) <= 3:
|
|
671
|
+
raise ValueError("opening requires one to three segments")
|
|
672
|
+
if source_type == "NICHE":
|
|
673
|
+
if (
|
|
674
|
+
len(segments) != 1
|
|
675
|
+
or segments[0].openSide is not None
|
|
676
|
+
or segments[0].outside is not None
|
|
677
|
+
):
|
|
678
|
+
raise ValueError("niche requires one segment without openSide/outside")
|
|
679
|
+
elif len(segments) == 1:
|
|
680
|
+
if source_type is None:
|
|
681
|
+
raise ValueError("ordinary opening requires type")
|
|
682
|
+
if segments[0].outside is not None:
|
|
683
|
+
raise ValueError("ordinary single-segment opening has no outside field")
|
|
684
|
+
elif row.get("depth") is not None:
|
|
685
|
+
raise ValueError("composite opening has no depth field")
|
|
686
|
+
openings.append(
|
|
687
|
+
Opening(
|
|
688
|
+
_text(row["id"]),
|
|
689
|
+
source_type,
|
|
690
|
+
tuple(segments),
|
|
691
|
+
_dimension(row, "height"),
|
|
692
|
+
_dimension(row, "bottomElevation", signed=True),
|
|
693
|
+
_dimension(row, "depth"),
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
floor_openings = []
|
|
697
|
+
for value in _array(data.get("floorOpenings", [])):
|
|
698
|
+
row = _object(value, "id geometry", "name bottomElevation thickness")
|
|
699
|
+
geometry = geometry_from_dict(row["geometry"])
|
|
700
|
+
if not isinstance(geometry, (PolygonGeometry, PathGeometry, CircleGeometry)):
|
|
701
|
+
raise TypeError("floor opening requires a polygon, closed path or circle")
|
|
702
|
+
floor_openings.append(FloorOpening(
|
|
703
|
+
_text(row["id"]), geometry, _label(row.get("name")),
|
|
704
|
+
_dimension(row, "bottomElevation", signed=True), _dimension(row, "thickness", positive=True),
|
|
705
|
+
))
|
|
706
|
+
buildings = []
|
|
707
|
+
for value in _array(data.get("buildings", [])):
|
|
708
|
+
row = _object(
|
|
709
|
+
value,
|
|
710
|
+
"id type geometry",
|
|
711
|
+
"height bottomElevation thickness direction name bayWindow ridgeLines inspectionHatch",
|
|
712
|
+
)
|
|
713
|
+
hatch = None
|
|
714
|
+
if row.get("inspectionHatch") is not None:
|
|
715
|
+
hatch = InspectionHatchParameters(**_object(row["inspectionHatch"], "", "surface hasStorage"))
|
|
716
|
+
bay = None
|
|
717
|
+
if row.get("bayWindow") is not None:
|
|
718
|
+
params = _object(row["bayWindow"], "", "beamThickness upperFloorThickness lowerFloorThickness "
|
|
719
|
+
"wallThickness offset windowHeight platformType")
|
|
720
|
+
platform = params.get("platformType")
|
|
721
|
+
bay = BayWindowParameters(
|
|
722
|
+
_dimension(params, "beamThickness"), _dimension(params, "upperFloorThickness"),
|
|
723
|
+
_dimension(params, "lowerFloorThickness"), _dimension(params, "wallThickness"),
|
|
724
|
+
_dimension(params, "offset", signed=True), _dimension(params, "windowHeight"),
|
|
725
|
+
None if platform is None else _choice(platform, {"BOTTOM_ONLY", "TOP_ONLY", "ALL"}),
|
|
726
|
+
)
|
|
727
|
+
buildings.append(
|
|
728
|
+
Building(
|
|
729
|
+
_text(row["id"]),
|
|
730
|
+
_text(row["type"]),
|
|
731
|
+
geometry_from_dict(row["geometry"]),
|
|
732
|
+
_dimension(row, "height"),
|
|
733
|
+
_dimension(row, "bottomElevation", signed=True),
|
|
734
|
+
_dimension(row, "thickness", positive=True),
|
|
735
|
+
_point(row["direction"]) if row.get("direction") is not None else None,
|
|
736
|
+
_label(row.get("name")),
|
|
737
|
+
bay,
|
|
738
|
+
tuple(LineGeometry(*_line(line)) for line in _array(row.get("ridgeLines", []))),
|
|
739
|
+
inspectionHatch=hatch,
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
furniture = []
|
|
743
|
+
for value in _array(data.get("furniture", [])):
|
|
744
|
+
row = _object(
|
|
745
|
+
value,
|
|
746
|
+
"id type placement geometry",
|
|
747
|
+
"height bottomElevation thickness name shape upStairDirections path attributes presentation",
|
|
748
|
+
)
|
|
749
|
+
placement_row = _object(row["placement"], "center width depth rotation")
|
|
750
|
+
width = _dimension(placement_row, "width", positive=True)
|
|
751
|
+
depth = _dimension(placement_row, "depth", positive=True)
|
|
752
|
+
rotation = _number(placement_row["rotation"])
|
|
753
|
+
if width is None or depth is None:
|
|
754
|
+
raise ValueError("furniture placement width and depth are required")
|
|
755
|
+
if not 0 <= rotation < 360:
|
|
756
|
+
raise ValueError("furniture placement rotation must be in [0, 360) degrees")
|
|
757
|
+
geometry = geometry_from_dict(row["geometry"])
|
|
758
|
+
if not isinstance(geometry, PolygonGeometry):
|
|
759
|
+
raise TypeError("furniture geometry must be a polygon in placement-local coordinates")
|
|
760
|
+
furniture.append(
|
|
761
|
+
Furniture(
|
|
762
|
+
_text(row["id"]),
|
|
763
|
+
_text(row["type"]),
|
|
764
|
+
FurniturePlacement(_point(placement_row["center"]), width, depth, rotation),
|
|
765
|
+
geometry,
|
|
766
|
+
_dimension(row, "height"),
|
|
767
|
+
_dimension(row, "bottomElevation", signed=True),
|
|
768
|
+
_dimension(row, "thickness", positive=True),
|
|
769
|
+
_label(row.get("name")),
|
|
770
|
+
_label(row.get("shape")),
|
|
771
|
+
_stair_directions(row.get("upStairDirections")),
|
|
772
|
+
tuple(_point(point) for point in _array(row["path"])) if row.get("path") is not None else None,
|
|
773
|
+
_json_attributes(row.get("attributes")),
|
|
774
|
+
_furniture_presentation(row.get("presentation")),
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
zones = []
|
|
778
|
+
for value in _array(data.get("zones", [])):
|
|
779
|
+
row = _object(value, "id geometry", "name")
|
|
780
|
+
shape = geometry_from_dict(row["geometry"])
|
|
781
|
+
if not isinstance(shape, PolygonGeometry):
|
|
782
|
+
raise TypeError("zone geometry must be a polygon")
|
|
783
|
+
zones.append(Zone(_text(row["id"]), _label(row.get("name")), shape))
|
|
784
|
+
dimension_annotations = []
|
|
785
|
+
for value in _array(data.get("dimensionAnnotations", [])):
|
|
786
|
+
row = _object(value, "id kind value geometry", "refId")
|
|
787
|
+
geometry = geometry_from_dict(row["geometry"])
|
|
788
|
+
if not isinstance(geometry, LineGeometry):
|
|
789
|
+
raise TypeError("dimension annotation geometry must be a line")
|
|
790
|
+
annotation_value = _integer(row["value"])
|
|
791
|
+
if annotation_value <= 0:
|
|
792
|
+
raise ValueError("dimension annotation value must be a positive integer")
|
|
793
|
+
dimension_annotations.append(DimensionAnnotation(
|
|
794
|
+
_text(row["id"]), annotation_value, geometry,
|
|
795
|
+
_text(row["refId"]) if row.get("refId") is not None else None,
|
|
796
|
+
cast(Literal["linear"], _choice(row["kind"], {"linear"})),
|
|
797
|
+
))
|
|
798
|
+
referenced_entities: list[SceneEntity] = [
|
|
799
|
+
*rooms, *walls, *separators, *openings, *floor_openings, *buildings, *furniture, *zones,
|
|
800
|
+
]
|
|
801
|
+
entities: list[SceneEntity] = [*referenced_entities, *dimension_annotations]
|
|
802
|
+
ids = [item.id for item in entities]
|
|
803
|
+
if len(ids) != len(set(ids)):
|
|
804
|
+
raise ValueError("duplicate entity ids")
|
|
805
|
+
wall_ids = {wall.id for wall in walls}
|
|
806
|
+
if any(
|
|
807
|
+
segment.wallId is not None and segment.wallId not in wall_ids
|
|
808
|
+
for opening in openings
|
|
809
|
+
for segment in opening.segments
|
|
810
|
+
):
|
|
811
|
+
raise ValueError("opening references an unknown host wall")
|
|
812
|
+
entity_ids = {item.id for item in referenced_entities}
|
|
813
|
+
if any(item.refId is not None and item.refId not in entity_ids for item in dimension_annotations):
|
|
814
|
+
raise ValueError("dimension annotation references an unknown entity")
|
|
815
|
+
return FloorplanScene(
|
|
816
|
+
_text(data["id"]),
|
|
817
|
+
rooms=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in rooms),
|
|
818
|
+
walls=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in walls),
|
|
819
|
+
separators=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in separators),
|
|
820
|
+
openings=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in openings),
|
|
821
|
+
floorOpenings=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in floor_openings),
|
|
822
|
+
buildings=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in buildings),
|
|
823
|
+
furniture=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in furniture),
|
|
824
|
+
height=scene_height,
|
|
825
|
+
zones=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in zones),
|
|
826
|
+
dimensionAnnotations=tuple(replace(item, attributes=entity_attributes.get(item.id)) for item in dimension_annotations),
|
|
827
|
+
metadata=metadata,
|
|
828
|
+
presentation=(ScenePresentation(tuple(_array(_object(data["presentation"], "viewBox")["viewBox"])))
|
|
829
|
+
if data.get("presentation") is not None else None),
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def quantize_mm(value: float) -> float:
|
|
834
|
+
"""Normalize millimeter values to the default 1e-3 mm grid."""
|
|
835
|
+
result = round(value, DEFAULT_MM_DECIMALS)
|
|
836
|
+
return 0.0 if result == 0 else result
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def scene_to_dict(scene: FloorplanScene) -> JsonObject:
|
|
840
|
+
def point(value: Point) -> list[float]:
|
|
841
|
+
return [value[0], value[1]]
|
|
842
|
+
|
|
843
|
+
def line(value: LineGeometry) -> list[float]:
|
|
844
|
+
return [*value.start, *value.end]
|
|
845
|
+
|
|
846
|
+
def ring(value: Ring) -> list[list[float]]:
|
|
847
|
+
return [point(item) for item in value[:-1]]
|
|
848
|
+
|
|
849
|
+
def footprint(value: Footprint) -> list[list[list[list[float]]]]:
|
|
850
|
+
return [[ring(item) for item in polygon] for polygon in value]
|
|
851
|
+
|
|
852
|
+
def shape(value: Geometry) -> JsonObject:
|
|
853
|
+
if isinstance(value, LineGeometry):
|
|
854
|
+
return {"line": line(value)}
|
|
855
|
+
if isinstance(value, ArcGeometry):
|
|
856
|
+
return {"arc": {"start": point(value.start), "end": point(value.end), "bulge": value.bulge}}
|
|
857
|
+
if isinstance(value, QuadraticGeometry):
|
|
858
|
+
return {"quadratic": {"start": point(value.start), "control": point(value.control),
|
|
859
|
+
"end": point(value.end)}}
|
|
860
|
+
if isinstance(value, PolygonGeometry):
|
|
861
|
+
if len(value.polygons) == 1 and len(value.polygons[0]) == 1:
|
|
862
|
+
return {"polygon": ring(value.polygons[0][0])}
|
|
863
|
+
return {"polygons": footprint(value.polygons)}
|
|
864
|
+
if isinstance(value, PathGeometry):
|
|
865
|
+
return {"path": [[[shape(edge) for edge in curve_ring] for curve_ring in polygon]
|
|
866
|
+
for polygon in value.polygons]}
|
|
867
|
+
if isinstance(value, CircleGeometry):
|
|
868
|
+
return {"circle": {"center": point(value.center), "radius": value.radius}}
|
|
869
|
+
if isinstance(value, PipeGeometry):
|
|
870
|
+
return {"pipe": {"center": point(value.center), "width": value.width, "depth": value.depth,
|
|
871
|
+
"angle": value.angle, "profile": value.profile,
|
|
872
|
+
"wallThickness": list(value.wallThickness)}}
|
|
873
|
+
return {"bayWindow": {"outline": ring(value.outline), "wallPath": [point(p) for p in value.wallPath],
|
|
874
|
+
"profile": value.profile}}
|
|
875
|
+
|
|
876
|
+
def optional(row: JsonObject, obj: Any, names: tuple[str, ...]) -> None:
|
|
877
|
+
for name in names:
|
|
878
|
+
value = getattr(obj, name)
|
|
879
|
+
if value is not None:
|
|
880
|
+
row[name] = value
|
|
881
|
+
|
|
882
|
+
data: JsonObject = {"id": scene.id, "units": {"length": scene.unit, "area": "m2", "angle": "deg"}}
|
|
883
|
+
if scene.height is not None:
|
|
884
|
+
data["height"] = scene.height
|
|
885
|
+
if scene.presentation is not None:
|
|
886
|
+
data["presentation"] = {"viewBox": list(scene.presentation.viewBox)}
|
|
887
|
+
if scene.metadata is not None:
|
|
888
|
+
origin, inference, level = scene.metadata.originImage, scene.metadata.inference, scene.metadata.level
|
|
889
|
+
metadata_row: JsonObject = {}
|
|
890
|
+
if origin is not None:
|
|
891
|
+
metadata_row["originImage"] = {"url": origin.url, "pixelWidth": origin.pixelWidth,
|
|
892
|
+
"pixelHeight": origin.pixelHeight, "sceneBounds": list(origin.sceneBounds)}
|
|
893
|
+
if inference is not None:
|
|
894
|
+
metadata_row["inference"] = {"model": inference.model, "reasoningEffort": inference.reasoningEffort,
|
|
895
|
+
"sessionId": inference.sessionId, "skillVersion": inference.skillVersion,
|
|
896
|
+
"sourceAsset": inference.sourceAsset}
|
|
897
|
+
if level is not None:
|
|
898
|
+
metadata_row["level"] = {"name": level.name}
|
|
899
|
+
if level.index is not None:
|
|
900
|
+
metadata_row["level"]["index"] = level.index
|
|
901
|
+
data["metadata"] = metadata_row
|
|
902
|
+
data["rooms"] = []
|
|
903
|
+
for room in scene.rooms:
|
|
904
|
+
room_row: JsonObject = {
|
|
905
|
+
"id": room.id,
|
|
906
|
+
"type": room.type,
|
|
907
|
+
"labelPoint": point(room.labelPoint),
|
|
908
|
+
"geometry": shape(room.geometry),
|
|
909
|
+
}
|
|
910
|
+
optional(room_row, room, ("name", "area", "roomTypeId"))
|
|
911
|
+
data["rooms"].append(room_row)
|
|
912
|
+
walls: list[JsonObject] = []
|
|
913
|
+
for wall in scene.walls:
|
|
914
|
+
wall_row: JsonObject = {"id": wall.id, "geometry": shape(wall.geometry)}
|
|
915
|
+
optional(wall_row, wall, ("thickness",))
|
|
916
|
+
if wall.loadBearing:
|
|
917
|
+
wall_row["loadBearing"] = True
|
|
918
|
+
if wall.height != scene.height:
|
|
919
|
+
wall_row["height"] = wall.height
|
|
920
|
+
walls.append(wall_row)
|
|
921
|
+
if walls:
|
|
922
|
+
data["walls"] = walls
|
|
923
|
+
if scene.separators:
|
|
924
|
+
data["separators"] = [
|
|
925
|
+
{"id": separator.id, "geometry": shape(separator.geometry)}
|
|
926
|
+
for separator in scene.separators
|
|
927
|
+
]
|
|
928
|
+
if scene.openings:
|
|
929
|
+
data["openings"] = []
|
|
930
|
+
for opening in scene.openings:
|
|
931
|
+
opening_row: JsonObject = {"id": opening.id, "type": opening.type}
|
|
932
|
+
optional(opening_row, opening, ("height", "depth"))
|
|
933
|
+
if opening.bottomElevation is not None:
|
|
934
|
+
opening_row["bottomElevation"] = opening.bottomElevation
|
|
935
|
+
segments: list[JsonObject] = []
|
|
936
|
+
for source_segment in opening.segments:
|
|
937
|
+
segment: JsonObject = {"geometry": shape(source_segment.geometry), "wallId": source_segment.wallId}
|
|
938
|
+
optional(segment, source_segment, ("openSide", "outside"))
|
|
939
|
+
segments.append(segment)
|
|
940
|
+
opening_row["segments"] = segments
|
|
941
|
+
data["openings"].append(opening_row)
|
|
942
|
+
if scene.floorOpenings:
|
|
943
|
+
data["floorOpenings"] = []
|
|
944
|
+
for floor_opening in scene.floorOpenings:
|
|
945
|
+
floor_row: JsonObject = {"id": floor_opening.id, "geometry": shape(floor_opening.geometry)}
|
|
946
|
+
optional(floor_row, floor_opening, ("name", "bottomElevation", "thickness"))
|
|
947
|
+
data["floorOpenings"].append(floor_row)
|
|
948
|
+
buildings: list[JsonObject] = []
|
|
949
|
+
for building in scene.buildings:
|
|
950
|
+
building_row: JsonObject = {
|
|
951
|
+
"id": building.id,
|
|
952
|
+
"type": building.type,
|
|
953
|
+
"geometry": shape(building.geometry),
|
|
954
|
+
}
|
|
955
|
+
optional(building_row, building, ("height", "thickness", "name"))
|
|
956
|
+
if building.bottomElevation is not None:
|
|
957
|
+
building_row["bottomElevation"] = building.bottomElevation
|
|
958
|
+
if building.type == "roof":
|
|
959
|
+
building_row.update(height=building.height, bottomElevation=building.bottomElevation,
|
|
960
|
+
thickness=building.thickness,
|
|
961
|
+
ridgeLines=[[*point(line.start), *point(line.end)] for line in building.ridgeLines])
|
|
962
|
+
if building.direction is not None:
|
|
963
|
+
building_row["direction"] = point(building.direction)
|
|
964
|
+
if building.bayWindow is not None:
|
|
965
|
+
params: JsonObject = {}
|
|
966
|
+
optional(params, building.bayWindow, ("beamThickness", "upperFloorThickness", "lowerFloorThickness",
|
|
967
|
+
"wallThickness", "offset", "windowHeight", "platformType"))
|
|
968
|
+
building_row["bayWindow"] = params
|
|
969
|
+
if building.inspectionHatch is not None:
|
|
970
|
+
hatch_params: JsonObject = {"surface": building.inspectionHatch.surface}
|
|
971
|
+
optional(hatch_params, building.inspectionHatch, ("hasStorage",))
|
|
972
|
+
building_row["inspectionHatch"] = hatch_params
|
|
973
|
+
buildings.append(building_row)
|
|
974
|
+
if buildings:
|
|
975
|
+
data["buildings"] = buildings
|
|
976
|
+
if scene.furniture:
|
|
977
|
+
data["furniture"] = []
|
|
978
|
+
for item in scene.furniture:
|
|
979
|
+
row: JsonObject = {
|
|
980
|
+
"id": item.id,
|
|
981
|
+
"type": item.type,
|
|
982
|
+
"placement": {
|
|
983
|
+
"center": point(item.placement.center),
|
|
984
|
+
"width": item.placement.width,
|
|
985
|
+
"depth": item.placement.depth,
|
|
986
|
+
"rotation": item.placement.rotation,
|
|
987
|
+
},
|
|
988
|
+
"geometry": shape(item.geometry),
|
|
989
|
+
}
|
|
990
|
+
optional(row, item, ("height", "thickness", "name"))
|
|
991
|
+
if item.bottomElevation is not None:
|
|
992
|
+
row["bottomElevation"] = item.bottomElevation
|
|
993
|
+
if item.shape is not None:
|
|
994
|
+
row["shape"] = item.shape
|
|
995
|
+
if item.upStairDirections is not None:
|
|
996
|
+
row["upStairDirections"] = deepcopy(list(item.upStairDirections))
|
|
997
|
+
if item.path is not None:
|
|
998
|
+
row["path"] = [point(value) for value in item.path]
|
|
999
|
+
if item.attributes is not None:
|
|
1000
|
+
row["attributes"] = deepcopy(item.attributes)
|
|
1001
|
+
if item.presentation is not None:
|
|
1002
|
+
row["presentation"] = {
|
|
1003
|
+
"decorations": [{"tag": value.tag, "attributes": deepcopy(value.attributes)}
|
|
1004
|
+
for value in item.presentation.decorations],
|
|
1005
|
+
"footprintStyle": deepcopy(item.presentation.footprintStyle),
|
|
1006
|
+
"footprintIndex": item.presentation.footprintIndex,
|
|
1007
|
+
}
|
|
1008
|
+
data["furniture"].append(row)
|
|
1009
|
+
if scene.zones:
|
|
1010
|
+
data["zones"] = []
|
|
1011
|
+
for zone in scene.zones:
|
|
1012
|
+
row = {"id": zone.id, "geometry": shape(zone.geometry)}
|
|
1013
|
+
if zone.name is not None:
|
|
1014
|
+
row["name"] = zone.name
|
|
1015
|
+
data["zones"].append(row)
|
|
1016
|
+
if scene.dimensionAnnotations:
|
|
1017
|
+
data["dimensionAnnotations"] = []
|
|
1018
|
+
for dimension in scene.dimensionAnnotations:
|
|
1019
|
+
row = {
|
|
1020
|
+
"id": dimension.id,
|
|
1021
|
+
"kind": dimension.kind,
|
|
1022
|
+
"value": dimension.value,
|
|
1023
|
+
"geometry": shape(dimension.geometry),
|
|
1024
|
+
}
|
|
1025
|
+
if dimension.refId is not None:
|
|
1026
|
+
row["refId"] = dimension.refId
|
|
1027
|
+
data["dimensionAnnotations"].append(row)
|
|
1028
|
+
|
|
1029
|
+
for collection in ("rooms", "walls", "separators", "openings", "floorOpenings", "buildings",
|
|
1030
|
+
"furniture", "zones", "dimensionAnnotations"):
|
|
1031
|
+
for entity, row in zip(getattr(scene, collection), data.get(collection, [])):
|
|
1032
|
+
if entity.attributes is not None:
|
|
1033
|
+
row["attributes"] = _json_attributes(entity.attributes)
|
|
1034
|
+
|
|
1035
|
+
def compact_numbers(value: Any, unit: str = "length") -> Any:
|
|
1036
|
+
if isinstance(value, dict):
|
|
1037
|
+
return {
|
|
1038
|
+
key: deepcopy(item) if key == "attributes" else compact_numbers(
|
|
1039
|
+
item,
|
|
1040
|
+
"area" if key == "area" else "native" if key in {"angle", "bulge", "direction", "rotation"} else unit,
|
|
1041
|
+
)
|
|
1042
|
+
for key, item in value.items()
|
|
1043
|
+
}
|
|
1044
|
+
if isinstance(value, list):
|
|
1045
|
+
return [compact_numbers(item, unit) for item in value]
|
|
1046
|
+
if isinstance(value, float):
|
|
1047
|
+
if unit == "area":
|
|
1048
|
+
value = round(value, 2)
|
|
1049
|
+
elif unit == "length":
|
|
1050
|
+
value = quantize_mm(value)
|
|
1051
|
+
return int(value) if value.is_integer() else value
|
|
1052
|
+
return value
|
|
1053
|
+
|
|
1054
|
+
return cast(JsonObject, compact_numbers(data))
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def read_json(text: str) -> JsonObject:
|
|
1058
|
+
def reject_constant(value: str) -> None:
|
|
1059
|
+
raise ValueError(f"non-finite JSON number: {value}")
|
|
1060
|
+
|
|
1061
|
+
def unique(pairs: list[tuple[str, Any]]) -> JsonObject:
|
|
1062
|
+
result: JsonObject = {}
|
|
1063
|
+
for key, value in pairs:
|
|
1064
|
+
if key in result:
|
|
1065
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
1066
|
+
result[key] = value
|
|
1067
|
+
return result
|
|
1068
|
+
|
|
1069
|
+
value = json.loads(text, object_pairs_hook=unique, parse_constant=reject_constant)
|
|
1070
|
+
if not isinstance(value, dict):
|
|
1071
|
+
raise TypeError("expected a JSON object")
|
|
1072
|
+
return value
|