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/fpi.py
ADDED
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
"""FPI/Legend adapters. Source attributes stay in a separate, bound supplement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import warnings
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from .component_defaults import component_defaults
|
|
13
|
+
from .fpi_geometry import (
|
|
14
|
+
building_geometry,
|
|
15
|
+
building_to_fpi,
|
|
16
|
+
line_geometry,
|
|
17
|
+
line_to_fpi,
|
|
18
|
+
optional_number,
|
|
19
|
+
point_dict,
|
|
20
|
+
polygon_geometry,
|
|
21
|
+
polygon_to_fpi,
|
|
22
|
+
source_points,
|
|
23
|
+
wall_geometry,
|
|
24
|
+
wall_to_fpi,
|
|
25
|
+
)
|
|
26
|
+
from .fpi_geometry import source_point as _xy
|
|
27
|
+
from .furniture import (
|
|
28
|
+
LEGEND_SHAPES,
|
|
29
|
+
LEGEND_TYPES,
|
|
30
|
+
LEGEND_TYPES_SUPPORTED,
|
|
31
|
+
complete_legend_fields,
|
|
32
|
+
furniture_type,
|
|
33
|
+
legend_required_fields_missing,
|
|
34
|
+
legend_type_attributes,
|
|
35
|
+
require_stair_direction,
|
|
36
|
+
)
|
|
37
|
+
from .geometry import Issue, opening_host_issues, snap_openings_to_hosts
|
|
38
|
+
from .model import (
|
|
39
|
+
OPENING_TYPES,
|
|
40
|
+
BayWindowParameters,
|
|
41
|
+
Building,
|
|
42
|
+
FloorplanScene,
|
|
43
|
+
Furniture,
|
|
44
|
+
FurniturePlacement,
|
|
45
|
+
JsonObject,
|
|
46
|
+
Opening,
|
|
47
|
+
OpeningKind,
|
|
48
|
+
OpeningSegment,
|
|
49
|
+
Point,
|
|
50
|
+
Room,
|
|
51
|
+
SceneEntity,
|
|
52
|
+
Separator,
|
|
53
|
+
Wall,
|
|
54
|
+
Zone,
|
|
55
|
+
_boolean,
|
|
56
|
+
_number,
|
|
57
|
+
_point,
|
|
58
|
+
opening_kind,
|
|
59
|
+
scene_from_dict,
|
|
60
|
+
scene_to_dict,
|
|
61
|
+
)
|
|
62
|
+
from .shape_geometry import (
|
|
63
|
+
arc_subsegment,
|
|
64
|
+
geometry_shape,
|
|
65
|
+
local_furniture_geometry,
|
|
66
|
+
placed_furniture_geometry,
|
|
67
|
+
)
|
|
68
|
+
from .shapes import (
|
|
69
|
+
GEOMETRY_TOLERANCE_MM,
|
|
70
|
+
ArcGeometry,
|
|
71
|
+
BayWindowGeometry,
|
|
72
|
+
CircleGeometry,
|
|
73
|
+
Geometry,
|
|
74
|
+
LineGeometry,
|
|
75
|
+
PolygonGeometry,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
BUILDING_TYPES = {
|
|
79
|
+
"pillars": "pillar",
|
|
80
|
+
"circlePillars": "pillar",
|
|
81
|
+
"flues": "flue",
|
|
82
|
+
"beams": "beam",
|
|
83
|
+
"placeHolders": "placeholder",
|
|
84
|
+
"pipeWalls": "pipe_wall",
|
|
85
|
+
"bayWindowSills": "bay_window_sill",
|
|
86
|
+
}
|
|
87
|
+
BUILDING_COLLECTIONS = set(BUILDING_TYPES)
|
|
88
|
+
FPI_COLLECTIONS = {
|
|
89
|
+
"walls",
|
|
90
|
+
"roomSeparators",
|
|
91
|
+
"rooms",
|
|
92
|
+
"holes",
|
|
93
|
+
"compositeHoles",
|
|
94
|
+
"niches",
|
|
95
|
+
"zones",
|
|
96
|
+
*BUILDING_COLLECTIONS,
|
|
97
|
+
}
|
|
98
|
+
BAY_WINDOW_FIELDS = {
|
|
99
|
+
"beamThickness",
|
|
100
|
+
"upperFloorThickness",
|
|
101
|
+
"lowerFloorThickness",
|
|
102
|
+
"wallThickness",
|
|
103
|
+
"offset",
|
|
104
|
+
"windowHeight",
|
|
105
|
+
"platformType",
|
|
106
|
+
}
|
|
107
|
+
GEOMETRY_FIELDS = {
|
|
108
|
+
"start",
|
|
109
|
+
"end",
|
|
110
|
+
"position",
|
|
111
|
+
"center",
|
|
112
|
+
"thickness",
|
|
113
|
+
"polygon",
|
|
114
|
+
"wallPolygon",
|
|
115
|
+
"shape",
|
|
116
|
+
"outlinePolygon",
|
|
117
|
+
"bbox",
|
|
118
|
+
"hostId",
|
|
119
|
+
"holeSegments",
|
|
120
|
+
"bulge",
|
|
121
|
+
"control",
|
|
122
|
+
"edges",
|
|
123
|
+
"height",
|
|
124
|
+
"z",
|
|
125
|
+
"sillHeight",
|
|
126
|
+
"groundClearance",
|
|
127
|
+
"depth",
|
|
128
|
+
"width",
|
|
129
|
+
"diameter",
|
|
130
|
+
"angle",
|
|
131
|
+
"direction",
|
|
132
|
+
"openSide",
|
|
133
|
+
"outside",
|
|
134
|
+
*BAY_WINDOW_FIELDS,
|
|
135
|
+
}
|
|
136
|
+
ROOM_IDS = {
|
|
137
|
+
"living": 1,
|
|
138
|
+
"dining": 3,
|
|
139
|
+
"living_dining": 22,
|
|
140
|
+
"bedroom": 15,
|
|
141
|
+
"kitchen": 7,
|
|
142
|
+
"bathroom": 8,
|
|
143
|
+
"balcony": 13,
|
|
144
|
+
"circulation": 14,
|
|
145
|
+
"other": 0,
|
|
146
|
+
"unknown": 31,
|
|
147
|
+
"study": 6,
|
|
148
|
+
"storage": 18,
|
|
149
|
+
"closet": 19,
|
|
150
|
+
"laundry": 27,
|
|
151
|
+
"office": 28,
|
|
152
|
+
"multipurpose": 9,
|
|
153
|
+
}
|
|
154
|
+
CUSTOM_ROOM_TYPE_ID = 17
|
|
155
|
+
ROOM_TYPE_BY_ID = {
|
|
156
|
+
0: "other",
|
|
157
|
+
1: "living",
|
|
158
|
+
2: "circulation",
|
|
159
|
+
3: "living",
|
|
160
|
+
4: "bedroom",
|
|
161
|
+
5: "bedroom",
|
|
162
|
+
6: "other",
|
|
163
|
+
7: "kitchen",
|
|
164
|
+
8: "bathroom",
|
|
165
|
+
9: "living",
|
|
166
|
+
10: "bedroom",
|
|
167
|
+
11: "bedroom",
|
|
168
|
+
12: "bedroom",
|
|
169
|
+
13: "balcony",
|
|
170
|
+
14: "circulation",
|
|
171
|
+
15: "bedroom",
|
|
172
|
+
16: "circulation",
|
|
173
|
+
17: "other",
|
|
174
|
+
18: "other",
|
|
175
|
+
19: "other",
|
|
176
|
+
20: "other",
|
|
177
|
+
21: "living",
|
|
178
|
+
22: "living",
|
|
179
|
+
23: "balcony",
|
|
180
|
+
24: "living",
|
|
181
|
+
25: "other",
|
|
182
|
+
26: "circulation",
|
|
183
|
+
27: "other",
|
|
184
|
+
28: "other",
|
|
185
|
+
29: "other",
|
|
186
|
+
30: "other",
|
|
187
|
+
31: "other",
|
|
188
|
+
32: "other",
|
|
189
|
+
33: "bedroom",
|
|
190
|
+
34: "living",
|
|
191
|
+
35: "other",
|
|
192
|
+
36: "circulation",
|
|
193
|
+
37: "balcony",
|
|
194
|
+
38: "balcony",
|
|
195
|
+
39: "other",
|
|
196
|
+
40: "bathroom",
|
|
197
|
+
41: "bathroom",
|
|
198
|
+
42: "kitchen",
|
|
199
|
+
43: "kitchen",
|
|
200
|
+
}
|
|
201
|
+
ROOM_TYPE_NAME_TOKENS = (
|
|
202
|
+
("卫生间", "bathroom"),
|
|
203
|
+
("主卫", "bathroom"),
|
|
204
|
+
("次卫", "bathroom"),
|
|
205
|
+
("客卫", "bathroom"),
|
|
206
|
+
("中厨", "kitchen"),
|
|
207
|
+
("西厨", "kitchen"),
|
|
208
|
+
("厨房", "kitchen"),
|
|
209
|
+
("景观阳台", "balcony"),
|
|
210
|
+
("生活阳台", "balcony"),
|
|
211
|
+
("露台", "balcony"),
|
|
212
|
+
("阳台", "balcony"),
|
|
213
|
+
("儿童房", "bedroom"),
|
|
214
|
+
("主卧", "bedroom"),
|
|
215
|
+
("次卧", "bedroom"),
|
|
216
|
+
("客房", "bedroom"),
|
|
217
|
+
("客卧", "bedroom"),
|
|
218
|
+
("老人房", "bedroom"),
|
|
219
|
+
("保姆房", "bedroom"),
|
|
220
|
+
("卧室", "bedroom"),
|
|
221
|
+
("多功能室", "living"),
|
|
222
|
+
("起居室", "living"),
|
|
223
|
+
("影视间", "living"),
|
|
224
|
+
("健身房", "living"),
|
|
225
|
+
("客餐厅", "living_dining"),
|
|
226
|
+
("客厅", "living"),
|
|
227
|
+
("餐厅", "dining"),
|
|
228
|
+
("走道", "circulation"),
|
|
229
|
+
("过道", "circulation"),
|
|
230
|
+
("玄关", "circulation"),
|
|
231
|
+
("门厅", "circulation"),
|
|
232
|
+
("楼梯间", "circulation"),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _items(value: JsonObject, key: str) -> list[JsonObject]:
|
|
237
|
+
raw = value.get(key, [])
|
|
238
|
+
if raw is None:
|
|
239
|
+
return []
|
|
240
|
+
if not isinstance(raw, list) or any(not isinstance(item, dict) for item in raw):
|
|
241
|
+
raise ValueError(f"{key} must be an object array")
|
|
242
|
+
return raw
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _extract_floorplan(fpi: JsonObject, level_index: int, floorplan_index: int) -> JsonObject:
|
|
246
|
+
if level_index < 0 or floorplan_index < 0:
|
|
247
|
+
raise ValueError("level_index and floorplan_index must be non-negative")
|
|
248
|
+
direct = fpi.get("floorPlan")
|
|
249
|
+
if isinstance(direct, dict):
|
|
250
|
+
return direct
|
|
251
|
+
if (
|
|
252
|
+
"floorPlan" not in fpi
|
|
253
|
+
and "levels" not in fpi
|
|
254
|
+
and (
|
|
255
|
+
not fpi
|
|
256
|
+
or FPI_COLLECTIONS.intersection(fpi)
|
|
257
|
+
or {"name", "communityName", "city", "province", "height"}.intersection(fpi)
|
|
258
|
+
)
|
|
259
|
+
):
|
|
260
|
+
return fpi
|
|
261
|
+
try:
|
|
262
|
+
floorplan = fpi["levels"][level_index]["fpiFloorPlans"][floorplan_index]
|
|
263
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
264
|
+
raise ValueError("expected floorPlan or levels[*].fpiFloorPlans[*] in FPI JSON") from exc
|
|
265
|
+
if not isinstance(floorplan, dict):
|
|
266
|
+
raise TypeError("selected FPI floor plan must be an object")
|
|
267
|
+
return floorplan
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _room_kind(room: JsonObject) -> str:
|
|
271
|
+
room_type_id = room.get("roomTypeId")
|
|
272
|
+
mapped = ROOM_TYPE_BY_ID.get(room_type_id) if isinstance(room_type_id, int) else None
|
|
273
|
+
if mapped is not None:
|
|
274
|
+
return mapped
|
|
275
|
+
name = str(room.get("name") or "").strip()
|
|
276
|
+
if name == "走廊":
|
|
277
|
+
return "circulation"
|
|
278
|
+
return next((kind for token, kind in ROOM_TYPE_NAME_TOKENS if token in name), "other")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _inverse_transform(fpi: JsonObject) -> list[list[float]]:
|
|
282
|
+
transforms = fpi.get("transforms") or []
|
|
283
|
+
try:
|
|
284
|
+
matrix = transforms[0]["inverseMatrix"]
|
|
285
|
+
if len(matrix) != 3 or any(len(row) != 3 for row in matrix):
|
|
286
|
+
raise ValueError
|
|
287
|
+
return [[_number(value) for value in row] for row in matrix]
|
|
288
|
+
except (IndexError, KeyError, TypeError, ValueError) as exc:
|
|
289
|
+
raise ValueError("FPI export must contain a 3x3 transforms[0].inverseMatrix") from exc
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _transform_point(matrix: list[list[float]], point: Point) -> Point:
|
|
293
|
+
x, y = point
|
|
294
|
+
denominator = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2]
|
|
295
|
+
if abs(denominator) < 1e-12:
|
|
296
|
+
raise ValueError("invalid inverse transform denominator")
|
|
297
|
+
return (
|
|
298
|
+
_number((matrix[0][0] * x + matrix[0][1] * y + matrix[0][2]) / denominator),
|
|
299
|
+
_number((matrix[1][0] * x + matrix[1][1] * y + matrix[1][2]) / denominator),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _legend_placement(item: JsonObject) -> FurniturePlacement:
|
|
304
|
+
bbox = item["bbox"]
|
|
305
|
+
if not isinstance(bbox, dict):
|
|
306
|
+
raise TypeError("Legend bbox must be an object")
|
|
307
|
+
center = _xy(bbox["center"])
|
|
308
|
+
width, depth = _number(bbox["width"]), _number(bbox["height"])
|
|
309
|
+
rotation = _number(bbox.get("rotationAngle", 0))
|
|
310
|
+
if width <= 0 or depth <= 0:
|
|
311
|
+
raise ValueError("Legend bbox width and height must be finite and positive")
|
|
312
|
+
return FurniturePlacement(center, width, depth, rotation % 360)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _transform_placement(
|
|
316
|
+
matrix: list[list[float]],
|
|
317
|
+
placement: FurniturePlacement,
|
|
318
|
+
) -> FurniturePlacement:
|
|
319
|
+
angle = math.radians(placement.rotation)
|
|
320
|
+
center = _transform_point(matrix, placement.center)
|
|
321
|
+
width_point = _transform_point(
|
|
322
|
+
matrix,
|
|
323
|
+
(placement.center[0] + math.cos(angle) * placement.width / 2,
|
|
324
|
+
placement.center[1] + math.sin(angle) * placement.width / 2),
|
|
325
|
+
)
|
|
326
|
+
depth_point = _transform_point(
|
|
327
|
+
matrix,
|
|
328
|
+
(placement.center[0] - math.sin(angle) * placement.depth / 2,
|
|
329
|
+
placement.center[1] + math.cos(angle) * placement.depth / 2),
|
|
330
|
+
)
|
|
331
|
+
rotation = math.degrees(math.atan2(width_point[1] - center[1], width_point[0] - center[0])) % 360
|
|
332
|
+
return FurniturePlacement(
|
|
333
|
+
center,
|
|
334
|
+
2 * math.dist(center, width_point),
|
|
335
|
+
2 * math.dist(center, depth_point),
|
|
336
|
+
rotation,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _item_polygon_points(
|
|
341
|
+
item: JsonObject,
|
|
342
|
+
placement: FurniturePlacement,
|
|
343
|
+
) -> tuple[list[tuple[float, float]], str]:
|
|
344
|
+
outline = item.get("outlinePolygon") or []
|
|
345
|
+
if len(outline) >= 3:
|
|
346
|
+
return (
|
|
347
|
+
[
|
|
348
|
+
_xy(point) if isinstance(point, dict) else (float(point[0]), float(point[1]))
|
|
349
|
+
for point in outline
|
|
350
|
+
],
|
|
351
|
+
"source_outline_polygon",
|
|
352
|
+
)
|
|
353
|
+
center = placement.center
|
|
354
|
+
width, depth = placement.width, placement.depth
|
|
355
|
+
angle = math.radians(placement.rotation)
|
|
356
|
+
cosine, sine = math.cos(angle), math.sin(angle)
|
|
357
|
+
points = [
|
|
358
|
+
(
|
|
359
|
+
center[0] + dx * cosine - dy * sine,
|
|
360
|
+
center[1] + dx * sine + dy * cosine,
|
|
361
|
+
)
|
|
362
|
+
for dx, dy in (
|
|
363
|
+
(-width / 2, -depth / 2),
|
|
364
|
+
(width / 2, -depth / 2),
|
|
365
|
+
(width / 2, depth / 2),
|
|
366
|
+
(-width / 2, depth / 2),
|
|
367
|
+
)
|
|
368
|
+
]
|
|
369
|
+
return points, "rotated_bounding_box"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass(frozen=True)
|
|
373
|
+
class FpiSupplement:
|
|
374
|
+
sceneId: str
|
|
375
|
+
frameId: str
|
|
376
|
+
planAttributes: JsonObject
|
|
377
|
+
bindings: JsonObject
|
|
378
|
+
additionalRoomLabels: tuple[JsonObject, ...]
|
|
379
|
+
excludedLegend: tuple[JsonObject, ...]
|
|
380
|
+
furnitureCoverage: str
|
|
381
|
+
issues: tuple[Issue, ...]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass(frozen=True)
|
|
385
|
+
class FpiImport:
|
|
386
|
+
scene: FloorplanScene
|
|
387
|
+
supplement: FpiSupplement
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _graph_ring(value: object, label: str) -> tuple[Point, ...]:
|
|
391
|
+
if not isinstance(value, list) or len(value) < 3:
|
|
392
|
+
raise ValueError(f"{label} must contain at least three points")
|
|
393
|
+
ring = tuple(_xy(point) for point in value)
|
|
394
|
+
return ring if ring[0] == ring[-1] else (*ring, ring[0])
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _graph_room_data(graph: JsonObject | None, floorplan_index: int) -> dict[str, JsonObject]:
|
|
398
|
+
if graph is None:
|
|
399
|
+
return {}
|
|
400
|
+
frame = graph.get("geometryFrame")
|
|
401
|
+
if not isinstance(frame, dict) or frame.get("lengthUnit") != "mm" or frame.get("millimetersPerUnit") != 1:
|
|
402
|
+
raise ValueError("withGraph geometryFrame must use millimeters")
|
|
403
|
+
graph_index = graph.get("floorPlanIndex")
|
|
404
|
+
if graph_index is not None and graph_index != floorplan_index:
|
|
405
|
+
raise ValueError("withGraph floorPlanIndex does not match the selected FPI")
|
|
406
|
+
segments: dict[str, tuple[Point, ...]] = {}
|
|
407
|
+
for segment in _items(graph, "roomBoundarySegments"):
|
|
408
|
+
key = segment.get("key")
|
|
409
|
+
segment_points = segment.get("points")
|
|
410
|
+
if not isinstance(key, str) or not key or key in segments:
|
|
411
|
+
raise ValueError("withGraph room boundary segment keys must be unique strings")
|
|
412
|
+
if not isinstance(segment_points, list) or len(segment_points) < 2:
|
|
413
|
+
raise ValueError(f"withGraph segment {key} must contain at least two points")
|
|
414
|
+
segments[key] = tuple(_xy(point) for point in segment_points)
|
|
415
|
+
result: dict[str, JsonObject] = {}
|
|
416
|
+
for source in _items(graph, "roomOutlines"):
|
|
417
|
+
room_key = source.get("roomKey")
|
|
418
|
+
if not isinstance(room_key, str) or not room_key or room_key in result:
|
|
419
|
+
raise ValueError("withGraph roomKey values must be unique strings")
|
|
420
|
+
rings = [_graph_ring(source.get("outline"), f"withGraph outline {room_key}")]
|
|
421
|
+
contours = source.get("contours", [])
|
|
422
|
+
if not isinstance(contours, list) or any(not isinstance(item, dict) for item in contours):
|
|
423
|
+
raise ValueError(f"withGraph contours {room_key} must be an object array")
|
|
424
|
+
for contour in contours:
|
|
425
|
+
if contour.get("role") != "hole":
|
|
426
|
+
continue
|
|
427
|
+
keys = contour.get("boundarySegmentKeys")
|
|
428
|
+
if not isinstance(keys, list) or not keys or any(not isinstance(key, str) for key in keys):
|
|
429
|
+
raise ValueError(f"withGraph hole contour {room_key} has invalid segment keys")
|
|
430
|
+
hole_points: list[Point] = []
|
|
431
|
+
for key in keys:
|
|
432
|
+
if key not in segments:
|
|
433
|
+
raise ValueError(f"withGraph hole contour {room_key} references missing segment {key}")
|
|
434
|
+
part = segments[key]
|
|
435
|
+
if hole_points and math.dist(hole_points[-1], part[0]) > GEOMETRY_TOLERANCE_MM:
|
|
436
|
+
raise ValueError(f"withGraph hole contour {room_key} is not contiguous")
|
|
437
|
+
hole_points.extend(part if not hole_points else part[1:])
|
|
438
|
+
if len(hole_points) < 4 or math.dist(hole_points[0], hole_points[-1]) > GEOMETRY_TOLERANCE_MM:
|
|
439
|
+
raise ValueError(f"withGraph hole contour {room_key} is not closed")
|
|
440
|
+
hole_points[-1] = hole_points[0]
|
|
441
|
+
rings.append(tuple(hole_points))
|
|
442
|
+
area = source.get("area")
|
|
443
|
+
if area is not None:
|
|
444
|
+
area = _number(area)
|
|
445
|
+
if area <= 0:
|
|
446
|
+
raise ValueError(f"withGraph area {room_key} must be positive")
|
|
447
|
+
result[room_key] = {
|
|
448
|
+
"outline": tuple(rings),
|
|
449
|
+
"cellKey": source.get("cellKey"),
|
|
450
|
+
"faceId": source.get("faceId"),
|
|
451
|
+
"areaM2": area,
|
|
452
|
+
"confidence": source.get("confidence"),
|
|
453
|
+
}
|
|
454
|
+
return result
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _room_type(source: JsonObject) -> str:
|
|
458
|
+
room_type = source.get("roomTypeId")
|
|
459
|
+
if room_type is not None and (isinstance(room_type, bool) or not isinstance(room_type, int)):
|
|
460
|
+
raise ValueError("roomTypeId must be an integer when supplied")
|
|
461
|
+
exact_type = next((kind for kind, type_id in ROOM_IDS.items() if type_id == room_type), None)
|
|
462
|
+
return exact_type if exact_type is not None else _room_kind(source)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _opening_kind(source: JsonObject) -> OpeningKind:
|
|
466
|
+
source_type = source.get("type")
|
|
467
|
+
if source_type is not None and (not isinstance(source_type, str) or source_type not in OPENING_TYPES - {"NICHE"}):
|
|
468
|
+
raise ValueError(f"unsupported FPI opening type: {source_type!r}")
|
|
469
|
+
return opening_kind(source_type)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def import_fpi(
|
|
473
|
+
fpi: JsonObject,
|
|
474
|
+
legend: JsonObject | None = None,
|
|
475
|
+
*,
|
|
476
|
+
graph: JsonObject | None = None,
|
|
477
|
+
scene_id: str,
|
|
478
|
+
frame_id: str,
|
|
479
|
+
level_index: int = 0,
|
|
480
|
+
floorplan_index: int = 0,
|
|
481
|
+
) -> FpiImport:
|
|
482
|
+
plan = _extract_floorplan(fpi, level_index, floorplan_index)
|
|
483
|
+
graph_rooms = _graph_room_data(graph, floorplan_index)
|
|
484
|
+
source_rooms = _items(plan, "rooms")
|
|
485
|
+
expected_room_keys = {f"room:{room.get('id')}" for room in source_rooms}
|
|
486
|
+
if graph is not None and set(graph_rooms) != expected_room_keys:
|
|
487
|
+
raise ValueError("withGraph room keys must exactly match the selected FPI rooms")
|
|
488
|
+
for key, value in plan.items():
|
|
489
|
+
if isinstance(value, list) and value and key not in FPI_COLLECTIONS:
|
|
490
|
+
raise ValueError(f"unsupported non-empty FPI collection: {key}")
|
|
491
|
+
bindings: JsonObject = {}
|
|
492
|
+
source_ids: dict[str, set[str]] = defaultdict(set)
|
|
493
|
+
|
|
494
|
+
def bind(entity_id: str, collection: str, source: JsonObject) -> None:
|
|
495
|
+
source_id = source.get("id")
|
|
496
|
+
if isinstance(source_id, bool) or not isinstance(source_id, (str, int)) or not str(source_id):
|
|
497
|
+
raise ValueError(f"{collection} requires a string or integer source id")
|
|
498
|
+
if str(source_id) in source_ids[collection]:
|
|
499
|
+
raise ValueError(f"duplicate {collection} id: {source_id}")
|
|
500
|
+
source_ids[collection].add(str(source_id))
|
|
501
|
+
bindings[entity_id] = {
|
|
502
|
+
"collection": collection,
|
|
503
|
+
"sourceId": source_id,
|
|
504
|
+
"attributes": deepcopy(
|
|
505
|
+
{k: v for k, v in source.items() if k not in GEOMETRY_FIELDS and k != "id"}
|
|
506
|
+
),
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
walls: list[Wall] = []
|
|
510
|
+
separators: list[Separator] = []
|
|
511
|
+
wall_ids: dict[str, str] = {}
|
|
512
|
+
for collection, prefix in (("walls", "W"), ("roomSeparators", "V")):
|
|
513
|
+
for index, source in enumerate(_items(plan, collection), 1):
|
|
514
|
+
entity_id = f"{prefix}{index}"
|
|
515
|
+
bind(entity_id, collection, source)
|
|
516
|
+
thickness = optional_number(source, "thickness") if collection == "walls" else None
|
|
517
|
+
load_bearing = source.get("loadBearingWall") if collection == "walls" else None
|
|
518
|
+
if collection == "walls":
|
|
519
|
+
walls.append(
|
|
520
|
+
Wall(
|
|
521
|
+
entity_id,
|
|
522
|
+
wall_geometry(source),
|
|
523
|
+
thickness,
|
|
524
|
+
False if load_bearing is None else _boolean(load_bearing),
|
|
525
|
+
optional_number(source, "height"),
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
wall_ids[str(source["id"])] = entity_id
|
|
529
|
+
else:
|
|
530
|
+
separators.append(Separator(entity_id, line_geometry(source)))
|
|
531
|
+
openings: list[Opening] = []
|
|
532
|
+
|
|
533
|
+
def host_id(source: JsonObject) -> str | None:
|
|
534
|
+
value = source.get("hostId")
|
|
535
|
+
if value is None:
|
|
536
|
+
return None
|
|
537
|
+
if str(value) not in wall_ids:
|
|
538
|
+
raise ValueError(f"unknown FPI hostId: {value}")
|
|
539
|
+
return wall_ids[str(value)]
|
|
540
|
+
|
|
541
|
+
walls_by_id = {wall.id: wall for wall in walls}
|
|
542
|
+
|
|
543
|
+
def opening_geometry(source: JsonObject) -> LineGeometry | ArcGeometry:
|
|
544
|
+
line = line_geometry(source)
|
|
545
|
+
wall = walls_by_id.get(host_id(source) or "")
|
|
546
|
+
if wall is not None and isinstance(wall.geometry, ArcGeometry) and wall.geometry.bulge != 0:
|
|
547
|
+
return arc_subsegment(wall.geometry, line.start, line.end)
|
|
548
|
+
return line
|
|
549
|
+
|
|
550
|
+
for collection in ("holes", "compositeHoles", "niches"):
|
|
551
|
+
for source in _items(plan, collection):
|
|
552
|
+
entity_id = f"O{len(openings) + 1}"
|
|
553
|
+
bind(entity_id, collection, source)
|
|
554
|
+
source_parts = _items(source, "holeSegments") if collection == "compositeHoles" else [source]
|
|
555
|
+
source_type = "NICHE" if collection == "niches" else source.get("type")
|
|
556
|
+
if collection == "holes" and source_type is None:
|
|
557
|
+
raise ValueError("ordinary FPI hole requires type")
|
|
558
|
+
if collection != "niches":
|
|
559
|
+
_opening_kind(source)
|
|
560
|
+
openings.append(
|
|
561
|
+
Opening(
|
|
562
|
+
entity_id,
|
|
563
|
+
source_type,
|
|
564
|
+
tuple(
|
|
565
|
+
OpeningSegment(
|
|
566
|
+
opening_geometry(part),
|
|
567
|
+
host_id(part),
|
|
568
|
+
part.get("openSide"),
|
|
569
|
+
optional_number(part, "outside"),
|
|
570
|
+
)
|
|
571
|
+
for part in source_parts
|
|
572
|
+
),
|
|
573
|
+
optional_number(source, "height"),
|
|
574
|
+
optional_number(source, "sillHeight")
|
|
575
|
+
if source.get("sillHeight") is not None
|
|
576
|
+
else optional_number(source, "groundClearance")
|
|
577
|
+
if collection == "holes"
|
|
578
|
+
else None,
|
|
579
|
+
optional_number(source, "depth") if collection != "compositeHoles" else None,
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
if collection == "compositeHoles":
|
|
583
|
+
bindings[entity_id]["segmentAttributes"] = [
|
|
584
|
+
{k: deepcopy(v) for k, v in segment.items() if k not in GEOMETRY_FIELDS}
|
|
585
|
+
for segment in source_parts
|
|
586
|
+
]
|
|
587
|
+
rooms: list[Room] = []
|
|
588
|
+
additional_room_labels: list[JsonObject] = []
|
|
589
|
+
issues: list[Issue] = []
|
|
590
|
+
for index, source in enumerate(source_rooms, 1):
|
|
591
|
+
entity_id = f"R{index}"
|
|
592
|
+
bind(entity_id, "rooms", source)
|
|
593
|
+
room_key = f"room:{source['id']}"
|
|
594
|
+
room_data = graph_rooms.get(room_key)
|
|
595
|
+
if room_data is None:
|
|
596
|
+
raise ValueError(f"withGraph did not return an outline for {room_key}")
|
|
597
|
+
rooms.append(
|
|
598
|
+
Room(
|
|
599
|
+
entity_id,
|
|
600
|
+
_room_type(source),
|
|
601
|
+
_xy(source["position"]),
|
|
602
|
+
PolygonGeometry((cast(tuple[tuple[Point, ...], ...], room_data["outline"]),)),
|
|
603
|
+
str(source.get("name") or "").strip() or None,
|
|
604
|
+
cast(float | None, room_data.get("areaM2")),
|
|
605
|
+
source.get("roomTypeId"),
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
bindings[entity_id]["geometryMethod"] = "withGraph"
|
|
609
|
+
bindings[entity_id]["geometryConfidence"] = room_data.get("confidence")
|
|
610
|
+
buildings: list[Building] = []
|
|
611
|
+
for collection in sorted(BUILDING_COLLECTIONS):
|
|
612
|
+
for source in _items(plan, collection):
|
|
613
|
+
entity_id = f"B{len(buildings) + 1}"
|
|
614
|
+
bind(entity_id, collection, source)
|
|
615
|
+
parameters = None
|
|
616
|
+
if collection == "bayWindowSills" and any(
|
|
617
|
+
source.get(key) is not None for key in BAY_WINDOW_FIELDS
|
|
618
|
+
):
|
|
619
|
+
parameters = BayWindowParameters(
|
|
620
|
+
beamThickness=optional_number(source, "beamThickness"),
|
|
621
|
+
upperFloorThickness=optional_number(source, "upperFloorThickness"),
|
|
622
|
+
lowerFloorThickness=optional_number(source, "lowerFloorThickness"),
|
|
623
|
+
wallThickness=optional_number(source, "wallThickness"),
|
|
624
|
+
offset=optional_number(source, "offset"),
|
|
625
|
+
windowHeight=optional_number(source, "windowHeight"),
|
|
626
|
+
platformType=source.get("platformType"),
|
|
627
|
+
)
|
|
628
|
+
buildings.append(
|
|
629
|
+
Building(
|
|
630
|
+
entity_id,
|
|
631
|
+
BUILDING_TYPES[collection],
|
|
632
|
+
building_geometry(collection, source),
|
|
633
|
+
height=optional_number(source, "height")
|
|
634
|
+
if collection in {"beams", "pipeWalls", "placeHolders"}
|
|
635
|
+
else None,
|
|
636
|
+
bottomElevation=optional_number(source, "sillHeight")
|
|
637
|
+
if collection == "placeHolders"
|
|
638
|
+
else None,
|
|
639
|
+
thickness=optional_number(source, "thickness") if collection == "beams" else None,
|
|
640
|
+
direction=_xy(source["direction"])
|
|
641
|
+
if collection == "placeHolders" and source.get("direction") is not None
|
|
642
|
+
else None,
|
|
643
|
+
name=source.get("name") if collection == "placeHolders" else None,
|
|
644
|
+
bayWindow=parameters,
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
zones: list[Zone] = []
|
|
648
|
+
for index, source in enumerate(_items(plan, "zones"), 1):
|
|
649
|
+
entity_id = f"Z{index}"
|
|
650
|
+
bind(entity_id, "zones", source)
|
|
651
|
+
zones.append(
|
|
652
|
+
Zone(entity_id, source.get("name"), polygon_geometry(source_points(source.get("polygon"))))
|
|
653
|
+
)
|
|
654
|
+
if legend is not None and not isinstance(legend.get("items"), list):
|
|
655
|
+
raise ValueError("provided Legend must contain an items array; null is not an observed empty list")
|
|
656
|
+
items = _items(legend, "items") if legend is not None else []
|
|
657
|
+
matrix = None
|
|
658
|
+
if items and legend is not None and legend.get("coordinateSystem") not in {None, "fpi_mm"}:
|
|
659
|
+
raise ValueError("unsupported Legend coordinateSystem")
|
|
660
|
+
excluded: list[JsonObject] = []
|
|
661
|
+
furniture: list[Furniture] = []
|
|
662
|
+
furniture_count = 0
|
|
663
|
+
for source in items:
|
|
664
|
+
entity_id = f"F{furniture_count + 1}"
|
|
665
|
+
bind(entity_id, "legend", source)
|
|
666
|
+
kind = furniture_type(source)
|
|
667
|
+
if kind is None:
|
|
668
|
+
excluded.append(deepcopy(source))
|
|
669
|
+
del bindings[entity_id]
|
|
670
|
+
issues.append(Issue(
|
|
671
|
+
"filtered_furniture_type", None,
|
|
672
|
+
f"Legend {source['id']}: unrecognized furniture type.", "warning",
|
|
673
|
+
))
|
|
674
|
+
continue
|
|
675
|
+
try:
|
|
676
|
+
outline = source.get("outlinePolygon")
|
|
677
|
+
if outline:
|
|
678
|
+
if not isinstance(outline, list) or len(outline) < 3:
|
|
679
|
+
raise ValueError("Legend outlinePolygon must have at least three points")
|
|
680
|
+
for point in outline:
|
|
681
|
+
_xy(point) if isinstance(point, dict) else _point(point)
|
|
682
|
+
placement = _legend_placement(source)
|
|
683
|
+
points, method = _item_polygon_points(source, placement)
|
|
684
|
+
if matrix is None and legend is not None and legend.get("coordinateSystem") != "fpi_mm":
|
|
685
|
+
matrix = _inverse_transform(fpi)
|
|
686
|
+
if matrix is not None:
|
|
687
|
+
points = [_transform_point(matrix, point) for point in points]
|
|
688
|
+
placement = _transform_placement(matrix, placement)
|
|
689
|
+
if method == "rotated_bounding_box" and matrix is None:
|
|
690
|
+
local_points = (
|
|
691
|
+
(-placement.width / 2, -placement.depth / 2),
|
|
692
|
+
(placement.width / 2, -placement.depth / 2),
|
|
693
|
+
(placement.width / 2, placement.depth / 2),
|
|
694
|
+
(-placement.width / 2, placement.depth / 2),
|
|
695
|
+
)
|
|
696
|
+
geometry = polygon_geometry(local_points)
|
|
697
|
+
else:
|
|
698
|
+
geometry = local_furniture_geometry(polygon_geometry(tuple(points)), placement)
|
|
699
|
+
except (KeyError, IndexError, TypeError, ValueError, OverflowError) as exc:
|
|
700
|
+
raise ValueError(f"Legend {source['id']}: invalid footprint") from exc
|
|
701
|
+
bindings[entity_id]["geometryMethod"] = method
|
|
702
|
+
bindings[entity_id]["geometryConfidence"] = (
|
|
703
|
+
"exact" if method == "source_outline_polygon" else "derived"
|
|
704
|
+
)
|
|
705
|
+
bindings[entity_id]["typeMethod"] = "legend_category"
|
|
706
|
+
bindings[entity_id]["typeConfidence"] = "derived"
|
|
707
|
+
path = source.get("path")
|
|
708
|
+
local_path = None
|
|
709
|
+
if path is not None:
|
|
710
|
+
world_path = [_xy(point) if isinstance(point, dict) else _point(point) for point in path]
|
|
711
|
+
if matrix is not None:
|
|
712
|
+
world_path = [_transform_point(matrix, point) for point in world_path]
|
|
713
|
+
local_path = local_furniture_geometry(
|
|
714
|
+
PolygonGeometry(((tuple(world_path),),)), placement,
|
|
715
|
+
).polygons[0][0]
|
|
716
|
+
directions = deepcopy(source.get("upStairDirections"))
|
|
717
|
+
if directions is not None and matrix is not None:
|
|
718
|
+
original_placement = _legend_placement(source)
|
|
719
|
+
x, y = original_placement.center
|
|
720
|
+
denominator = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2]
|
|
721
|
+
determinant = sum(
|
|
722
|
+
matrix[0][i] * (matrix[1][(i + 1) % 3] * matrix[2][(i + 2) % 3]
|
|
723
|
+
- matrix[1][(i + 2) % 3] * matrix[2][(i + 1) % 3])
|
|
724
|
+
for i in range(3)
|
|
725
|
+
)
|
|
726
|
+
mirrored = determinant / denominator ** 3 < 0
|
|
727
|
+
for direction in directions:
|
|
728
|
+
# Legend directions use the same world-angle convention as its bbox.
|
|
729
|
+
direction["rotationAngle"] = _transform_placement(
|
|
730
|
+
matrix, FurniturePlacement(original_placement.center, 1, 1,
|
|
731
|
+
_number(direction["rotationAngle"])),
|
|
732
|
+
).rotation
|
|
733
|
+
if mirrored and direction.get("ascendSide") in {"LEFT", "RIGHT"}:
|
|
734
|
+
direction["ascendSide"] = {"LEFT": "RIGHT", "RIGHT": "LEFT"}[direction["ascendSide"]]
|
|
735
|
+
furniture.append(Furniture(entity_id, kind, placement, geometry,
|
|
736
|
+
shape=source.get("shape"), upStairDirections=directions, path=local_path))
|
|
737
|
+
furniture_count += 1
|
|
738
|
+
scene = scene_from_dict(
|
|
739
|
+
scene_to_dict(
|
|
740
|
+
FloorplanScene(
|
|
741
|
+
scene_id,
|
|
742
|
+
rooms=tuple(rooms),
|
|
743
|
+
walls=tuple(walls),
|
|
744
|
+
separators=tuple(separators),
|
|
745
|
+
openings=tuple(openings),
|
|
746
|
+
buildings=tuple(buildings),
|
|
747
|
+
furniture=tuple(furniture),
|
|
748
|
+
height=optional_number(plan, "height"),
|
|
749
|
+
zones=tuple(zones),
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
supplement = FpiSupplement(
|
|
754
|
+
scene.id,
|
|
755
|
+
frame_id,
|
|
756
|
+
deepcopy({k: v for k, v in plan.items() if k not in FPI_COLLECTIONS and k != "height"}),
|
|
757
|
+
bindings,
|
|
758
|
+
tuple(additional_room_labels),
|
|
759
|
+
tuple(excluded),
|
|
760
|
+
"filtered" if excluded else "provided" if legend is not None else "unavailable",
|
|
761
|
+
tuple(issues),
|
|
762
|
+
)
|
|
763
|
+
return FpiImport(scene, supplement)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def export_fpi(
|
|
767
|
+
scene: FloorplanScene,
|
|
768
|
+
supplement: FpiSupplement | None = None,
|
|
769
|
+
*,
|
|
770
|
+
base_scene: FloorplanScene | None = None,
|
|
771
|
+
diagnostics: list[JsonObject] | None = None,
|
|
772
|
+
mm_per_pixel: float | None = None,
|
|
773
|
+
) -> tuple[JsonObject, JsonObject]:
|
|
774
|
+
"""Translate representable objects; room closure and spatial analysis are separate checks."""
|
|
775
|
+
scene_from_dict(scene_to_dict(scene))
|
|
776
|
+
if mm_per_pixel is not None and (not math.isfinite(mm_per_pixel) or mm_per_pixel <= 0):
|
|
777
|
+
raise ValueError("mm_per_pixel must be finite and positive")
|
|
778
|
+
if diagnostics is None:
|
|
779
|
+
diagnostics = []
|
|
780
|
+
scene = snap_openings_to_hosts(scene, max(1.0, mm_per_pixel or 1.0), diagnostics)
|
|
781
|
+
host_issues = [issue for issue in opening_host_issues(scene) if issue.code != "missing_host"]
|
|
782
|
+
if host_issues:
|
|
783
|
+
raise ValueError("invalid scene geometry: " + ", ".join(issue.code for issue in host_issues))
|
|
784
|
+
bindings: JsonObject = {}
|
|
785
|
+
plan: JsonObject = {}
|
|
786
|
+
if supplement is not None:
|
|
787
|
+
base = base_scene or scene
|
|
788
|
+
if supplement.sceneId != base.id:
|
|
789
|
+
raise ValueError("FPI supplement does not match the base scene")
|
|
790
|
+
bindings = supplement.bindings
|
|
791
|
+
plan = deepcopy(supplement.planAttributes)
|
|
792
|
+
for collection in sorted(FPI_COLLECTIONS):
|
|
793
|
+
plan[collection] = []
|
|
794
|
+
plan.pop("height", None)
|
|
795
|
+
if scene.height is not None:
|
|
796
|
+
plan["height"] = scene.height
|
|
797
|
+
reserved = {str(item["sourceId"]) for item in bindings.values()}
|
|
798
|
+
if supplement is not None:
|
|
799
|
+
reserved.update(str(item["source"]["id"]) for item in supplement.additionalRoomLabels)
|
|
800
|
+
next_id = 1
|
|
801
|
+
ids: dict[str, str | int] = {}
|
|
802
|
+
entities: tuple[SceneEntity, ...] = (
|
|
803
|
+
*scene.walls,
|
|
804
|
+
*scene.separators,
|
|
805
|
+
*scene.rooms,
|
|
806
|
+
*scene.openings,
|
|
807
|
+
*scene.buildings,
|
|
808
|
+
*scene.furniture,
|
|
809
|
+
*scene.zones,
|
|
810
|
+
)
|
|
811
|
+
for entity in entities:
|
|
812
|
+
if entity.id in bindings:
|
|
813
|
+
ids[entity.id] = bindings[entity.id]["sourceId"]
|
|
814
|
+
else:
|
|
815
|
+
while str(next_id) in reserved:
|
|
816
|
+
next_id += 1
|
|
817
|
+
ids[entity.id] = next_id
|
|
818
|
+
reserved.add(str(next_id))
|
|
819
|
+
|
|
820
|
+
def attrs(entity_id: str, collections: set[str]) -> JsonObject:
|
|
821
|
+
binding = bindings.get(entity_id)
|
|
822
|
+
if binding is not None and binding["collection"] not in collections:
|
|
823
|
+
raise ValueError(f"{entity_id} changed object family; use a new id")
|
|
824
|
+
attributes = binding["attributes"] if binding else {}
|
|
825
|
+
return {
|
|
826
|
+
**deepcopy({key: value for key, value in attributes.items() if key not in GEOMETRY_FIELDS}),
|
|
827
|
+
"id": ids[entity_id],
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
def optional_attrs(item: JsonObject, **values: object) -> None:
|
|
831
|
+
for key, value in values.items():
|
|
832
|
+
item.pop(key, None)
|
|
833
|
+
if value is not None:
|
|
834
|
+
item[key] = value
|
|
835
|
+
|
|
836
|
+
def unsupported(item: Building | Furniture, allowed: set[str]) -> None:
|
|
837
|
+
fields = {"height", "bottomElevation", "thickness", "name"} - allowed
|
|
838
|
+
if isinstance(item, Building):
|
|
839
|
+
fields |= {"direction", "bayWindow"} - allowed
|
|
840
|
+
extra = [field for field in sorted(fields) if getattr(item, field) is not None]
|
|
841
|
+
if extra:
|
|
842
|
+
raise ValueError(f"{item.id}: {item.type} has no ordinary FPI/Legend fields for {extra}")
|
|
843
|
+
|
|
844
|
+
for wall in scene.walls:
|
|
845
|
+
item = attrs(wall.id, {"walls"})
|
|
846
|
+
item.pop("type", None)
|
|
847
|
+
item.update(wall_to_fpi(wall.geometry), loadBearingWall=wall.loadBearing)
|
|
848
|
+
optional_attrs(item, thickness=wall.thickness, height=wall.height)
|
|
849
|
+
plan["walls"].append(item)
|
|
850
|
+
for separator in scene.separators:
|
|
851
|
+
item = attrs(separator.id, {"roomSeparators"})
|
|
852
|
+
item.pop("type", None)
|
|
853
|
+
geometry = separator.geometry
|
|
854
|
+
if isinstance(geometry, ArcGeometry):
|
|
855
|
+
warnings.warn(
|
|
856
|
+
f"separator_arc_to_chord: {separator.id}: FPI roomSeparators stores only endpoints; "
|
|
857
|
+
"exporting the arc as a straight chord, losing curvature",
|
|
858
|
+
UserWarning,
|
|
859
|
+
stacklevel=2,
|
|
860
|
+
)
|
|
861
|
+
geometry = LineGeometry(geometry.start, geometry.end)
|
|
862
|
+
item.update(line_to_fpi(geometry))
|
|
863
|
+
plan["roomSeparators"].append(item)
|
|
864
|
+
base_room_types = {room.id: room.type for room in (base_scene or scene).rooms}
|
|
865
|
+
additional_by_room = {
|
|
866
|
+
str(item["roomId"])
|
|
867
|
+
for item in (supplement.additionalRoomLabels if supplement is not None else ())
|
|
868
|
+
}
|
|
869
|
+
unchanged_rooms: set[str] = set()
|
|
870
|
+
for room in scene.rooms:
|
|
871
|
+
item = attrs(room.id, {"rooms"})
|
|
872
|
+
unchanged = room.type == base_room_types.get(room.id)
|
|
873
|
+
preserve_source_labels = unchanged and room.id in additional_by_room
|
|
874
|
+
room_type_id = ROOM_IDS.get(room.type, CUSTOM_ROOM_TYPE_ID)
|
|
875
|
+
if room.roomTypeId is not None:
|
|
876
|
+
item["roomTypeId"] = room.roomTypeId
|
|
877
|
+
elif room.id not in bindings or (
|
|
878
|
+
not preserve_source_labels and _room_type(item) != room.type
|
|
879
|
+
):
|
|
880
|
+
item["roomTypeId"] = room_type_id
|
|
881
|
+
optional_attrs(item, name=room.name)
|
|
882
|
+
item.update(position=point_dict(room.labelPoint))
|
|
883
|
+
plan["rooms"].append(item)
|
|
884
|
+
if unchanged:
|
|
885
|
+
unchanged_rooms.add(room.id)
|
|
886
|
+
if supplement is not None:
|
|
887
|
+
plan["rooms"].extend(
|
|
888
|
+
deepcopy(item["source"])
|
|
889
|
+
for item in supplement.additionalRoomLabels
|
|
890
|
+
if str(item["roomId"]) in unchanged_rooms
|
|
891
|
+
)
|
|
892
|
+
for opening in scene.openings:
|
|
893
|
+
item = attrs(opening.id, {"holes", "compositeHoles", "niches"})
|
|
894
|
+
item.pop("type", None)
|
|
895
|
+
if opening.kind != "niche":
|
|
896
|
+
if opening.type is not None:
|
|
897
|
+
item["type"] = opening.type
|
|
898
|
+
elif opening.kind != "unknown":
|
|
899
|
+
item["type"] = {"door": "DOOR", "window": "WINDOW", "passage": "PASS"}[opening.kind]
|
|
900
|
+
if _opening_kind(item) != opening.kind:
|
|
901
|
+
raise ValueError(f"{opening.id}: opening kind disagrees with its FPI type")
|
|
902
|
+
optional_attrs(item, height=opening.height, sillHeight=opening.bottomElevation)
|
|
903
|
+
segment_attrs = bindings.get(opening.id, {}).get("segmentAttributes", [])
|
|
904
|
+
segments = []
|
|
905
|
+
for index, source_part in enumerate(opening.segments):
|
|
906
|
+
source_attributes = segment_attrs[index] if index < len(segment_attrs) else {}
|
|
907
|
+
part = deepcopy(
|
|
908
|
+
{key: value for key, value in source_attributes.items() if key not in GEOMETRY_FIELDS}
|
|
909
|
+
)
|
|
910
|
+
geometry = source_part.geometry
|
|
911
|
+
host = next((wall for wall in scene.walls if wall.id == source_part.wallId), None)
|
|
912
|
+
host_arc = host.geometry if host is not None and isinstance(host.geometry, ArcGeometry) else None
|
|
913
|
+
if isinstance(geometry, ArcGeometry) and geometry.bulge != 0 and (
|
|
914
|
+
host_arc is None or host_arc.bulge == 0
|
|
915
|
+
):
|
|
916
|
+
raise ValueError(f"{opening.id}: FPI arc opening requires an arc host wall")
|
|
917
|
+
if host_arc is not None and host_arc.bulge != 0:
|
|
918
|
+
restored = arc_subsegment(host_arc, geometry.start, geometry.end)
|
|
919
|
+
if geometry_shape(restored).hausdorff_distance(geometry_shape(geometry)) > GEOMETRY_TOLERANCE_MM:
|
|
920
|
+
raise ValueError(f"{opening.id}: FPI opening must follow its host arc; cannot preserve this curve")
|
|
921
|
+
part.update(start=point_dict(geometry.start), end=point_dict(geometry.end))
|
|
922
|
+
optional_attrs(
|
|
923
|
+
part,
|
|
924
|
+
hostId=ids[source_part.wallId] if source_part.wallId is not None else None,
|
|
925
|
+
openSide=source_part.openSide,
|
|
926
|
+
outside=source_part.outside,
|
|
927
|
+
)
|
|
928
|
+
segments.append(part)
|
|
929
|
+
if opening.kind == "niche":
|
|
930
|
+
if (
|
|
931
|
+
len(segments) != 1
|
|
932
|
+
or opening.segments[0].openSide is not None
|
|
933
|
+
or opening.segments[0].outside is not None
|
|
934
|
+
):
|
|
935
|
+
raise ValueError(f"{opening.id}: niche requires one segment without openSide/outside")
|
|
936
|
+
item.update(segments[0])
|
|
937
|
+
optional_attrs(item, depth=opening.depth)
|
|
938
|
+
plan["niches"].append(item)
|
|
939
|
+
continue
|
|
940
|
+
composite = len(segments) > 1 or any(part.outside is not None for part in opening.segments)
|
|
941
|
+
if composite:
|
|
942
|
+
if not 2 <= len(segments) <= 3:
|
|
943
|
+
raise ValueError(f"{opening.id}: ordinary FPI compositeHole requires two or three segments")
|
|
944
|
+
if opening.depth is not None:
|
|
945
|
+
raise ValueError(f"{opening.id}: compositeHole has no depth field")
|
|
946
|
+
item["holeSegments"] = segments
|
|
947
|
+
else:
|
|
948
|
+
item.update(segments[0])
|
|
949
|
+
optional_attrs(item, depth=opening.depth)
|
|
950
|
+
plan["compositeHoles" if composite else "holes"].append(item)
|
|
951
|
+
for floor_opening in scene.floorOpenings:
|
|
952
|
+
warnings.warn(
|
|
953
|
+
f"floor_opening_omitted: {floor_opening.id}: floor opening is retained in Scene/SVG only; omitted from FPI",
|
|
954
|
+
UserWarning, stacklevel=2,
|
|
955
|
+
)
|
|
956
|
+
def component_geometry(geometry: Geometry, entity_id: str) -> Geometry:
|
|
957
|
+
if isinstance(geometry, PolygonGeometry) and any(len(rings) > 1 for rings in geometry.polygons):
|
|
958
|
+
diagnostics.append({"code": "component_holes_filled", "entityId": entity_id,
|
|
959
|
+
"holeCount": sum(len(rings) - 1 for rings in geometry.polygons)})
|
|
960
|
+
return PolygonGeometry(tuple((rings[0],) for rings in geometry.polygons))
|
|
961
|
+
return geometry
|
|
962
|
+
|
|
963
|
+
for building in scene.buildings:
|
|
964
|
+
if building.type in {"roof", "inspection_hatch"}:
|
|
965
|
+
if building.type == "inspection_hatch" and diagnostics is not None:
|
|
966
|
+
diagnostics.append({
|
|
967
|
+
"code": "inspection_hatch_omitted", "entityId": building.id,
|
|
968
|
+
"sourceType": building.type, "targetType": None,
|
|
969
|
+
"lostFields": ["geometry", "inspectionHatch"],
|
|
970
|
+
})
|
|
971
|
+
warnings.warn(f"{building.type}_omitted: {building.id}: {building.type} is retained in Scene/SVG only; omitted from FPI",
|
|
972
|
+
UserWarning, stacklevel=2)
|
|
973
|
+
continue
|
|
974
|
+
building_collection: str | None
|
|
975
|
+
if building.type == "pillar":
|
|
976
|
+
# FPI splits one Scene semantic type into two geometry-specific collections.
|
|
977
|
+
building_collection = "circlePillars" if isinstance(building.geometry, CircleGeometry) else "pillars"
|
|
978
|
+
else:
|
|
979
|
+
building_collection = next(
|
|
980
|
+
(key for key, kind in BUILDING_TYPES.items() if kind == building.type), None
|
|
981
|
+
)
|
|
982
|
+
if building.type in {"pipe_shaft", "service_shaft", "building_element"}:
|
|
983
|
+
building_collection = "placeHolders"
|
|
984
|
+
fallback = building_collection is None
|
|
985
|
+
if building_collection is None:
|
|
986
|
+
building_collection = "placeHolders"
|
|
987
|
+
lost = ["type"] + [key for key in ("thickness", "bayWindow") if getattr(building, key) is not None]
|
|
988
|
+
diagnostics.append({"code": "building_type_fallback", "entityId": building.id,
|
|
989
|
+
"sourceType": building.type, "targetType": "placeholder", "lostFields": lost,
|
|
990
|
+
"reason": "FPI 无对应类型,保留名称与占地轮廓;原始 Scene/SVG 语义不变"})
|
|
991
|
+
warnings.warn(f"building_type_fallback: {building.id}: {building.type} -> placeholder",
|
|
992
|
+
UserWarning, stacklevel=2)
|
|
993
|
+
item = attrs(building.id, BUILDING_COLLECTIONS)
|
|
994
|
+
binding = bindings.get(building.id)
|
|
995
|
+
if binding is not None and binding["collection"] != building_collection:
|
|
996
|
+
item = {"id": ids[building.id], "name": building.type}
|
|
997
|
+
if building_collection == "bayWindowSills" and not isinstance(building.geometry, BayWindowGeometry):
|
|
998
|
+
building_collection = "placeHolders"
|
|
999
|
+
diagnostics.append({"code": "bay_window_geometry_fallback", "entityId": building.id,
|
|
1000
|
+
"sourceType": building.type, "targetType": "placeholder",
|
|
1001
|
+
"reason": "缺少可靠的飘窗附墙路径或形状,保留占地"})
|
|
1002
|
+
item.update(building_to_fpi(building_collection, component_geometry(building.geometry, building.id)))
|
|
1003
|
+
allowed: set[str] = {"name"}
|
|
1004
|
+
if building.type == "bay_window_sill" and building_collection == "placeHolders":
|
|
1005
|
+
allowed.add("bayWindow") # Original parameters stay in Scene; fallback carries only footprint.
|
|
1006
|
+
if building.name is not None:
|
|
1007
|
+
optional_attrs(item, name=building.name)
|
|
1008
|
+
if building_collection in {"beams", "pipeWalls", "placeHolders"}:
|
|
1009
|
+
allowed.add("height")
|
|
1010
|
+
optional_attrs(item, height=building.height)
|
|
1011
|
+
if building_collection == "beams":
|
|
1012
|
+
allowed.add("thickness")
|
|
1013
|
+
optional_attrs(item, thickness=building.thickness)
|
|
1014
|
+
if building.height is None:
|
|
1015
|
+
beam_defaults = component_defaults()
|
|
1016
|
+
item["height"] = beam_defaults["beam"]["height"]
|
|
1017
|
+
diagnostics.append({"code": "component_defaults_applied", "entityId": building.id,
|
|
1018
|
+
"values": {"height": item["height"]}, "source": beam_defaults["beamSource"]})
|
|
1019
|
+
if building_collection == "placeHolders":
|
|
1020
|
+
allowed.update({"bottomElevation", "direction"})
|
|
1021
|
+
optional_attrs(
|
|
1022
|
+
item,
|
|
1023
|
+
sillHeight=building.bottomElevation,
|
|
1024
|
+
name=building.name if building.name is not None else (
|
|
1025
|
+
building.type if building.type != "placeholder" else None
|
|
1026
|
+
),
|
|
1027
|
+
direction=point_dict(building.direction) if building.direction is not None else None,
|
|
1028
|
+
)
|
|
1029
|
+
if building_collection == "bayWindowSills":
|
|
1030
|
+
allowed.add("bayWindow")
|
|
1031
|
+
if building.bayWindow is not None:
|
|
1032
|
+
optional_attrs(item, **asdict(building.bayWindow))
|
|
1033
|
+
defaults = component_defaults()["bay_window_sill"]
|
|
1034
|
+
applied = {key: value for key, value in defaults.items() if item.get(key) is None}
|
|
1035
|
+
item.update(applied)
|
|
1036
|
+
if applied:
|
|
1037
|
+
diagnostics.append({"code": "component_defaults_applied", "entityId": building.id,
|
|
1038
|
+
"values": applied, "source": component_defaults()["source"]})
|
|
1039
|
+
if fallback:
|
|
1040
|
+
allowed.update({"thickness", "bayWindow"}) # Omitted fields are recorded in the fallback diagnostic.
|
|
1041
|
+
unsupported(building, allowed)
|
|
1042
|
+
plan[building_collection].append(item)
|
|
1043
|
+
legend_items = []
|
|
1044
|
+
|
|
1045
|
+
def furniture_warning(
|
|
1046
|
+
item: Furniture, code: str, lost: list[str], target: str, missing: list[str] | None = None,
|
|
1047
|
+
) -> None:
|
|
1048
|
+
diagnostic: JsonObject = {
|
|
1049
|
+
"code": code, "entityId": item.id, "sourceType": item.type,
|
|
1050
|
+
"targetType": target, "lostFields": lost,
|
|
1051
|
+
}
|
|
1052
|
+
if missing:
|
|
1053
|
+
diagnostic["missingFields"] = missing
|
|
1054
|
+
if diagnostics is not None:
|
|
1055
|
+
diagnostics.append(diagnostic)
|
|
1056
|
+
warnings.warn(f"{code}: {item.id} ({item.type} -> {target}); lostFields={lost}",
|
|
1057
|
+
UserWarning, stacklevel=2)
|
|
1058
|
+
|
|
1059
|
+
for furniture in scene.furniture:
|
|
1060
|
+
item = attrs(furniture.id, {"legend"})
|
|
1061
|
+
known = furniture.type in LEGEND_TYPES or furniture.type in {"sink", "tv_cabinet"}
|
|
1062
|
+
if not known:
|
|
1063
|
+
furniture_warning(furniture, "furniture_type_fallback", ["type"], "OtherAppliance")
|
|
1064
|
+
if (not known or furniture_type(item) != furniture.type
|
|
1065
|
+
or item.get("recommendedType") != legend_type_attributes(furniture.type)["recommendedType"]):
|
|
1066
|
+
for key in (
|
|
1067
|
+
"recommendedType",
|
|
1068
|
+
"subCategory",
|
|
1069
|
+
"typeCode",
|
|
1070
|
+
"confidence",
|
|
1071
|
+
"subCategoryConfidence",
|
|
1072
|
+
"label",
|
|
1073
|
+
):
|
|
1074
|
+
item.pop(key, None)
|
|
1075
|
+
item.update(legend_type_attributes(furniture.type), name=furniture.type)
|
|
1076
|
+
if furniture.name is not None:
|
|
1077
|
+
item["name"] = furniture.name
|
|
1078
|
+
target = str(item["recommendedType"])
|
|
1079
|
+
lost = [field for field in ("height", "bottomElevation", "thickness")
|
|
1080
|
+
if getattr(furniture, field) is not None]
|
|
1081
|
+
if furniture.attributes:
|
|
1082
|
+
lost.append("attributes")
|
|
1083
|
+
for field, supported in (("shape", target in LEGEND_SHAPES),
|
|
1084
|
+
("upStairDirections", target == "Stair"), ("path", target == "Railing")):
|
|
1085
|
+
item.pop(field, None)
|
|
1086
|
+
value = getattr(furniture, field)
|
|
1087
|
+
if value is not None:
|
|
1088
|
+
if supported:
|
|
1089
|
+
item[field] = deepcopy(list(value) if field == "upStairDirections" else value)
|
|
1090
|
+
else:
|
|
1091
|
+
lost.append(field)
|
|
1092
|
+
if target == "Stair" and item.get("upStairDirections") is not None:
|
|
1093
|
+
for direction in item["upStairDirections"]:
|
|
1094
|
+
direction.setdefault("ascendSide", None)
|
|
1095
|
+
if furniture.path is not None and target == "Railing":
|
|
1096
|
+
path_geometry = PolygonGeometry(((furniture.path,),))
|
|
1097
|
+
path_item = Furniture(furniture.id, furniture.type, furniture.placement, path_geometry)
|
|
1098
|
+
item["path"] = [point_dict(point) for point in placed_furniture_geometry(path_item).polygons[0][0]]
|
|
1099
|
+
world_geometry = placed_furniture_geometry(furniture)
|
|
1100
|
+
item.update(
|
|
1101
|
+
bbox={
|
|
1102
|
+
"center": point_dict(furniture.placement.center),
|
|
1103
|
+
"width": furniture.placement.width,
|
|
1104
|
+
"height": furniture.placement.depth,
|
|
1105
|
+
"rotationAngle": furniture.placement.rotation,
|
|
1106
|
+
},
|
|
1107
|
+
outlinePolygon=polygon_to_fpi(component_geometry(world_geometry, furniture.id)),
|
|
1108
|
+
)
|
|
1109
|
+
item, defaults = complete_legend_fields(item)
|
|
1110
|
+
missing = legend_required_fields_missing(item)
|
|
1111
|
+
require_stair_direction(item, missing)
|
|
1112
|
+
if target not in LEGEND_TYPES_SUPPORTED or missing:
|
|
1113
|
+
discarded = [field for field in ("shape", "upStairDirections", "path") if field in item]
|
|
1114
|
+
for field in discarded:
|
|
1115
|
+
item.pop(field)
|
|
1116
|
+
item.update(recommendedType="OtherAppliance", subCategory="NA")
|
|
1117
|
+
furniture_warning(furniture, "furniture_required_fields_fallback" if missing else "furniture_type_fallback",
|
|
1118
|
+
list(dict.fromkeys(["type", *discarded, *lost])), "OtherAppliance", missing)
|
|
1119
|
+
elif lost:
|
|
1120
|
+
furniture_warning(furniture, "furniture_fields_omitted", lost, target)
|
|
1121
|
+
if item["recommendedType"] == target and defaults:
|
|
1122
|
+
if diagnostics is not None:
|
|
1123
|
+
diagnostics.extend({**entry, "entityId": furniture.id, "sourceType": furniture.type}
|
|
1124
|
+
for entry in defaults)
|
|
1125
|
+
warnings.warn(f"legend_fields_defaulted: {furniture.id}; "
|
|
1126
|
+
f"fields={[entry['field'] for entry in defaults]}", UserWarning, stacklevel=2)
|
|
1127
|
+
legend_items.append(item)
|
|
1128
|
+
for zone in scene.zones:
|
|
1129
|
+
item = attrs(zone.id, {"zones"})
|
|
1130
|
+
optional_attrs(item, name=zone.name)
|
|
1131
|
+
item["polygon"] = polygon_to_fpi(zone.geometry)
|
|
1132
|
+
plan["zones"].append(item)
|
|
1133
|
+
return {"floorPlan": plan}, {"coordinateSystem": "fpi_mm", "items": legend_items}
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def supplement_from_dict(value: JsonObject) -> FpiSupplement:
|
|
1137
|
+
data = deepcopy(value)
|
|
1138
|
+
data["issues"] = tuple(Issue(**item) for item in data["issues"])
|
|
1139
|
+
data["additionalRoomLabels"] = tuple(data["additionalRoomLabels"])
|
|
1140
|
+
data["excludedLegend"] = tuple(data["excludedLegend"])
|
|
1141
|
+
return FpiSupplement(**data)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def supplement_to_dict(value: FpiSupplement) -> JsonObject:
|
|
1145
|
+
return asdict(value)
|