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