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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Exact ordinary FPI geometry translation; curve sampling stays in analysis/rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Literal, cast
|
|
7
|
+
|
|
8
|
+
from .model import JsonObject, _number
|
|
9
|
+
from .shapes import (
|
|
10
|
+
ArcGeometry,
|
|
11
|
+
BayWindowGeometry,
|
|
12
|
+
CircleGeometry,
|
|
13
|
+
Curve,
|
|
14
|
+
CurvedFootprint,
|
|
15
|
+
Geometry,
|
|
16
|
+
LineGeometry,
|
|
17
|
+
PathGeometry,
|
|
18
|
+
PipeGeometry,
|
|
19
|
+
Point,
|
|
20
|
+
PolygonGeometry,
|
|
21
|
+
QuadraticGeometry,
|
|
22
|
+
Ring,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def required(source: JsonObject, key: str) -> Any:
|
|
27
|
+
if key not in source or source[key] is None:
|
|
28
|
+
raise ValueError(f"FPI geometry requires {key}")
|
|
29
|
+
return source[key]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def source_point(value: JsonObject) -> Point:
|
|
33
|
+
if not isinstance(value, dict):
|
|
34
|
+
raise TypeError("FPI point must be an x/y object")
|
|
35
|
+
return _number(required(value, "x")), _number(required(value, "y"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def point_dict(point: Point) -> JsonObject:
|
|
39
|
+
return {"x": point[0], "y": point[1]}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def optional_number(source: JsonObject, key: str) -> float | None:
|
|
43
|
+
value = source.get(key)
|
|
44
|
+
return None if value is None else _number(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def source_points(value: object) -> tuple[Point, ...]:
|
|
48
|
+
if not isinstance(value, list) or any(not isinstance(point, dict) for point in value):
|
|
49
|
+
raise ValueError("FPI points must be an array of x/y objects")
|
|
50
|
+
return tuple(source_point(point) for point in value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def closed_ring(points: tuple[Point, ...]) -> Ring:
|
|
54
|
+
if len(points) < 3:
|
|
55
|
+
raise ValueError("FPI polygon needs at least three vertices")
|
|
56
|
+
return points if points[0] == points[-1] else (*points, points[0])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def polygon_geometry(points: tuple[Point, ...]) -> PolygonGeometry:
|
|
60
|
+
return PolygonGeometry(((closed_ring(points),),))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def line_geometry(source: JsonObject) -> LineGeometry:
|
|
64
|
+
return LineGeometry(source_point(required(source, "start")), source_point(required(source, "end")))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def curve_geometry(source: JsonObject) -> Curve:
|
|
68
|
+
kind = source.get("type") or "LINE"
|
|
69
|
+
line = line_geometry(source)
|
|
70
|
+
if kind == "LINE":
|
|
71
|
+
return line
|
|
72
|
+
if kind == "ARC":
|
|
73
|
+
return ArcGeometry(line.start, line.end, _number(required(source, "bulge")))
|
|
74
|
+
if kind == "QUADRATIC_BEZIER_CURVE":
|
|
75
|
+
return QuadraticGeometry(line.start, source_point(required(source, "control")), line.end)
|
|
76
|
+
raise ValueError(f"unsupported FPI curve type: {kind}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reverse_curve(curve: Curve) -> Curve:
|
|
80
|
+
if isinstance(curve, ArcGeometry):
|
|
81
|
+
return ArcGeometry(curve.end, curve.start, -curve.bulge)
|
|
82
|
+
if isinstance(curve, QuadraticGeometry):
|
|
83
|
+
return QuadraticGeometry(curve.end, curve.control, curve.start)
|
|
84
|
+
return LineGeometry(curve.end, curve.start)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _closed_paths(edges: list[JsonObject]) -> CurvedFootprint:
|
|
88
|
+
curves = tuple(curve_geometry(edge) for edge in edges)
|
|
89
|
+
adjacency: dict[Point, list[int]] = {}
|
|
90
|
+
for index, curve in enumerate(curves):
|
|
91
|
+
adjacency.setdefault(curve.start, []).append(index)
|
|
92
|
+
adjacency.setdefault(curve.end, []).append(index)
|
|
93
|
+
remaining = set(range(len(curves)))
|
|
94
|
+
rings: list[tuple[Curve, ...]] = []
|
|
95
|
+
while remaining:
|
|
96
|
+
vertices = [curves[min(remaining)].start]
|
|
97
|
+
path: list[Curve] = []
|
|
98
|
+
while True:
|
|
99
|
+
vertex = vertices[-1]
|
|
100
|
+
candidate = next((item for item in adjacency[vertex] if item in remaining), None)
|
|
101
|
+
if candidate is None:
|
|
102
|
+
if path:
|
|
103
|
+
raise ValueError("SHAPED edges must form closed connected rings")
|
|
104
|
+
break
|
|
105
|
+
remaining.remove(candidate)
|
|
106
|
+
curve = curves[candidate]
|
|
107
|
+
if curve.start != vertex:
|
|
108
|
+
curve = _reverse_curve(curve)
|
|
109
|
+
path.append(curve)
|
|
110
|
+
# Split a loop as soon as an exact endpoint repeats, including loops sharing a vertex.
|
|
111
|
+
if curve.end in vertices:
|
|
112
|
+
start = vertices.index(curve.end)
|
|
113
|
+
rings.append(tuple(path[start:]))
|
|
114
|
+
path = path[:start]
|
|
115
|
+
vertices = vertices[: start + 1]
|
|
116
|
+
else:
|
|
117
|
+
vertices.append(curve.end)
|
|
118
|
+
return tuple((ring,) for ring in rings)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def wall_geometry(source: JsonObject) -> Geometry:
|
|
122
|
+
if source.get("type") != "SHAPED":
|
|
123
|
+
return curve_geometry(source)
|
|
124
|
+
edges = source.get("edges")
|
|
125
|
+
if edges:
|
|
126
|
+
if not isinstance(edges, list) or any(not isinstance(edge, dict) for edge in edges):
|
|
127
|
+
raise ValueError("SHAPED edges must be an object array")
|
|
128
|
+
return PathGeometry(_closed_paths(edges))
|
|
129
|
+
return polygon_geometry(source_points(source.get("polygon")))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def building_geometry(collection: str, source: JsonObject) -> Geometry:
|
|
133
|
+
if collection == "circlePillars":
|
|
134
|
+
return CircleGeometry(
|
|
135
|
+
source_point(required(source, "position")), _number(required(source, "diameter")) / 2
|
|
136
|
+
)
|
|
137
|
+
if collection == "beams":
|
|
138
|
+
return line_geometry(source)
|
|
139
|
+
if collection == "pipeWalls":
|
|
140
|
+
thickness = source.get("wallThickness")
|
|
141
|
+
if not isinstance(thickness, list):
|
|
142
|
+
raise ValueError("pipeWall.wallThickness must be an array")
|
|
143
|
+
return PipeGeometry(
|
|
144
|
+
source_point(required(source, "center")),
|
|
145
|
+
_number(required(source, "width")),
|
|
146
|
+
_number(required(source, "depth")),
|
|
147
|
+
math.degrees(_number(required(source, "angle"))),
|
|
148
|
+
cast(Literal["L", "U", "RECT"], required(source, "shape")),
|
|
149
|
+
tuple(_number(value) for value in thickness),
|
|
150
|
+
)
|
|
151
|
+
if collection == "bayWindowSills":
|
|
152
|
+
return BayWindowGeometry(
|
|
153
|
+
closed_ring(source_points(source.get("polygon"))),
|
|
154
|
+
source_points(source.get("wallPolygon")),
|
|
155
|
+
required(source, "shape"),
|
|
156
|
+
)
|
|
157
|
+
return polygon_geometry(source_points(source.get("polygon")))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def polygon_to_fpi(geometry: Geometry) -> list[JsonObject]:
|
|
161
|
+
if not isinstance(geometry, PolygonGeometry):
|
|
162
|
+
raise TypeError("FPI polygon objects require polygon geometry; no curve flattening was applied")
|
|
163
|
+
if len(geometry.polygons) != 1 or len(geometry.polygons[0]) != 1:
|
|
164
|
+
raise ValueError("ordinary FPI polygon cannot represent disconnected or holed geometry")
|
|
165
|
+
return [point_dict(point) for point in geometry.polygons[0][0][:-1]]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def line_to_fpi(geometry: Geometry) -> JsonObject:
|
|
169
|
+
if not isinstance(geometry, LineGeometry):
|
|
170
|
+
raise TypeError("ordinary FPI object requires a straight segment")
|
|
171
|
+
return {"start": point_dict(geometry.start), "end": point_dict(geometry.end)}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def curve_to_fpi(geometry: Curve) -> JsonObject:
|
|
175
|
+
result: JsonObject = {"start": point_dict(geometry.start), "end": point_dict(geometry.end)}
|
|
176
|
+
if isinstance(geometry, ArcGeometry):
|
|
177
|
+
result.update(type="ARC", bulge=geometry.bulge)
|
|
178
|
+
elif isinstance(geometry, QuadraticGeometry):
|
|
179
|
+
result.update(type="QUADRATIC_BEZIER_CURVE", control=point_dict(geometry.control))
|
|
180
|
+
else:
|
|
181
|
+
result["type"] = "LINE"
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def wall_to_fpi(geometry: Geometry) -> JsonObject:
|
|
186
|
+
if isinstance(geometry, (LineGeometry, ArcGeometry, QuadraticGeometry)):
|
|
187
|
+
return curve_to_fpi(geometry)
|
|
188
|
+
if isinstance(geometry, PolygonGeometry):
|
|
189
|
+
if len(geometry.polygons) == 1:
|
|
190
|
+
return {"type": "SHAPED", "polygon": polygon_to_fpi(geometry)}
|
|
191
|
+
if any(len(polygon) != 1 for polygon in geometry.polygons):
|
|
192
|
+
raise ValueError("ordinary FPI SHAPED wall cannot represent holed geometry")
|
|
193
|
+
return {
|
|
194
|
+
"type": "SHAPED",
|
|
195
|
+
"edges": [
|
|
196
|
+
curve_to_fpi(LineGeometry(start, end))
|
|
197
|
+
for polygon in geometry.polygons
|
|
198
|
+
for start, end in zip(polygon[0][:-1], polygon[0][1:], strict=True)
|
|
199
|
+
],
|
|
200
|
+
}
|
|
201
|
+
if isinstance(geometry, PathGeometry):
|
|
202
|
+
if any(len(polygon) != 1 for polygon in geometry.polygons):
|
|
203
|
+
raise ValueError("ordinary FPI SHAPED wall cannot represent holed geometry")
|
|
204
|
+
return {
|
|
205
|
+
"type": "SHAPED",
|
|
206
|
+
"edges": [curve_to_fpi(curve) for polygon in geometry.polygons for curve in polygon[0]],
|
|
207
|
+
}
|
|
208
|
+
raise ValueError(f"ordinary FPI wall cannot represent {geometry.type} geometry")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def building_to_fpi(collection: str, geometry: Geometry) -> JsonObject:
|
|
212
|
+
if collection == "circlePillars":
|
|
213
|
+
if not isinstance(geometry, CircleGeometry):
|
|
214
|
+
raise ValueError("circlePillar requires circle geometry")
|
|
215
|
+
return {"position": point_dict(geometry.center), "diameter": geometry.radius * 2}
|
|
216
|
+
if collection == "beams":
|
|
217
|
+
return line_to_fpi(geometry)
|
|
218
|
+
if collection == "pipeWalls":
|
|
219
|
+
if not isinstance(geometry, PipeGeometry):
|
|
220
|
+
raise ValueError("pipeWall requires pipe geometry")
|
|
221
|
+
return {
|
|
222
|
+
"center": point_dict(geometry.center),
|
|
223
|
+
"width": geometry.width,
|
|
224
|
+
"depth": geometry.depth,
|
|
225
|
+
"angle": math.radians(geometry.angle),
|
|
226
|
+
"shape": geometry.profile,
|
|
227
|
+
"wallThickness": list(geometry.wallThickness),
|
|
228
|
+
}
|
|
229
|
+
if collection == "bayWindowSills":
|
|
230
|
+
if not isinstance(geometry, BayWindowGeometry):
|
|
231
|
+
raise ValueError("bayWindowSill requires bay_window geometry")
|
|
232
|
+
return {
|
|
233
|
+
"shape": geometry.profile,
|
|
234
|
+
"polygon": [point_dict(point) for point in geometry.outline[:-1]],
|
|
235
|
+
"wallPolygon": [point_dict(point) for point in geometry.wallPath],
|
|
236
|
+
}
|
|
237
|
+
return {"polygon": polygon_to_fpi(geometry)}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Known Legend furniture types shared with the image skill vocabulary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from itertools import pairwise
|
|
8
|
+
|
|
9
|
+
from .model import JsonObject
|
|
10
|
+
|
|
11
|
+
# Stable target vocabulary; unknown Scene types use the existing OtherAppliance fallback.
|
|
12
|
+
LEGEND_TYPES: dict[str, tuple[str, str | None]] = {
|
|
13
|
+
"bed": ("Bed", None),
|
|
14
|
+
"single_bed": ("Bed", "SINGLE_BED"),
|
|
15
|
+
"double_bed": ("Bed", "DOUBLE_BED"),
|
|
16
|
+
"sofa": ("Sofa", None),
|
|
17
|
+
"single_sofa": ("Sofa", "SINGLE_SOFA"),
|
|
18
|
+
"double_sofa": ("Sofa", "DOUBLE_SOFA"),
|
|
19
|
+
"multi_seat_sofa": ("Sofa", "MULTI_SEAT_SOFA"),
|
|
20
|
+
"chaise_sofa": ("Sofa", "CHAISE_SOFA"),
|
|
21
|
+
"bedside_table": ("BedsideTable", "BEDSIDE_TABLE"),
|
|
22
|
+
"coffee_table": ("CoffeeTableGroup", "COFFEE_TABLE"),
|
|
23
|
+
"side_table": ("CoffeeTableGroup", "SIDE_TABLE"),
|
|
24
|
+
# These observed table functions have no dedicated legend_doc subtype.
|
|
25
|
+
# Keep the family and let the existing audited subtype default apply.
|
|
26
|
+
"conference_table": ("TableGroup", None),
|
|
27
|
+
"meeting_table": ("TableGroup", None),
|
|
28
|
+
"mahjong_table": ("TableGroup", None),
|
|
29
|
+
"table": ("TableGroup", None),
|
|
30
|
+
"dining_table": ("TableGroup", "DINING_TABLE"),
|
|
31
|
+
"desk": ("TableGroup", "DESK"),
|
|
32
|
+
"dressing_table": ("TableGroup", "DRESSING_TABLE"),
|
|
33
|
+
"cabinet": ("CabinetShelf", None),
|
|
34
|
+
"wardrobe": ("CabinetShelf", "WARDROBE"),
|
|
35
|
+
"bookcase": ("CabinetShelf", "BOOKCASE"),
|
|
36
|
+
"shelf": ("CabinetShelf", "SHELF"),
|
|
37
|
+
"entry_cabinet": ("CabinetShelf", "ENTRY_CABINET"),
|
|
38
|
+
"shoe_storage": ("CabinetShelf", "SHOE_STORAGE"),
|
|
39
|
+
"storage_cabinet": ("CabinetShelf", "STORAGE_CABINET"),
|
|
40
|
+
"balcony_cabinet": ("CabinetShelf", "BALCONY_CABINET"),
|
|
41
|
+
"drawer_cabinet": ("CabinetShelf", "DRAWER_CABINET"),
|
|
42
|
+
"sideboard": ("CabinetShelf", "SIDEBOARD"),
|
|
43
|
+
"tv_stand_set": ("CabinetShelf", "TV_STAND_SET"),
|
|
44
|
+
"bunk_bed": ("Bed", "BUNK_BED"),
|
|
45
|
+
"baby_cot": ("Bed", "BABY_COT"),
|
|
46
|
+
"tatami": ("Bed", "TATAMI"),
|
|
47
|
+
"bar": ("Bar", "BAR"),
|
|
48
|
+
"countertop": ("Countertop", "COUNTERTOP"),
|
|
49
|
+
"kitchen_cabinet": ("KitchenCabinet", None),
|
|
50
|
+
"kitchen_base_cabinet": ("KitchenBaseCabinet", None),
|
|
51
|
+
"kitchen_tall_cabinet": ("KitchenTallCabinet", "KITCHEN_TALL_CABINET"),
|
|
52
|
+
"kitchen_wall_cabinet": ("KitchenWallCabinet", "KITCHEN_WALL_CABINET"),
|
|
53
|
+
"kitchen_island": ("KitchenIsland", "KITCHEN_ISLAND"),
|
|
54
|
+
"bathroom_cabinet": ("BathroomCabinet", "BATHROOM_CABINET"),
|
|
55
|
+
"wash_basin": ("WashBasin", None),
|
|
56
|
+
"basin": ("Basin", "BASIN"),
|
|
57
|
+
"chair": ("ChairStool", "CHAIR"),
|
|
58
|
+
"stool": ("ChairStool", "STOOL"),
|
|
59
|
+
"counter_chair": ("ChairStool", "COUNTER_CHAIR"),
|
|
60
|
+
"dining_chair_bench": ("ChairStool", "DINING_CHAIR_BENCH"),
|
|
61
|
+
"toilet": ("Toilet", "TOILET"),
|
|
62
|
+
"bathtub": ("Bathtub", "BATHTUB"),
|
|
63
|
+
"shower": ("Shower", "SHOWER"),
|
|
64
|
+
"shower_room": ("ShowerRoom", "SHOWER_ROOM"),
|
|
65
|
+
"shower_partition": ("ShowerPartition", "SHOWER_PARTITION"),
|
|
66
|
+
"kitchen_sink": ("KitchenSink", "KITCHEN_SINK"),
|
|
67
|
+
"stove": ("Stove", "STOVE"),
|
|
68
|
+
"range_hood": ("RangeHood", "RANGE_HOOD"),
|
|
69
|
+
"mirror_cabinet": ("MirrorCabinet", "MIRROR_CABINET"),
|
|
70
|
+
"refrigerator": ("Refrigerator", "REFRIGERATOR"),
|
|
71
|
+
"washing_machine": ("WashingMachine", "WASHING_MACHINE"),
|
|
72
|
+
"tv": ("Tv", "TV"),
|
|
73
|
+
"stair": ("Stair", "STAIR"),
|
|
74
|
+
"steps": ("Stair", "STEPS"),
|
|
75
|
+
"ramp": ("Stair", "RAMP"),
|
|
76
|
+
"railing": ("Railing", "RAILING"),
|
|
77
|
+
"fireplace": ("Fireplace", "FIREPLACE"),
|
|
78
|
+
"squat_toilet": ("Toilet", "SQUAT_TOILET"),
|
|
79
|
+
"urinal": ("Toilet", "URINAL"),
|
|
80
|
+
"floor_lamp": ("FloorLamp", "FLOOR_LAMP"),
|
|
81
|
+
"wall_ac": ("AirConditioner", "WALL_AC"),
|
|
82
|
+
"floor_ac": ("AirConditioner", "FLOOR_AC"),
|
|
83
|
+
"carpet": ("Carpet", "CARPET"),
|
|
84
|
+
"curtain": ("Curtain", "CURTAIN"),
|
|
85
|
+
"plant": ("Plant", "PLANT"),
|
|
86
|
+
"piano": ("Piano", "PIANO"),
|
|
87
|
+
"car": ("Car", "CAR"),
|
|
88
|
+
"other_appliance": ("OtherAppliance", "NA"),
|
|
89
|
+
}
|
|
90
|
+
TYPE_BY_LEGEND = {value: key for key, value in LEGEND_TYPES.items()}
|
|
91
|
+
# Keep reading historical parent-only Legend items without inventing a subtype.
|
|
92
|
+
for _kind, (_category, _subcategory) in LEGEND_TYPES.items():
|
|
93
|
+
TYPE_BY_LEGEND.setdefault((_category, None), _kind)
|
|
94
|
+
TYPE_BY_LEGEND[("ChairStool", None)] = "chair"
|
|
95
|
+
TYPE_BY_LEGEND[("Basin", None)] = "basin"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def furniture_type(source: JsonObject) -> str | None:
|
|
99
|
+
category, subcategory = source.get("recommendedType"), source.get("subCategory")
|
|
100
|
+
if not isinstance(category, str) or (subcategory is not None and not isinstance(subcategory, str)):
|
|
101
|
+
return None
|
|
102
|
+
if category == "Sofa" and not subcategory:
|
|
103
|
+
description = " ".join(str(source.get(key) or "").upper() for key in ("subCategory", "name", "label"))
|
|
104
|
+
if any(
|
|
105
|
+
marker in description
|
|
106
|
+
for marker in ("SINGLE", "ARMCHAIR", "EASY_CHAIR", "LOUNGE_CHAIR", "单人沙发", "单人位沙发")
|
|
107
|
+
):
|
|
108
|
+
return "single_sofa"
|
|
109
|
+
known = TYPE_BY_LEGEND.get((category, subcategory))
|
|
110
|
+
if known is not None:
|
|
111
|
+
return known
|
|
112
|
+
label = subcategory or category
|
|
113
|
+
# Preserve explicit subtypes and new families instead of falling back to a parent.
|
|
114
|
+
label = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", label.strip())
|
|
115
|
+
label = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", label)
|
|
116
|
+
return re.sub(r"[\s-]+", "_", label).lower() or None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def legend_type_attributes(kind: str) -> JsonObject:
|
|
120
|
+
kind = {"sink": "kitchen_sink", "tv_cabinet": "tv_stand_set"}.get(kind, kind)
|
|
121
|
+
category, subcategory = LEGEND_TYPES.get(kind, LEGEND_TYPES["other_appliance"])
|
|
122
|
+
attributes: JsonObject = {"recommendedType": category}
|
|
123
|
+
if subcategory is not None:
|
|
124
|
+
attributes["subCategory"] = subcategory
|
|
125
|
+
return attributes
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Capabilities of the pinned legend_doc contract; not restrictions on Scene/model output.
|
|
129
|
+
LEGEND_OUTLINE_TYPES = {"CabinetShelf", "KitchenWallCabinet", "KitchenTallCabinet", "Countertop", "Stair", "Railing"}
|
|
130
|
+
# Ordered exactly as legend_doc/Classification.puml at the pinned commit.
|
|
131
|
+
LEGEND_CLASSIFICATION_SOURCE = "legend_doc@8813f642680f36a5864dd8420ce3e2050f8c59fe/Classification.puml"
|
|
132
|
+
LEGEND_SUBCATEGORIES: dict[str, tuple[str, ...]] = {
|
|
133
|
+
"Sofa": ("SINGLE_SOFA", "DOUBLE_SOFA", "MULTI_SEAT_SOFA", "CHAISE_SOFA"),
|
|
134
|
+
"CoffeeTableGroup": ("COFFEE_TABLE", "SIDE_TABLE"),
|
|
135
|
+
"CabinetShelf": (
|
|
136
|
+
"STORAGE_CABINET", "DRAWER_CABINET", "SHELF", "BOOKCASE", "TV_STAND_SET", "SIDEBOARD",
|
|
137
|
+
"ENTRY_CABINET", "SHOE_STORAGE", "BALCONY_CABINET", "WARDROBE",
|
|
138
|
+
),
|
|
139
|
+
"BedsideTable": ("BEDSIDE_TABLE",),
|
|
140
|
+
"TableGroup": ("DESK", "DINING_TABLE", "DRESSING_TABLE"),
|
|
141
|
+
"ChairStool": ("CHAIR", "STOOL", "COUNTER_CHAIR", "DINING_CHAIR_BENCH"),
|
|
142
|
+
"Bed": ("SINGLE_BED", "DOUBLE_BED", "BUNK_BED", "BABY_COT", "TATAMI"),
|
|
143
|
+
"Bar": ("BAR",),
|
|
144
|
+
"KitchenIsland": ("KITCHEN_ISLAND",),
|
|
145
|
+
"KitchenWallCabinet": ("KITCHEN_WALL_CABINET",),
|
|
146
|
+
"KitchenTallCabinet": ("KITCHEN_TALL_CABINET",),
|
|
147
|
+
"Countertop": ("COUNTERTOP",),
|
|
148
|
+
"KitchenSink": ("KITCHEN_SINK",),
|
|
149
|
+
"Stove": ("STOVE",),
|
|
150
|
+
"RangeHood": ("RANGE_HOOD",),
|
|
151
|
+
"BathroomCabinet": ("BATHROOM_CABINET",),
|
|
152
|
+
"MirrorCabinet": ("MIRROR_CABINET",),
|
|
153
|
+
"Basin": ("BASIN",),
|
|
154
|
+
"Shower": ("SHOWER",),
|
|
155
|
+
"Bathtub": ("BATHTUB",),
|
|
156
|
+
"ShowerRoom": ("SHOWER_ROOM",),
|
|
157
|
+
"ShowerPartition": ("SHOWER_PARTITION",),
|
|
158
|
+
"Stair": ("STAIR", "STEPS", "RAMP"),
|
|
159
|
+
"Railing": ("RAILING",),
|
|
160
|
+
"Fireplace": ("FIREPLACE",),
|
|
161
|
+
"Toilet": ("TOILET", "SQUAT_TOILET", "URINAL"),
|
|
162
|
+
"FloorLamp": ("FLOOR_LAMP",),
|
|
163
|
+
"Tv": ("TV",),
|
|
164
|
+
"Refrigerator": ("REFRIGERATOR",),
|
|
165
|
+
"WashingMachine": ("WASHING_MACHINE",),
|
|
166
|
+
"AirConditioner": ("WALL_AC", "FLOOR_AC"),
|
|
167
|
+
"Carpet": ("CARPET",),
|
|
168
|
+
"Curtain": ("CURTAIN",),
|
|
169
|
+
"Plant": ("PLANT",),
|
|
170
|
+
"Piano": ("PIANO",),
|
|
171
|
+
"Car": ("CAR",),
|
|
172
|
+
"OtherAppliance": ("NA",),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_EXTENDED_SHAPES: tuple[str, ...] = (
|
|
176
|
+
"RECTANGLE", "CIRCLE", "ELLIPSE", "SEMICIRCLE", "IRREGULAR", "ARC", "L_SHAPE",
|
|
177
|
+
"L_SHAPE_LEFT", "L_SHAPE_RIGHT", "U_SHAPE", "U_SHAPE_LEFT", "U_SHAPE_RIGHT",
|
|
178
|
+
)
|
|
179
|
+
_BASIC_SHAPES: tuple[str, ...] = (
|
|
180
|
+
"RECTANGLE", "CIRCLE", "ELLIPSE", "SEMICIRCLE", "IRREGULAR", "LONG_STRIP", "TRIANGLE",
|
|
181
|
+
)
|
|
182
|
+
_LAYOUT_SHAPES: tuple[str, ...] = (
|
|
183
|
+
"LINEAR", "L_SHAPE", "L_SHAPE_LEFT", "L_SHAPE_RIGHT", "U_SHAPE", "U_SHAPE_LEFT", "U_SHAPE_RIGHT",
|
|
184
|
+
)
|
|
185
|
+
_COUNTERTOP_SHAPES: tuple[str, ...] = (
|
|
186
|
+
"LINEAR", "L_SHAPE", "L_SHAPE_LEFT", "L_SHAPE_RIGHT", "U_SHAPE", "U_SHAPE_LEFT", "U_SHAPE_RIGHT", "IRREGULAR",
|
|
187
|
+
)
|
|
188
|
+
_BATH_SHAPES: tuple[str, ...] = (
|
|
189
|
+
"RECTANGLE", "CIRCLE", "ELLIPSE", "IRREGULAR", "FAN_LEFT", "FAN_RIGHT",
|
|
190
|
+
)
|
|
191
|
+
_SHOWER_ROOM_SHAPES: tuple[str, ...] = (
|
|
192
|
+
"RECTANGLE", "FAN_LEFT", "FAN_RIGHT", "DIAMOND_LEFT", "DIAMOND_RIGHT",
|
|
193
|
+
)
|
|
194
|
+
_PARTITION_SHAPES: tuple[str, ...] = (
|
|
195
|
+
"LINEAR",
|
|
196
|
+
)
|
|
197
|
+
_STAIR_SHAPES: tuple[str, ...] = (
|
|
198
|
+
"RECTANGLE", "L_SHAPE", "U_SHAPE", "CIRCLE", "ARC", "IRREGULAR",
|
|
199
|
+
)
|
|
200
|
+
_RAILING_SHAPES: tuple[str, ...] = (
|
|
201
|
+
"RECTANGLE", "ARC", "IRREGULAR",
|
|
202
|
+
)
|
|
203
|
+
_IRREGULAR_SHAPES: tuple[str, ...] = (
|
|
204
|
+
"IRREGULAR",
|
|
205
|
+
)
|
|
206
|
+
LEGEND_SHAPES: dict[str, tuple[str, ...]] = {
|
|
207
|
+
"Sofa": _EXTENDED_SHAPES,
|
|
208
|
+
"CoffeeTableGroup": _BASIC_SHAPES,
|
|
209
|
+
"CabinetShelf": _LAYOUT_SHAPES,
|
|
210
|
+
"BedsideTable": _BASIC_SHAPES,
|
|
211
|
+
"TableGroup": _EXTENDED_SHAPES,
|
|
212
|
+
"ChairStool": _BASIC_SHAPES,
|
|
213
|
+
"Bed": _BASIC_SHAPES,
|
|
214
|
+
"Bar": _BASIC_SHAPES,
|
|
215
|
+
"KitchenIsland": _BASIC_SHAPES,
|
|
216
|
+
"KitchenWallCabinet": _EXTENDED_SHAPES,
|
|
217
|
+
"KitchenTallCabinet": _EXTENDED_SHAPES,
|
|
218
|
+
"Countertop": _COUNTERTOP_SHAPES,
|
|
219
|
+
"KitchenSink": _BASIC_SHAPES,
|
|
220
|
+
"Stove": _BASIC_SHAPES,
|
|
221
|
+
"BathroomCabinet": _BASIC_SHAPES,
|
|
222
|
+
"MirrorCabinet": _BASIC_SHAPES,
|
|
223
|
+
"Basin": _BASIC_SHAPES,
|
|
224
|
+
"Bathtub": _BATH_SHAPES,
|
|
225
|
+
"ShowerRoom": _SHOWER_ROOM_SHAPES,
|
|
226
|
+
"ShowerPartition": _PARTITION_SHAPES,
|
|
227
|
+
"Stair": _STAIR_SHAPES,
|
|
228
|
+
"Railing": _RAILING_SHAPES,
|
|
229
|
+
"AirConditioner": _BASIC_SHAPES,
|
|
230
|
+
"Carpet": _BASIC_SHAPES,
|
|
231
|
+
"Curtain": _LAYOUT_SHAPES,
|
|
232
|
+
"Plant": _IRREGULAR_SHAPES,
|
|
233
|
+
"Piano": _BASIC_SHAPES,
|
|
234
|
+
}
|
|
235
|
+
LEGEND_TYPES_SUPPORTED = set(LEGEND_SUBCATEGORIES)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def complete_legend_fields(item: JsonObject) -> tuple[JsonObject, list[JsonObject]]:
|
|
239
|
+
"""Complete derived classification fields; keep the observation and geometry intact."""
|
|
240
|
+
result = item.copy()
|
|
241
|
+
category = result.get("recommendedType")
|
|
242
|
+
diagnostics: list[JsonObject] = []
|
|
243
|
+
if not isinstance(category, str):
|
|
244
|
+
return result, diagnostics
|
|
245
|
+
for field, choices in (("subCategory", LEGEND_SUBCATEGORIES.get(category, ())),
|
|
246
|
+
("shape", LEGEND_SHAPES.get(category, ()))):
|
|
247
|
+
# Steps and ramps inherit Stair's category but only support these two shapes.
|
|
248
|
+
if field == "shape" and category == "Stair" and result.get("subCategory") in {"STEPS", "RAMP"}:
|
|
249
|
+
choices = ("RECTANGLE", "IRREGULAR")
|
|
250
|
+
value = result.get(field)
|
|
251
|
+
if not choices or value in choices:
|
|
252
|
+
continue
|
|
253
|
+
result[field] = choices[0]
|
|
254
|
+
diagnostics.append({
|
|
255
|
+
"code": "legend_fields_defaulted", "entityId": str(item.get("id", "")),
|
|
256
|
+
"sourceType": category, "targetType": category, "field": field,
|
|
257
|
+
"sourceValue": value, "value": choices[0],
|
|
258
|
+
"reason": "missing" if value is None or value == "" else "unsupported",
|
|
259
|
+
"confidence": "derived", "provenance": LEGEND_CLASSIFICATION_SOURCE,
|
|
260
|
+
})
|
|
261
|
+
return result, diagnostics
|
|
262
|
+
|
|
263
|
+
def legend_required_fields_missing(item: JsonObject) -> list[str]:
|
|
264
|
+
"""Only enforce target-required fields here; Scene retains incomplete observations."""
|
|
265
|
+
kind = item.get("recommendedType")
|
|
266
|
+
if kind not in {"Stair", "Railing"}:
|
|
267
|
+
return []
|
|
268
|
+
missing = []
|
|
269
|
+
shape = item.get("shape")
|
|
270
|
+
if shape not in LEGEND_SHAPES[kind]:
|
|
271
|
+
missing.append("shape")
|
|
272
|
+
outline = item.get("outlinePolygon")
|
|
273
|
+
if (kind == "Railing" or shape != "RECTANGLE" or outline) and (
|
|
274
|
+
not outline or len(outline) < 3 or abs(sum(
|
|
275
|
+
a["x"] * b["y"] - b["x"] * a["y"]
|
|
276
|
+
for a, b in zip(outline, outline[1:] + outline[:1])
|
|
277
|
+
)) <= 1e-9
|
|
278
|
+
):
|
|
279
|
+
missing.append("outlinePolygon")
|
|
280
|
+
if kind == "Stair":
|
|
281
|
+
if item.get("subCategory") not in {"STAIR", "STEPS", "RAMP"}:
|
|
282
|
+
missing.append("subCategory")
|
|
283
|
+
if item.get("subCategory") in {"STEPS", "RAMP"} and shape not in {"RECTANGLE", "IRREGULAR"}:
|
|
284
|
+
missing.append("shape")
|
|
285
|
+
directions = item.get("upStairDirections")
|
|
286
|
+
if not isinstance(directions, list) or not directions or any(
|
|
287
|
+
not isinstance(direction, dict)
|
|
288
|
+
or isinstance(direction.get("rotationAngle"), bool)
|
|
289
|
+
or not isinstance(direction.get("rotationAngle"), (int, float))
|
|
290
|
+
or not math.isfinite(direction["rotationAngle"])
|
|
291
|
+
or not 0 <= direction["rotationAngle"] < 360
|
|
292
|
+
or direction.get("ascendSide") not in {None, "LEFT", "RIGHT"}
|
|
293
|
+
for direction in directions
|
|
294
|
+
):
|
|
295
|
+
missing.append("upStairDirections")
|
|
296
|
+
else:
|
|
297
|
+
path = item.get("path")
|
|
298
|
+
if item.get("subCategory") != "RAILING":
|
|
299
|
+
missing.append("subCategory")
|
|
300
|
+
if not isinstance(path, list) or len(path) < 2 or path[0] == path[-1] or any(
|
|
301
|
+
a == b for a, b in pairwise(path)
|
|
302
|
+
) or shape == "ARC" and (len(path) < 3 or all(
|
|
303
|
+
abs((p["x"] - path[0]["x"]) * (path[1]["y"] - path[0]["y"])
|
|
304
|
+
- (p["y"] - path[0]["y"]) * (path[1]["x"] - path[0]["x"])) <= 1e-9
|
|
305
|
+
for p in path[2:]
|
|
306
|
+
)):
|
|
307
|
+
missing.append("path")
|
|
308
|
+
return missing
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def require_stair_direction(item: JsonObject, missing: list[str]) -> None:
|
|
312
|
+
"""Prevent both Legend exporters from disguising an incomplete stair as generic furniture."""
|
|
313
|
+
if item.get("recommendedType") == "Stair" and "upStairDirections" in missing:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"stair_direction_required: {item.get('id')} 缺少或无效的 upStairDirections;"
|
|
316
|
+
"请依据原图补充上行方向后重新转换,楼梯不能降级为普通家具。"
|
|
317
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Derived geometry checks and explicit export-only host snapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from shapely.geometry import Point, Polygon # type: ignore[import-untyped]
|
|
10
|
+
from shapely.ops import nearest_points # type: ignore[import-untyped]
|
|
11
|
+
|
|
12
|
+
from .model import FloorplanScene, scene_from_dict, scene_to_dict
|
|
13
|
+
from .shape_geometry import arc_circle, arc_subsegment, geometry_shape
|
|
14
|
+
from .shape_geometry import wall_shape as wall_geometry
|
|
15
|
+
from .shapes import GEOMETRY_TOLERANCE_MM, ArcGeometry, Geometry, QuadraticGeometry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Issue:
|
|
20
|
+
code: str
|
|
21
|
+
entityId: str | None
|
|
22
|
+
message: str
|
|
23
|
+
severity: Literal["error", "warning"] = "error"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def polygon_parts(geometry: Any) -> list[Any]:
|
|
29
|
+
if isinstance(geometry, Polygon):
|
|
30
|
+
return [geometry]
|
|
31
|
+
return [polygon for part in getattr(geometry, "geoms", ()) for polygon in polygon_parts(part)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def opening_host_issues(scene: FloorplanScene) -> list[Issue]:
|
|
41
|
+
"""A curved wall opening is represented by its chord, not a segment of the wall axis."""
|
|
42
|
+
issues: list[Issue] = []
|
|
43
|
+
walls = {wall.id: wall for wall in scene.walls}
|
|
44
|
+
for opening in scene.openings:
|
|
45
|
+
for index, part in enumerate(opening.segments):
|
|
46
|
+
wall = walls.get(part.wallId or "")
|
|
47
|
+
if wall is None:
|
|
48
|
+
issues.append(Issue("missing_host", opening.id, f"Segment {index} has no host wall."))
|
|
49
|
+
continue
|
|
50
|
+
host = geometry_shape(wall.geometry)
|
|
51
|
+
if opening.kind == "niche":
|
|
52
|
+
on_host = (isinstance(wall.geometry, (ArcGeometry, QuadraticGeometry)) or
|
|
53
|
+
wall.geometry.type == "line") and wall.thickness is None
|
|
54
|
+
if not on_host:
|
|
55
|
+
on_host = wall_geometry(wall).buffer(GEOMETRY_TOLERANCE_MM).covers(
|
|
56
|
+
geometry_shape(part.geometry, opening.depth))
|
|
57
|
+
elif isinstance(part.geometry, ArcGeometry):
|
|
58
|
+
on_host = host.buffer(GEOMETRY_TOLERANCE_MM).covers(geometry_shape(part.geometry))
|
|
59
|
+
elif isinstance(wall.geometry, (ArcGeometry, QuadraticGeometry)):
|
|
60
|
+
on_host = all(host.distance(Point(p)) <= GEOMETRY_TOLERANCE_MM for p in (part.geometry.start, part.geometry.end))
|
|
61
|
+
else:
|
|
62
|
+
# Allow up to 1 mm of source-coordinate deviation without moving either geometry.
|
|
63
|
+
on_host = host.buffer(GEOMETRY_TOLERANCE_MM).covers(geometry_shape(part.geometry))
|
|
64
|
+
if not on_host:
|
|
65
|
+
issues.append(Issue("opening_off_host", opening.id, f"Segment {index} is outside its host geometry."))
|
|
66
|
+
return issues
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def snap_openings_to_hosts(
|
|
72
|
+
scene: FloorplanScene, tolerance_mm: float, diagnostics: list[dict[str, Any]],
|
|
73
|
+
) -> FloorplanScene:
|
|
74
|
+
"""Create an export-only repair; never change host IDs or the source scene."""
|
|
75
|
+
walls = {wall.id: wall for wall in scene.walls}
|
|
76
|
+
openings = []
|
|
77
|
+
for opening in scene.openings:
|
|
78
|
+
parts = []
|
|
79
|
+
for index, part in enumerate(opening.segments):
|
|
80
|
+
wall = walls.get(part.wallId or "")
|
|
81
|
+
if opening.kind == "niche" or wall is None or (isinstance(part.geometry, ArcGeometry) and not isinstance(wall.geometry, ArcGeometry)):
|
|
82
|
+
parts.append(part)
|
|
83
|
+
continue
|
|
84
|
+
before = (part.geometry.start, part.geometry.end)
|
|
85
|
+
after = tuple(_nearest_host_point(point, wall.geometry) for point in before)
|
|
86
|
+
distances = [Point(a).distance(Point(b)) for a, b in zip(before, after, strict=True)]
|
|
87
|
+
if max(distances) > tolerance_mm:
|
|
88
|
+
raise ValueError(f"invalid scene geometry: opening_off_host: {opening.id} exceeds {tolerance_mm:g} mm")
|
|
89
|
+
if Point(after[0]).distance(Point(after[1])) < 1e-6:
|
|
90
|
+
raise ValueError(f"opening_snap_degenerate: {opening.id}")
|
|
91
|
+
repaired = replace(part.geometry, start=after[0], end=after[1])
|
|
92
|
+
if (isinstance(part.geometry, ArcGeometry) and isinstance(wall.geometry, ArcGeometry)
|
|
93
|
+
and abs(wall.geometry.bulge) > 1e-12):
|
|
94
|
+
repaired = arc_subsegment(wall.geometry, after[0], after[1])
|
|
95
|
+
if geometry_shape(part.geometry).hausdorff_distance(geometry_shape(repaired)) > tolerance_mm:
|
|
96
|
+
raise ValueError(f"invalid scene geometry: opening_off_host: {opening.id} arc exceeds {tolerance_mm:g} mm")
|
|
97
|
+
if max(distances) > 1e-9 or repaired != part.geometry:
|
|
98
|
+
diagnostics.append({"code": "opening_snapped", "entityId": opening.id,
|
|
99
|
+
"segmentIndex": index, "hostId": part.wallId,
|
|
100
|
+
"before": before, "after": after,
|
|
101
|
+
"displacementsMm": distances, "toleranceMm": tolerance_mm})
|
|
102
|
+
part = replace(part, geometry=repaired)
|
|
103
|
+
parts.append(part)
|
|
104
|
+
openings.append(replace(opening, segments=tuple(parts)))
|
|
105
|
+
return scene_from_dict(scene_to_dict(replace(scene, openings=tuple(openings))))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _nearest_host_point(point: tuple[float, float], geometry: Geometry) -> tuple[float, float]:
|
|
109
|
+
if isinstance(geometry, ArcGeometry) and abs(geometry.bulge) > 1e-12:
|
|
110
|
+
center, radius = arc_circle(geometry)
|
|
111
|
+
angle = math.atan2(point[1] - center[1], point[0] - center[0])
|
|
112
|
+
start_angle = math.atan2(geometry.start[1] - center[1], geometry.start[0] - center[0])
|
|
113
|
+
along = ((1 if geometry.bulge > 0 else -1) * (angle - start_angle)) % math.tau
|
|
114
|
+
candidates = [geometry.start, geometry.end]
|
|
115
|
+
if along <= abs(4 * math.atan(geometry.bulge)):
|
|
116
|
+
candidates.append((center[0] + radius * math.cos(angle), center[1] + radius * math.sin(angle)))
|
|
117
|
+
return min(candidates, key=lambda candidate: math.dist(point, candidate))
|
|
118
|
+
result = nearest_points(Point(point), geometry_shape(geometry))[1]
|
|
119
|
+
return float(result.x), float(result.y)
|