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/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)