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,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