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,278 @@
|
|
|
1
|
+
"""Presentation and interaction hints for terminal SVG artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
|
|
10
|
+
from .model import FloorplanScene, Opening, OpeningSegment
|
|
11
|
+
from .render_labels import add_render_labels
|
|
12
|
+
from .render_style import DEFAULT_RENDER_COLORS, rich_stylesheet
|
|
13
|
+
from .shape_geometry import arc_circle, offset_arc
|
|
14
|
+
from .shapes import ArcGeometry
|
|
15
|
+
from .svg import NS, _curve_node, opening_width, scene_to_svg, svg_to_scene
|
|
16
|
+
|
|
17
|
+
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
|
|
18
|
+
_DOOR_SIDES = {
|
|
19
|
+
"LEFT": ("start", "left"),
|
|
20
|
+
"RIGHT": ("start", "right"),
|
|
21
|
+
"RIGHT_INVERSE": ("end", "left"),
|
|
22
|
+
"LEFT_INVERSE": ("end", "right"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ET.register_namespace("", NS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _number(value: float) -> str:
|
|
29
|
+
return str(int(value)) if float(value).is_integer() else repr(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _palette(colors: Mapping[str, str] | None) -> dict[str, str]:
|
|
33
|
+
values = dict(colors or {})
|
|
34
|
+
unknown = sorted(values.keys() - DEFAULT_RENDER_COLORS.keys())
|
|
35
|
+
if unknown:
|
|
36
|
+
raise ValueError(f"unknown render colors: {', '.join(unknown)}")
|
|
37
|
+
if any(not isinstance(value, str) or not _HEX_COLOR.fullmatch(value) for value in values.values()):
|
|
38
|
+
raise ValueError("render colors must use #RRGGBB")
|
|
39
|
+
return {**DEFAULT_RENDER_COLORS, **values}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _door_points(opening: Opening) -> list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]]:
|
|
43
|
+
result: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float]]] = []
|
|
44
|
+
if opening.type not in {"DOOR", "ARCHED_DOOR", "DOUBLE_DOOR"}:
|
|
45
|
+
return result
|
|
46
|
+
for segment in opening.segments:
|
|
47
|
+
side = _DOOR_SIDES.get(segment.openSide or "")
|
|
48
|
+
if side is None:
|
|
49
|
+
continue
|
|
50
|
+
start, end = segment.geometry.start, segment.geometry.end
|
|
51
|
+
dx, dy = end[0] - start[0], end[1] - start[1]
|
|
52
|
+
length = math.hypot(dx, dy)
|
|
53
|
+
normal = (-dy / length, dx / length) if side[1] == "left" else (dy / length, -dx / length)
|
|
54
|
+
hinge, closed = (start, end) if side[0] == "start" else (end, start)
|
|
55
|
+
leaves = [(hinge, closed)]
|
|
56
|
+
if opening.type == "DOUBLE_DOOR":
|
|
57
|
+
middle = ((start[0] + end[0]) / 2, (start[1] + end[1]) / 2)
|
|
58
|
+
leaves = [(start, middle), (end, middle)]
|
|
59
|
+
for hinge, closed in leaves:
|
|
60
|
+
radius = math.dist(hinge, closed)
|
|
61
|
+
opened = hinge[0] + normal[0] * radius, hinge[1] + normal[1] * radius
|
|
62
|
+
result.append((hinge, closed, opened))
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _opening_symbol(holder: ET.Element, opening: Opening, segment: OpeningSegment, width: float) -> None:
|
|
67
|
+
"""Draw in world coordinates; widths describe symbols, never additional Scene facts."""
|
|
68
|
+
if isinstance(segment.geometry, ArcGeometry) and segment.geometry.bulge != 0:
|
|
69
|
+
arc = segment.geometry
|
|
70
|
+
_curve_node(holder, arc, {"class": "opening-cutout", "stroke-width": _number(width)})
|
|
71
|
+
_, radius = arc_circle(arc)
|
|
72
|
+
outer = offset_arc(arc, width / 2)
|
|
73
|
+
inner = offset_arc(arc, -width / 2) if radius > width / 2 else None
|
|
74
|
+
for edge in (inner, outer):
|
|
75
|
+
if edge is not None:
|
|
76
|
+
_curve_node(holder, edge, {"class": "opening-edge", "aria-hidden": "true"})
|
|
77
|
+
if inner is not None:
|
|
78
|
+
for inner_point, outer_point in ((inner.start, outer.start), (inner.end, outer.end)):
|
|
79
|
+
ET.SubElement(holder, f"{{{NS}}}line", {
|
|
80
|
+
"x1": _number(inner_point[0]), "y1": _number(inner_point[1]),
|
|
81
|
+
"x2": _number(outer_point[0]), "y2": _number(outer_point[1]),
|
|
82
|
+
"class": "opening-jamb", "aria-hidden": "true",
|
|
83
|
+
})
|
|
84
|
+
if opening.kind == "window" and opening.type != "WINDOW_HOLE":
|
|
85
|
+
_curve_node(holder, arc, {"class": "opening-panel", "aria-hidden": "true"})
|
|
86
|
+
return
|
|
87
|
+
start, end = segment.geometry.start, segment.geometry.end
|
|
88
|
+
length = math.dist(start, end)
|
|
89
|
+
ux, uy = (end[0] - start[0]) / length, (end[1] - start[1]) / length
|
|
90
|
+
half = width / 2
|
|
91
|
+
|
|
92
|
+
def line(a: float, b: float, c: float, d: float, css: str) -> None:
|
|
93
|
+
ET.SubElement(holder, f"{{{NS}}}line", {
|
|
94
|
+
"x1": _number(start[0] + ux * a - uy * b),
|
|
95
|
+
"y1": _number(start[1] + uy * a + ux * b),
|
|
96
|
+
"x2": _number(start[0] + ux * c - uy * d),
|
|
97
|
+
"y2": _number(start[1] + uy * c + ux * d),
|
|
98
|
+
"class": css, "aria-hidden": "true",
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
line(0, 0, length, 0, "opening-cutout")
|
|
102
|
+
holder[-1].set("stroke-width", _number(width))
|
|
103
|
+
line(0, -half, 0, half, "opening-jamb")
|
|
104
|
+
line(length, -half, length, half, "opening-jamb")
|
|
105
|
+
sliding = opening.type in {"SLIDE_DOOR", "SLIDE_WINDOW"}
|
|
106
|
+
shutter = opening.type in {"SHUTTER_DOOR", "SHUTTER_WINDOW"}
|
|
107
|
+
if sliding:
|
|
108
|
+
line(0, -half / 2, length * .6, -half / 2, "opening-panel")
|
|
109
|
+
line(length * .4, half / 2, length, half / 2, "opening-panel")
|
|
110
|
+
for a, b in ((0, -half / 2), (length * .6, -half / 2), (length * .4, half / 2), (length, half / 2)):
|
|
111
|
+
line(a, b - half / 3, a, b + half / 3, "opening-panel")
|
|
112
|
+
elif shutter:
|
|
113
|
+
for index in range(1, 10):
|
|
114
|
+
position = length * index / 10
|
|
115
|
+
line(position - length / 50, -half, position + length / 50, half, "opening-panel")
|
|
116
|
+
elif opening.kind == "window":
|
|
117
|
+
line(0, -half, length, -half, "opening-edge")
|
|
118
|
+
line(0, half, length, half, "opening-edge")
|
|
119
|
+
if opening.type != "WINDOW_HOLE":
|
|
120
|
+
line(0, 0, length, 0, "opening-panel")
|
|
121
|
+
elif opening.type == "UNEQUAL_DOUBLE_DOOR":
|
|
122
|
+
# Leaf widths are absent from Scene; identify the type without inventing a ratio.
|
|
123
|
+
line(0, 0, length, 0, "opening-panel")
|
|
124
|
+
angle = math.degrees(math.atan2(uy, ux))
|
|
125
|
+
if angle > 90:
|
|
126
|
+
angle -= 180
|
|
127
|
+
elif angle < -90:
|
|
128
|
+
angle += 180
|
|
129
|
+
label = ET.SubElement(holder, f"{{{NS}}}text", {
|
|
130
|
+
"class": "opening-type-label", "text-anchor": "middle", "font-size": _number(min(140., length / 4)),
|
|
131
|
+
"transform": (f"translate({_number((start[0] + end[0]) / 2)} {_number((start[1] + end[1]) / 2)}) "
|
|
132
|
+
f"rotate({_number(angle)}) scale(1 -1)"),
|
|
133
|
+
"aria-hidden": "true",
|
|
134
|
+
})
|
|
135
|
+
label.text = opening.id + " 子母门"
|
|
136
|
+
elif opening.kind in {"passage", "niche"} or not _DOOR_SIDES.get(segment.openSide or ""):
|
|
137
|
+
line(0, -half, length, -half, "opening-edge")
|
|
138
|
+
line(0, half, length, half, "opening-edge")
|
|
139
|
+
if opening.kind == "niche":
|
|
140
|
+
line(0, half, length, half, "opening-panel")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _display_point(point: tuple[float, float]) -> tuple[float, float]:
|
|
144
|
+
return point[0], -point[1]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _expand_viewbox(root: ET.Element, points: list[tuple[float, float]]) -> None:
|
|
148
|
+
if not points:
|
|
149
|
+
return
|
|
150
|
+
x, y, width, height = (float(value) for value in root.attrib["viewBox"].split())
|
|
151
|
+
margin = 100.0
|
|
152
|
+
min_x = min(x, *(point[0] - margin for point in points))
|
|
153
|
+
min_y = min(y, *(point[1] - margin for point in points))
|
|
154
|
+
max_x = max(x + width, *(point[0] + margin for point in points))
|
|
155
|
+
max_y = max(y + height, *(point[1] + margin for point in points))
|
|
156
|
+
root.set("viewBox", " ".join(_number(value) for value in (min_x, min_y, max_x - min_x, max_y - min_y)))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _entity_label(node: ET.Element) -> str:
|
|
160
|
+
entity_id = node.attrib["id"]
|
|
161
|
+
detail = node.get("data-name") or node.get("data-type")
|
|
162
|
+
if detail is None and node.get("data-value") is not None:
|
|
163
|
+
detail = f'{node.get("data-value")} mm'
|
|
164
|
+
return f"{entity_id}, {detail}" if detail else entity_id
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def scene_to_rich_svg(
|
|
168
|
+
scene: FloorplanScene,
|
|
169
|
+
*,
|
|
170
|
+
colors: Mapping[str, str] | None = None,
|
|
171
|
+
width: int = 1400,
|
|
172
|
+
height: int | None = None,
|
|
173
|
+
) -> str:
|
|
174
|
+
"""Render dimensions and opening symbols in a colored, accessible SVG."""
|
|
175
|
+
palette = _palette(colors)
|
|
176
|
+
source = scene_to_svg(scene)
|
|
177
|
+
scene = svg_to_scene(source, scene_id=scene.id)
|
|
178
|
+
root = ET.fromstring(source)
|
|
179
|
+
root.set("data-rich-svg", "true")
|
|
180
|
+
root.set("role", "group")
|
|
181
|
+
root.set("aria-label", "Interactive floor plan")
|
|
182
|
+
stylesheet = ET.Element(f"{{{NS}}}style")
|
|
183
|
+
stylesheet.text = rich_stylesheet(palette)
|
|
184
|
+
# The workbench removes input CSS; retain only validated color variables there.
|
|
185
|
+
root.set("style", ";".join(f"--scene-{key.replace('.', '-')}: {value}" for key, value in palette.items()))
|
|
186
|
+
root.insert(0, stylesheet)
|
|
187
|
+
|
|
188
|
+
if scene.metadata is not None and scene.metadata.originImage is not None:
|
|
189
|
+
origin = scene.metadata.originImage
|
|
190
|
+
x, y, image_width, image_height = origin.sceneBounds
|
|
191
|
+
root.insert(1, ET.Element(f"{{{NS}}}image", {
|
|
192
|
+
"class": "origin-image", "href": origin.url,
|
|
193
|
+
"x": _number(x), "y": _number(-y - image_height),
|
|
194
|
+
"width": _number(image_width), "height": _number(image_height),
|
|
195
|
+
"preserveAspectRatio": "none", "opacity": "0.35",
|
|
196
|
+
"pointer-events": "none", "aria-hidden": "true",
|
|
197
|
+
}))
|
|
198
|
+
|
|
199
|
+
by_id = {node.get("id"): node for node in root.iter() if node.get("id")}
|
|
200
|
+
leaf_points: list[tuple[float, float]] = []
|
|
201
|
+
for node in by_id.values():
|
|
202
|
+
node.set("tabindex", "0")
|
|
203
|
+
node.set("role", "button")
|
|
204
|
+
node.set("aria-pressed", "false")
|
|
205
|
+
node.set("aria-label", _entity_label(node))
|
|
206
|
+
title = ET.Element(f"{{{NS}}}title")
|
|
207
|
+
title.text = _entity_label(node)
|
|
208
|
+
node.insert(0, title)
|
|
209
|
+
|
|
210
|
+
for furniture in scene.furniture:
|
|
211
|
+
holder = by_id.get(furniture.id)
|
|
212
|
+
if holder is None:
|
|
213
|
+
continue
|
|
214
|
+
length = furniture.placement.depth * .35
|
|
215
|
+
head = min(length * .25, furniture.placement.width * .2)
|
|
216
|
+
direction = ET.SubElement(holder, f"{{{NS}}}g", {
|
|
217
|
+
"class": "furniture-direction",
|
|
218
|
+
"data-rotation": _number(furniture.placement.rotation),
|
|
219
|
+
"aria-hidden": "true",
|
|
220
|
+
})
|
|
221
|
+
ET.SubElement(direction, f"{{{NS}}}line", {
|
|
222
|
+
"x1": "0", "y1": "0", "x2": "0", "y2": _number(-length),
|
|
223
|
+
})
|
|
224
|
+
ET.SubElement(direction, f"{{{NS}}}polyline", {
|
|
225
|
+
"points": (
|
|
226
|
+
f"{_number(-head)},{_number(-length + head)} "
|
|
227
|
+
f"0,{_number(-length)} {_number(head)},{_number(-length + head)}"
|
|
228
|
+
),
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
wall_by_id = {wall.id: wall for wall in scene.walls}
|
|
232
|
+
for opening in scene.openings:
|
|
233
|
+
holder = by_id.get(opening.id)
|
|
234
|
+
if holder is None:
|
|
235
|
+
continue
|
|
236
|
+
for node in list(holder):
|
|
237
|
+
if node.tag in {f"{{{NS}}}line", f"{{{NS}}}path"}:
|
|
238
|
+
node.set("data-opening-span", "true")
|
|
239
|
+
for segment in opening.segments:
|
|
240
|
+
_opening_symbol(holder, opening, segment, opening_width(opening, wall_by_id.get(segment.wallId or "")))
|
|
241
|
+
for hinge, closed, opened in _door_points(opening):
|
|
242
|
+
leaf_points.extend(map(_display_point, (hinge, closed, opened)))
|
|
243
|
+
# The parent flips Y for display; derive sweep in world coordinates.
|
|
244
|
+
radius = math.dist(hinge, closed)
|
|
245
|
+
cross = ((closed[0] - hinge[0]) * (opened[1] - hinge[1])
|
|
246
|
+
- (closed[1] - hinge[1]) * (opened[0] - hinge[0]))
|
|
247
|
+
ET.SubElement(holder, f"{{{NS}}}path", {
|
|
248
|
+
"class": "door-swing",
|
|
249
|
+
"d": (f"M {_number(closed[0])} {_number(closed[1])} "
|
|
250
|
+
f"A {_number(radius)} {_number(radius)} 0 0 {int(cross > 0)} "
|
|
251
|
+
f"{_number(opened[0])} {_number(opened[1])}"),
|
|
252
|
+
"aria-hidden": "true",
|
|
253
|
+
})
|
|
254
|
+
ET.SubElement(
|
|
255
|
+
holder,
|
|
256
|
+
f"{{{NS}}}line",
|
|
257
|
+
{
|
|
258
|
+
"class": "door-leaf",
|
|
259
|
+
"x1": _number(hinge[0]),
|
|
260
|
+
"y1": _number(hinge[1]),
|
|
261
|
+
"x2": _number(opened[0]),
|
|
262
|
+
"y2": _number(opened[1]),
|
|
263
|
+
"aria-hidden": "true",
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
_expand_viewbox(root, leaf_points)
|
|
268
|
+
return add_render_labels(scene, ET.tostring(root, encoding="unicode"), width, height) + "\n"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def enrich_svg(
|
|
272
|
+
svg: str,
|
|
273
|
+
*,
|
|
274
|
+
colors: Mapping[str, str] | None = None,
|
|
275
|
+
scene_id: str = "rich-svg",
|
|
276
|
+
) -> str:
|
|
277
|
+
"""Validate a semantic SVG through Scene before enriching it."""
|
|
278
|
+
return scene_to_rich_svg(svg_to_scene(svg, scene_id=scene_id), colors=colors)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Shapely projections of analytic shapes; sampled points never replace source geometry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from shapely.affinity import rotate, translate # type: ignore[import-untyped]
|
|
9
|
+
from shapely.geometry import LineString, MultiPolygon, Polygon, box # type: ignore[import-untyped]
|
|
10
|
+
from shapely.geometry import Point as ShapelyPoint
|
|
11
|
+
from shapely.ops import unary_union # type: ignore[import-untyped]
|
|
12
|
+
from svg.path import Arc, QuadraticBezier
|
|
13
|
+
|
|
14
|
+
from .model import Building, FloorplanScene, Furniture, FurniturePlacement, Wall
|
|
15
|
+
from .shapes import (
|
|
16
|
+
GEOMETRY_TOLERANCE_MM,
|
|
17
|
+
ArcGeometry,
|
|
18
|
+
BayWindowGeometry,
|
|
19
|
+
CircleGeometry,
|
|
20
|
+
Curve,
|
|
21
|
+
Geometry,
|
|
22
|
+
LineGeometry,
|
|
23
|
+
PathGeometry,
|
|
24
|
+
PipeGeometry,
|
|
25
|
+
Point,
|
|
26
|
+
PolygonGeometry,
|
|
27
|
+
QuadraticGeometry,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
PROJECTION_TOLERANCE = 0.05 # mm chord error, only for planar computation and PNG rendering.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def curve_points(curve: Curve) -> tuple[Point, ...]:
|
|
34
|
+
if isinstance(curve, LineGeometry) or isinstance(curve, ArcGeometry) and curve.bulge == 0:
|
|
35
|
+
return curve.start, curve.end
|
|
36
|
+
path: Arc | QuadraticBezier
|
|
37
|
+
if isinstance(curve, ArcGeometry):
|
|
38
|
+
bulge = curve.bulge
|
|
39
|
+
radius = math.dist(curve.start, curve.end) * (1 + bulge * bulge) / (4 * abs(bulge))
|
|
40
|
+
angle = abs(4 * math.atan(bulge))
|
|
41
|
+
count = max(2, math.ceil(angle * math.sqrt(radius / (8 * PROJECTION_TOLERANCE))))
|
|
42
|
+
path = Arc(complex(*curve.start), complex(radius, radius), 0, abs(bulge) > 1,
|
|
43
|
+
bulge > 0, complex(*curve.end))
|
|
44
|
+
else:
|
|
45
|
+
curvature = math.hypot(*(curve.start[i] - 2 * curve.control[i] + curve.end[i] for i in (0, 1)))
|
|
46
|
+
count = max(2, math.ceil(math.sqrt(curvature / (4 * PROJECTION_TOLERANCE))))
|
|
47
|
+
path = QuadraticBezier(complex(*curve.start), complex(*curve.control), complex(*curve.end))
|
|
48
|
+
if count > 100_000:
|
|
49
|
+
raise ValueError("curve exceeds projection size limit")
|
|
50
|
+
points = (path.point(i / count) for i in range(1, count))
|
|
51
|
+
return (curve.start, *((p.real, p.imag) for p in points), curve.end)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def geometry_shape(geometry: Geometry, thickness: float | None = None) -> Any:
|
|
55
|
+
if isinstance(geometry, (LineGeometry, ArcGeometry, QuadraticGeometry)):
|
|
56
|
+
axis = LineString(curve_points(geometry))
|
|
57
|
+
return axis.buffer(thickness / 2, cap_style=2, join_style=2) if thickness else axis
|
|
58
|
+
if isinstance(geometry, PolygonGeometry):
|
|
59
|
+
return MultiPolygon([Polygon(rings[0], rings[1:]) for rings in geometry.polygons])
|
|
60
|
+
if isinstance(geometry, PathGeometry):
|
|
61
|
+
polygons = []
|
|
62
|
+
for rings in geometry.polygons:
|
|
63
|
+
points = [tuple(p for edge in ring for p in curve_points(edge)[:-1]) for ring in rings]
|
|
64
|
+
polygons.append(Polygon(points[0], points[1:]))
|
|
65
|
+
return MultiPolygon(polygons)
|
|
66
|
+
if isinstance(geometry, CircleGeometry):
|
|
67
|
+
segments = max(8, math.ceil(math.pi * math.sqrt(geometry.radius / (8 * PROJECTION_TOLERANCE)) / 2))
|
|
68
|
+
if segments > 25_000:
|
|
69
|
+
raise ValueError("circle exceeds projection size limit")
|
|
70
|
+
return ShapelyPoint(geometry.center).buffer(geometry.radius, quad_segs=segments)
|
|
71
|
+
if isinstance(geometry, BayWindowGeometry):
|
|
72
|
+
return Polygon(geometry.outline)
|
|
73
|
+
if isinstance(geometry, PipeGeometry):
|
|
74
|
+
widths = dict(zip({"L": ("right", "bottom"), "U": ("left", "right", "bottom"),
|
|
75
|
+
"RECT": ("left", "top", "right", "bottom")}[geometry.profile],
|
|
76
|
+
geometry.wallThickness, strict=True))
|
|
77
|
+
x, y = geometry.width / 2, geometry.depth / 2
|
|
78
|
+
strips = {"left": lambda w: box(-x, -y, -x + w, y),
|
|
79
|
+
"top": lambda w: box(-x, y - w, x, y),
|
|
80
|
+
"right": lambda w: box(x - w, -y, x, y),
|
|
81
|
+
"bottom": lambda w: box(-x, -y, x, -y + w)}
|
|
82
|
+
profile = unary_union([strips[side](w) for side, w in widths.items()])
|
|
83
|
+
return translate(rotate(profile, geometry.angle, origin=(0, 0)), *geometry.center)
|
|
84
|
+
raise TypeError(f"unsupported geometry: {type(geometry).__name__}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def subtract_floor_openings(domain: Any, scene: FloorplanScene) -> Any:
|
|
88
|
+
"""Remove floor voids after joining rooms and door bridges, before clearance erosion."""
|
|
89
|
+
if not scene.floorOpenings:
|
|
90
|
+
return domain
|
|
91
|
+
return domain.difference(unary_union([geometry_shape(item.geometry) for item in scene.floorOpenings]))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def wall_shape(wall: Wall) -> Any:
|
|
95
|
+
return geometry_shape(wall.geometry, wall.thickness)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _transform_polygon(
|
|
99
|
+
geometry: PolygonGeometry,
|
|
100
|
+
placement: FurniturePlacement,
|
|
101
|
+
*,
|
|
102
|
+
inverse: bool,
|
|
103
|
+
) -> PolygonGeometry:
|
|
104
|
+
angle = math.radians(placement.rotation)
|
|
105
|
+
cosine, sine = math.cos(angle), math.sin(angle)
|
|
106
|
+
center_x, center_y = placement.center
|
|
107
|
+
|
|
108
|
+
def transform(point: Point) -> Point:
|
|
109
|
+
x, y = point
|
|
110
|
+
if inverse:
|
|
111
|
+
x, y = x - center_x, y - center_y
|
|
112
|
+
return x * cosine + y * sine, -x * sine + y * cosine
|
|
113
|
+
return center_x + x * cosine - y * sine, center_y + x * sine + y * cosine
|
|
114
|
+
|
|
115
|
+
return PolygonGeometry(tuple(tuple(tuple(transform(point) for point in ring) for ring in polygon)
|
|
116
|
+
for polygon in geometry.polygons))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def placed_furniture_geometry(item: Furniture) -> PolygonGeometry:
|
|
120
|
+
return _transform_polygon(item.geometry, item.placement, inverse=False)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def local_furniture_geometry(
|
|
124
|
+
geometry: PolygonGeometry,
|
|
125
|
+
placement: FurniturePlacement,
|
|
126
|
+
) -> PolygonGeometry:
|
|
127
|
+
return _transform_polygon(geometry, placement, inverse=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def object_shape(item: Building | Furniture) -> Any:
|
|
131
|
+
geometry = placed_furniture_geometry(item) if isinstance(item, Furniture) else item.geometry
|
|
132
|
+
return geometry_shape(geometry, item.thickness)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def arc_circle(arc: ArcGeometry) -> tuple[Point, float]:
|
|
136
|
+
"""Circle of a directed bulge arc in the unchanged world coordinate system."""
|
|
137
|
+
dx, dy = arc.end[0] - arc.start[0], arc.end[1] - arc.start[1]
|
|
138
|
+
factor = (1 - arc.bulge ** 2) / (4 * arc.bulge)
|
|
139
|
+
center = ((arc.start[0] + arc.end[0]) / 2 - dy * factor,
|
|
140
|
+
(arc.start[1] + arc.end[1]) / 2 + dx * factor)
|
|
141
|
+
return center, math.hypot(dx, dy) * (1 + arc.bulge ** 2) / (4 * abs(arc.bulge))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def arc_subsegment(host: ArcGeometry, start: Point, end: Point) -> ArcGeometry:
|
|
145
|
+
"""Recover the interval on a host arc, preserving the supplied endpoint order.
|
|
146
|
+
|
|
147
|
+
The host interval disambiguates major arcs; choosing the shortest arc loses FPI geometry.
|
|
148
|
+
Endpoints must already lie on the host centre line (1 mm allowance).
|
|
149
|
+
"""
|
|
150
|
+
center, radius = arc_circle(host)
|
|
151
|
+
direction = 1 if host.bulge > 0 else -1
|
|
152
|
+
extent = abs(4 * math.atan(host.bulge))
|
|
153
|
+
origin = math.atan2(host.start[1] - center[1], host.start[0] - center[0])
|
|
154
|
+
|
|
155
|
+
def parameter(point: Point) -> float:
|
|
156
|
+
if math.dist(point, host.start) <= GEOMETRY_TOLERANCE_MM:
|
|
157
|
+
return 0.
|
|
158
|
+
if math.dist(point, host.end) <= GEOMETRY_TOLERANCE_MM:
|
|
159
|
+
return extent
|
|
160
|
+
angle = math.atan2(point[1] - center[1], point[0] - center[0])
|
|
161
|
+
value = (direction * (angle - origin)) % math.tau
|
|
162
|
+
if abs(math.dist(point, center) - radius) > GEOMETRY_TOLERANCE_MM or value > extent + GEOMETRY_TOLERANCE_MM / radius:
|
|
163
|
+
raise ValueError("arc opening endpoints must lie on their host arc")
|
|
164
|
+
return min(value, extent)
|
|
165
|
+
|
|
166
|
+
first, last = parameter(start), parameter(end)
|
|
167
|
+
if first == 0 and last == extent:
|
|
168
|
+
return ArcGeometry(start, end, host.bulge)
|
|
169
|
+
if first == extent and last == 0:
|
|
170
|
+
return ArcGeometry(start, end, -host.bulge)
|
|
171
|
+
delta = direction * (last - first)
|
|
172
|
+
if abs(delta) < 1e-12:
|
|
173
|
+
raise ValueError("arc opening requires distinct endpoints on its host arc")
|
|
174
|
+
return ArcGeometry(start, end, math.tan(delta / 4))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def offset_arc(arc: ArcGeometry, distance: float) -> ArcGeometry:
|
|
178
|
+
"""Concentric display offset; positive distance increases the radius."""
|
|
179
|
+
center, radius = arc_circle(arc)
|
|
180
|
+
if radius + distance <= 0:
|
|
181
|
+
raise ValueError("arc display offset requires a positive radius")
|
|
182
|
+
ratio = (radius + distance) / radius
|
|
183
|
+
def point(value: Point) -> Point:
|
|
184
|
+
return center[0] + (value[0] - center[0]) * ratio, center[1] + (value[1] - center[1]) * ratio
|
|
185
|
+
return ArcGeometry(point(arc.start), point(arc.end), arc.bulge)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Analytic plan geometry shared by the Scene, FPI and SVG adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Literal, TypeAlias
|
|
7
|
+
|
|
8
|
+
GEOMETRY_TOLERANCE_MM = 1.0
|
|
9
|
+
|
|
10
|
+
Point: TypeAlias = tuple[float, float]
|
|
11
|
+
Line: TypeAlias = tuple[Point, Point]
|
|
12
|
+
Ring: TypeAlias = tuple[Point, ...]
|
|
13
|
+
Footprint: TypeAlias = tuple[tuple[Ring, ...], ...]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LineGeometry:
|
|
18
|
+
start: Point
|
|
19
|
+
end: Point
|
|
20
|
+
type: Literal["line"] = field(default="line", init=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ArcGeometry:
|
|
25
|
+
start: Point
|
|
26
|
+
end: Point
|
|
27
|
+
bulge: float
|
|
28
|
+
type: Literal["arc"] = field(default="arc", init=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class QuadraticGeometry:
|
|
33
|
+
start: Point
|
|
34
|
+
control: Point
|
|
35
|
+
end: Point
|
|
36
|
+
type: Literal["quadratic"] = field(default="quadratic", init=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Curve: TypeAlias = LineGeometry | ArcGeometry | QuadraticGeometry
|
|
40
|
+
CurveRing: TypeAlias = tuple[Curve, ...]
|
|
41
|
+
CurvedFootprint: TypeAlias = tuple[tuple[CurveRing, ...], ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PolygonGeometry:
|
|
46
|
+
polygons: Footprint
|
|
47
|
+
type: Literal["polygon"] = field(default="polygon", init=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class PathGeometry:
|
|
52
|
+
polygons: CurvedFootprint
|
|
53
|
+
type: Literal["path"] = field(default="path", init=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class CircleGeometry:
|
|
58
|
+
center: Point
|
|
59
|
+
radius: float
|
|
60
|
+
type: Literal["circle"] = field(default="circle", init=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PipeGeometry:
|
|
65
|
+
center: Point
|
|
66
|
+
width: float
|
|
67
|
+
depth: float
|
|
68
|
+
angle: float
|
|
69
|
+
profile: Literal["L", "U", "RECT"]
|
|
70
|
+
wallThickness: tuple[float, ...]
|
|
71
|
+
type: Literal["pipe"] = field(default="pipe", init=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class BayWindowGeometry:
|
|
76
|
+
outline: Ring
|
|
77
|
+
wallPath: tuple[Point, ...]
|
|
78
|
+
profile: str
|
|
79
|
+
type: Literal["bay_window"] = field(default="bay_window", init=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Geometry: TypeAlias = Curve | PolygonGeometry | PathGeometry | CircleGeometry | PipeGeometry | BayWindowGeometry
|