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,146 @@
1
+ """SVG 展示数据:只用于 Scene/SVG 往返,不属于物理几何或 FPI/Legend 合同。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import Literal
9
+
10
+ from svg.path import Arc, parse_path
11
+
12
+ PAINT = {"fill", "stroke", "stroke-width", "stroke-linecap", "stroke-dasharray", "fill-rule",
13
+ "font-size", "text-anchor"}
14
+ DRAWING_ATTRIBUTES = {
15
+ "line": {"x1", "y1", "x2", "y2"},
16
+ "path": {"d"},
17
+ "polygon": {"points"},
18
+ "polyline": {"points"},
19
+ "circle": {"cx", "cy", "r"},
20
+ }
21
+
22
+
23
+ def _finite(value: str | float) -> float:
24
+ number = float(value)
25
+ if not math.isfinite(number) or abs(number) > 1_000_000:
26
+ raise ValueError("presentation coordinates must be finite millimetres within 1 km")
27
+ return number
28
+
29
+
30
+ def _numbers(value: str) -> list[float]:
31
+ return [_finite(item) for item in re.split(r"[\s,]+", value.strip())]
32
+
33
+
34
+ def validate_paint(attributes: dict[str, str]) -> None:
35
+ """同一白名单用于 JSON 和 SVG;不允许 URL、CSS、事件或外部资源。"""
36
+ if not isinstance(attributes, dict) or set(attributes) - PAINT:
37
+ raise ValueError("unsupported presentation paint attribute")
38
+ for key, value in attributes.items():
39
+ if not isinstance(value, str):
40
+ raise TypeError("presentation attributes must be strings")
41
+ if key in {"fill", "stroke"}:
42
+ if value != "none" and not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
43
+ raise ValueError("presentation colours require none or six-digit hex")
44
+ elif key in {"stroke-width", "font-size"}:
45
+ if _finite(value) < 0:
46
+ raise ValueError("presentation paint size cannot be negative")
47
+ elif key == "stroke-dasharray":
48
+ if value != "none" and any(number < 0 for number in _numbers(value)):
49
+ raise ValueError("presentation dash lengths cannot be negative")
50
+ elif value not in {"stroke-linecap": {"butt"}, "fill-rule": {"nonzero", "evenodd"},
51
+ "text-anchor": {"start", "middle", "end"}}[key]:
52
+ raise ValueError("unsupported presentation paint value")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SvgDecoration:
57
+ """家具局部毫米坐标下的一个有序绘图图元;没有实体 ID,不计入占地/碰撞/家具数量。
58
+
59
+ attributes 只允许 tag 对应的几何属性和 PAINT 白名单,值采用 SVG 字符串语法。
60
+ 禁止 XML、子节点、transform、href、style、事件和 data-* 语义;placement 由家具统一提供。
61
+ path 保留 d(含曲线与多个子路径),无需将外观线误转换为物理轮廓。
62
+ """
63
+
64
+ tag: Literal["line", "path", "polygon", "polyline", "circle"]
65
+ attributes: dict[str, str] # 几何与已解析继承的绘制属性;绝不存整段 XML。
66
+
67
+ def __post_init__(self) -> None:
68
+ geometry = DRAWING_ATTRIBUTES.get(self.tag)
69
+ if geometry is None or not isinstance(self.attributes, dict):
70
+ raise ValueError("unsupported decoration primitive")
71
+ if set(self.attributes) - geometry - PAINT or not geometry <= self.attributes.keys():
72
+ raise ValueError("invalid decoration attributes")
73
+ if any(not isinstance(value, str) for value in self.attributes.values()):
74
+ raise TypeError("decoration attributes must be strings")
75
+ if sum(len(value) for value in self.attributes.values()) > 1_000_000:
76
+ raise ValueError("decoration exceeds 1 MB")
77
+ validate_paint({key: value for key, value in self.attributes.items() if key in PAINT})
78
+ if self.tag == "path":
79
+ try:
80
+ parts = parse_path(self.attributes["d"])
81
+ except (ValueError, IndexError, ZeroDivisionError) as exc:
82
+ raise ValueError("invalid decoration path") from exc
83
+ if not parts or len(parts) > 10000:
84
+ raise ValueError("invalid decoration path length")
85
+ for part in parts:
86
+ for name in ("start", "end", "control", "control1", "control2"):
87
+ point = getattr(part, name, None)
88
+ if point is not None:
89
+ _finite(point.real)
90
+ _finite(point.imag)
91
+ if isinstance(part, Arc):
92
+ _finite(part.radius.real)
93
+ _finite(part.radius.imag)
94
+ _finite(part.rotation)
95
+ elif self.tag in {"polygon", "polyline"}:
96
+ values = _numbers(self.attributes["points"])
97
+ if len(values) % 2 or len(values) < (6 if self.tag == "polygon" else 4):
98
+ raise ValueError("decoration points require coordinate pairs")
99
+ else:
100
+ for key in geometry:
101
+ _finite(self.attributes[key])
102
+ if self.tag == "circle" and float(self.attributes["r"]) <= 0:
103
+ raise ValueError("decoration circle radius must be positive")
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class FurniturePresentation:
108
+ """可选家具外观,随 placement 平移/旋转;不改变 geometry、尺寸或方向。
109
+
110
+ decorations 保持绘制顺序;footprintIndex 为物理占地图元在它们之间的插入位置。
111
+ footprintStyle 保存占地的可见绘制属性(包括继承值),不保存第二份占地几何。
112
+ Scene→FPI/Legend/legend_doc 忽略本结构;FPI→Scene 不生成它。
113
+ """
114
+
115
+ decorations: tuple[SvgDecoration, ...] = ()
116
+ footprintStyle: dict[str, str] = field(default_factory=dict)
117
+ footprintIndex: int = 0
118
+
119
+ def __post_init__(self) -> None:
120
+ if len(self.decorations) > 10000:
121
+ raise ValueError("too many decoration primitives")
122
+ if type(self.footprintIndex) is not int or not 0 <= self.footprintIndex <= len(self.decorations):
123
+ raise ValueError("footprintIndex must be an insertion index in decorations")
124
+ validate_paint(self.footprintStyle)
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ScenePresentation:
129
+ """可选 SVG 文档画布,不是建筑边界、原图映射或页面相机状态。
130
+
131
+ viewBox=(x, y, width, height),单位毫米,采用 SVG 画布坐标(Y 向下),
132
+ 而非世界组 scale(1 -1) 内的 Y 向上坐标。SVG 导入时保存,导出时优先使用;
133
+ 缺省时沿用 originImage 或几何自动取景。FPI 导入不生成,FPI/Legend 导出忽略。
134
+ """
135
+
136
+ viewBox: tuple[float, float, float, float]
137
+
138
+ def __post_init__(self) -> None:
139
+ if len(self.viewBox) != 4:
140
+ raise ValueError("presentation viewBox requires four numbers")
141
+ for value in self.viewBox:
142
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
143
+ raise TypeError("presentation viewBox requires numbers")
144
+ _finite(value)
145
+ if min(self.viewBox[2:]) <= 0:
146
+ raise ValueError("presentation viewBox width and height must be positive")
File without changes
@@ -0,0 +1,315 @@
1
+ """Deterministic labels used only by visual previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import xml.etree.ElementTree as ET
7
+ from typing import Any
8
+
9
+ from shapely.geometry import LineString # type: ignore[import-untyped]
10
+ from shapely.geometry import box as shape_box
11
+
12
+ from .dimensions import dimension_outward_normal, scene_shell
13
+ from .model import FloorplanScene
14
+ from .shape_geometry import geometry_shape, object_shape
15
+ from .svg import NS
16
+
17
+ Box = tuple[float, float, float, float]
18
+ Label = tuple[str, str, float, float, float]
19
+
20
+
21
+ def _number(value: float) -> str:
22
+ value = round(value, 3)
23
+ return "0" if value == 0 else f"{value:g}"
24
+
25
+
26
+ def _label_box(x: float, y: float, text: str, font_size: float) -> Box:
27
+ label_width = max(2, sum(1 if ord(char) > 255 else 0.6 for char in text)) * font_size
28
+ return x - label_width / 2, y - font_size * 0.65, x + label_width / 2, y + font_size * 0.65
29
+
30
+
31
+ def _overlaps(box: Box, occupied: list[Box]) -> bool:
32
+ return any(
33
+ not (
34
+ box[2] <= other[0]
35
+ or box[0] >= other[2]
36
+ or box[3] <= other[1]
37
+ or box[1] >= other[3]
38
+ )
39
+ for other in occupied
40
+ )
41
+
42
+
43
+ def _rotated_box(box: Box, angle: float) -> Box:
44
+ center_x, center_y = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
45
+ half_width, half_height = (box[2] - box[0]) / 2, (box[3] - box[1]) / 2
46
+ radians = math.radians(angle)
47
+ x_extent = abs(math.cos(radians)) * half_width + abs(math.sin(radians)) * half_height
48
+ y_extent = abs(math.sin(radians)) * half_width + abs(math.cos(radians)) * half_height
49
+ return center_x - x_extent, center_y - y_extent, center_x + x_extent, center_y + y_extent
50
+
51
+
52
+ def _labels(scene: FloorplanScene, room_font: float, entity_font: float) -> list[Label]:
53
+ labels: list[Label] = []
54
+ for room in scene.rooms:
55
+ name = room.name or room.type
56
+ text = name if room.area is None else f"{name} {room.area:.2f}㎡"
57
+ labels.append((room.id, text, room.labelPoint[0], -room.labelPoint[1], room_font))
58
+ for floor_opening in scene.floorOpenings:
59
+ center = geometry_shape(floor_opening.geometry).representative_point()
60
+ labels.append((floor_opening.id, floor_opening.name or floor_opening.id, float(center.x), -float(center.y), entity_font))
61
+ for building in scene.buildings:
62
+ center = object_shape(building).centroid
63
+ labels.append((building.id, building.id, float(center.x), -float(center.y), entity_font))
64
+ for furniture in scene.furniture:
65
+ center = object_shape(furniture).centroid
66
+ labels.append((furniture.id, furniture.id, float(center.x), -float(center.y), entity_font))
67
+ for wall in scene.walls:
68
+ center = geometry_shape(wall.geometry, wall.thickness).centroid
69
+ labels.append((wall.id, wall.id, float(center.x), -float(center.y), entity_font))
70
+ for separator in scene.separators:
71
+ center = geometry_shape(separator.geometry).centroid
72
+ labels.append((separator.id, separator.id, float(center.x), -float(center.y), entity_font))
73
+ for opening in scene.openings:
74
+ if opening.type == "UNEQUAL_DOUBLE_DOOR":
75
+ continue # The symbol already combines this ID and its type label.
76
+ centers = [geometry_shape(part.geometry).interpolate(.5, normalized=True) for part in opening.segments]
77
+ points = [(point.x, point.y) for point in centers]
78
+ if points:
79
+ labels.append(
80
+ (
81
+ opening.id,
82
+ opening.id,
83
+ sum(point[0] for point in points) / len(points),
84
+ -sum(point[1] for point in points) / len(points),
85
+ entity_font,
86
+ )
87
+ )
88
+ return labels
89
+
90
+
91
+ def _line(
92
+ parent: ET.Element,
93
+ role: str,
94
+ start: tuple[float, float],
95
+ end: tuple[float, float],
96
+ stroke_width: float,
97
+ ) -> None:
98
+ ET.SubElement(
99
+ parent,
100
+ f"{{{NS}}}line",
101
+ {
102
+ "x1": _number(start[0]),
103
+ "y1": _number(start[1]),
104
+ "x2": _number(end[0]),
105
+ "y2": _number(end[1]),
106
+ "stroke-width": _number(stroke_width),
107
+ "data-role": role,
108
+ },
109
+ )
110
+
111
+
112
+ def _dimension_overlays(
113
+ root: ET.Element,
114
+ scene: FloorplanScene,
115
+ unit_per_pixel: float,
116
+ ) -> tuple[list[Box], list[Box]]:
117
+ occupied: list[Box] = []
118
+ dimension_shapes: list[Any] = []
119
+ bounds: list[Box] = []
120
+ shell = scene_shell(scene)
121
+ layer = ET.SubElement(root, f"{{{NS}}}g", {"data-render-layer": "dimensions"})
122
+ for dimension in scene.dimensionAnnotations:
123
+ normal = dimension_outward_normal(scene, dimension)
124
+ if normal is None:
125
+ continue
126
+ start, end = dimension.geometry.start, dimension.geometry.end
127
+ dx, dy = end[0] - start[0], end[1] - start[1]
128
+ length = math.hypot(dx, dy)
129
+ if length == 0:
130
+ continue
131
+ tangent = (dx / length, -dy / length)
132
+ svg_normal = (normal[0], -normal[1])
133
+ angle = math.degrees(math.atan2(-dy, dx))
134
+ if angle > 90:
135
+ angle -= 180
136
+ elif angle < -90:
137
+ angle += 180
138
+ clearance = 6 * unit_per_pixel
139
+ label_size = 14 * unit_per_pixel
140
+ exterior_offset = 0.0
141
+ if shell is not None:
142
+ min_x, min_y, max_x, max_y = shell.bounds
143
+ exterior_offset = max(
144
+ 0.0,
145
+ *(normal[0] * (x - start[0]) + normal[1] * (y - start[1])
146
+ for x in (min_x, max_x) for y in (min_y, max_y)),
147
+ )
148
+ chosen: tuple[tuple[float, float], tuple[float, float], Box] | None = None
149
+ offset = exterior_offset + 32 * unit_per_pixel
150
+ while chosen is None:
151
+ scene_start = (start[0] + normal[0] * offset, start[1] + normal[1] * offset)
152
+ scene_end = (end[0] + normal[0] * offset, end[1] + normal[1] * offset)
153
+ display_start = (scene_start[0], -scene_start[1])
154
+ display_end = (scene_end[0], -scene_end[1])
155
+ text_point = (
156
+ (display_start[0] + display_end[0]) / 2 + svg_normal[0] * 10 * unit_per_pixel,
157
+ (display_start[1] + display_end[1]) / 2 + svg_normal[1] * 10 * unit_per_pixel,
158
+ )
159
+ label_box = _rotated_box(
160
+ _label_box(text_point[0], text_point[1], str(dimension.value), label_size), angle
161
+ )
162
+ line_shape = LineString((display_start, display_end)).buffer(clearance)
163
+ text_shape = shape_box(*label_box)
164
+ collisions = [
165
+ other for other in dimension_shapes
166
+ if line_shape.intersects(other) or text_shape.intersects(other)
167
+ ]
168
+ if not collisions:
169
+ chosen = display_start, display_end, label_box
170
+ dimension_shapes.extend((line_shape, text_shape))
171
+ break
172
+ # Move past the colliding shapes along the normal. A diagonal line's bounding
173
+ # box is not its painted footprint, and no valid dimension may be silently dropped.
174
+ blocking_max = max(
175
+ x * svg_normal[0] + y * svg_normal[1]
176
+ for shape in collisions for x in (shape.bounds[0], shape.bounds[2])
177
+ for y in (shape.bounds[1], shape.bounds[3])
178
+ )
179
+ candidate_min = min(
180
+ x * svg_normal[0] + y * svg_normal[1]
181
+ for shape in (line_shape, text_shape) for x in (shape.bounds[0], shape.bounds[2])
182
+ for y in (shape.bounds[1], shape.bounds[3])
183
+ )
184
+ offset += max(clearance, blocking_max - candidate_min + clearance)
185
+
186
+ display_start, display_end, label_box = chosen
187
+ holder = ET.SubElement(layer, f"{{{NS}}}g", {"data-render-dimension": dimension.id})
188
+ stroke_width = 1.2 * unit_per_pixel
189
+ extension_gap, extension_overrun = 4 * unit_per_pixel, 6 * unit_per_pixel
190
+ source_points = ((start[0], -start[1]), (end[0], -end[1]))
191
+ for source, display in zip(source_points, (display_start, display_end), strict=True):
192
+ extension_start = (
193
+ source[0] + svg_normal[0] * extension_gap,
194
+ source[1] + svg_normal[1] * extension_gap,
195
+ )
196
+ extension_end = (
197
+ display[0] + svg_normal[0] * extension_overrun,
198
+ display[1] + svg_normal[1] * extension_overrun,
199
+ )
200
+ _line(holder, "extension-line", extension_start, extension_end, stroke_width)
201
+ _line(holder, "dimension-line", display_start, display_end, stroke_width)
202
+
203
+ tick = 8 * unit_per_pixel
204
+ for point in (display_start, display_end):
205
+ tick_start = (
206
+ point[0] - (tangent[0] + svg_normal[0]) * tick / 2,
207
+ point[1] - (tangent[1] + svg_normal[1]) * tick / 2,
208
+ )
209
+ tick_end = (
210
+ point[0] + (tangent[0] + svg_normal[0]) * tick / 2,
211
+ point[1] + (tangent[1] + svg_normal[1]) * tick / 2,
212
+ )
213
+ _line(holder, "end-tick", tick_start, tick_end, stroke_width)
214
+
215
+ text_x = (label_box[0] + label_box[2]) / 2
216
+ text_y = (label_box[1] + label_box[3]) / 2
217
+ text = ET.SubElement(
218
+ holder,
219
+ f"{{{NS}}}text",
220
+ {
221
+ "x": _number(text_x),
222
+ "y": _number(text_y),
223
+ "font-size": _number(label_size),
224
+ "data-role": "dimension-value",
225
+ },
226
+ )
227
+ if abs(angle) > 0.001:
228
+ text.set("transform", f"rotate({_number(angle)} {_number(text_x)} {_number(text_y)})")
229
+ text.text = str(dimension.value)
230
+ line_box = (
231
+ min(display_start[0], display_end[0]) - clearance,
232
+ min(display_start[1], display_end[1]) - clearance,
233
+ max(display_start[0], display_end[0]) + clearance,
234
+ max(display_start[1], display_end[1]) + clearance,
235
+ )
236
+ occupied.extend((label_box, line_box))
237
+ bounds.extend(
238
+ (
239
+ label_box,
240
+ (
241
+ min(start[0], end[0], display_start[0], display_end[0]),
242
+ min(-start[1], -end[1], display_start[1], display_end[1]),
243
+ max(start[0], end[0], display_start[0], display_end[0]),
244
+ max(-start[1], -end[1], display_start[1], display_end[1]),
245
+ ),
246
+ )
247
+ )
248
+ if not list(layer):
249
+ root.remove(layer)
250
+ return occupied, bounds
251
+
252
+
253
+ def add_render_labels(
254
+ scene: FloorplanScene, svg: str, width: int, height: int | None = None,
255
+ ) -> str:
256
+ """Add dimensions and non-overlapping labels to an internal preview SVG."""
257
+ root = ET.fromstring(svg)
258
+ view_x, view_y, view_width, view_height = map(float, root.attrib["viewBox"].split())
259
+ unit_per_pixel = max(view_width / width, view_height / height if height else 0)
260
+ occupied, overlay_bounds = _dimension_overlays(root, scene, unit_per_pixel)
261
+ if overlay_bounds:
262
+ margin = 8 * unit_per_pixel
263
+ view_right, view_bottom = view_x + view_width, view_y + view_height
264
+ view_x = min(view_x, *(box[0] - margin for box in overlay_bounds))
265
+ view_y = min(view_y, *(box[1] - margin for box in overlay_bounds))
266
+ view_right = max(view_right, *(box[2] + margin for box in overlay_bounds))
267
+ view_bottom = max(view_bottom, *(box[3] + margin for box in overlay_bounds))
268
+ view_width, view_height = view_right - view_x, view_bottom - view_y
269
+ root.set(
270
+ "viewBox",
271
+ " ".join(_number(value) for value in (view_x, view_y, view_width, view_height)),
272
+ )
273
+ layer = ET.SubElement(root, f"{{{NS}}}g", {"data-render-layer": "labels"})
274
+ for entity_id, text, anchor_x, anchor_y, font_size in _labels(
275
+ scene, 17 * unit_per_pixel, 11 * unit_per_pixel
276
+ ):
277
+ candidates = [(0.0, 0.0)]
278
+ for distance in (font_size * 1.4, font_size * 2.8):
279
+ candidates.extend(
280
+ (
281
+ (0, -distance),
282
+ (0, distance),
283
+ (distance, 0),
284
+ (-distance, 0),
285
+ (distance, -distance),
286
+ (-distance, -distance),
287
+ (distance, distance),
288
+ (-distance, distance),
289
+ )
290
+ )
291
+ for offset_x, offset_y in candidates:
292
+ x, y = anchor_x + offset_x, anchor_y + offset_y
293
+ box = _label_box(x, y, text, font_size)
294
+ if (
295
+ box[0] < view_x
296
+ or box[1] < view_y
297
+ or box[2] > view_x + view_width
298
+ or box[3] > view_y + view_height
299
+ or _overlaps(box, occupied)
300
+ ):
301
+ continue
302
+ ET.SubElement(
303
+ layer,
304
+ f"{{{NS}}}text",
305
+ {
306
+ "x": str(x),
307
+ "y": str(y),
308
+ "font-size": str(font_size),
309
+ "data-render-label": entity_id,
310
+ },
311
+ ).text = text
312
+ occupied.append(box)
313
+ break
314
+ ET.register_namespace("", NS)
315
+ return ET.tostring(root, encoding="unicode")
@@ -0,0 +1,126 @@
1
+ """Shared presentation colors and rules for SVG and PNG previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from .model import DOOR_OPENING_TYPES, PASSAGE_OPENING_TYPES, WINDOW_OPENING_TYPES
8
+
9
+ DEFAULT_RENDER_COLORS = {
10
+ "background": "#FFFFFF",
11
+ "zone": "#EEF0F1",
12
+ "room": "#F0F1EE",
13
+ "room.living": "#F4EFE6",
14
+ "room.dining": "#F4EFE6",
15
+ "room.living_dining": "#F4EFE6",
16
+ "room.bedroom": "#F2E8DD",
17
+ "room.kitchen": "#EAF1E7",
18
+ "room.bathroom": "#E5F1F4",
19
+ "room.balcony": "#EDF3ED",
20
+ "room.circulation": "#ECEFF1",
21
+ "room.study": "#EEEBF2",
22
+ "room.storage": "#EEECE7",
23
+ "room.closet": "#EEECE7",
24
+ "room.laundry": "#EAF1E7",
25
+ "room.office": "#EEEBF2",
26
+ "room.multipurpose": "#F0F1EE",
27
+ "room.outline": "#C4CBD0",
28
+ "furniture": "#FAFBFC",
29
+ "furniture.outline": "#718089",
30
+ "floorOpening": "#E3F2FD",
31
+ "floorOpening.outline": "#81949F",
32
+ "building": "#A9B6C0",
33
+ "building.outline": "#606C72",
34
+ "wall": "#AEB7BB",
35
+ "wall.load_bearing": "#606C72",
36
+ "separator": "#9CA9AB",
37
+ "opening.door": "#AD7C4C",
38
+ "opening.passage": "#749184",
39
+ "opening.window": "#3485A5",
40
+ "opening.niche": "#9A8170",
41
+ "opening.unknown": "#718089",
42
+ "dimension": "#81949F",
43
+ "label": "#344B5C",
44
+ "hover.fill": "#E3EDF1",
45
+ "hover.stroke": "#48758A",
46
+ "selected.fill": "#D3E4EB",
47
+ "selected.stroke": "#315F74",
48
+ }
49
+
50
+
51
+
52
+ def render_stylesheet(colors: Mapping[str, str] | None = None) -> str:
53
+ """Build preview CSS; semantic SVG files remain free of presentation colors."""
54
+ palette = {**DEFAULT_RENDER_COLORS, **(colors or {})}
55
+ room_rules = "\n".join(
56
+ f'[data-layer="rooms"] > [id][data-type="{kind}"] {{ fill: {palette[f"room.{kind}"]}; }}'
57
+ for kind in (
58
+ "living",
59
+ "dining",
60
+ "living_dining",
61
+ "bedroom",
62
+ "kitchen",
63
+ "bathroom",
64
+ "balcony",
65
+ "circulation", "study", "storage", "closet", "laundry", "office", "multipurpose",
66
+ )
67
+ )
68
+ opening_rules = "\n".join(
69
+ f'{", ".join(f"""[data-layer="openings"] > [data-type="{kind}"]""" for kind in types)} '
70
+ f'{{ stroke: {palette[f"opening.{category}"]}; }}'
71
+ for category, types in (
72
+ ("door", sorted(DOOR_OPENING_TYPES)),
73
+ ("passage", sorted(PASSAGE_OPENING_TYPES)),
74
+ ("window", sorted(WINDOW_OPENING_TYPES)),
75
+ ("niche", ["NICHE"]),
76
+ )
77
+ )
78
+ opening_rules += (
79
+ f'\n[data-layer="openings"] > [id]:not([data-type]) '
80
+ f'{{ stroke: {palette["opening.unknown"]}; }}'
81
+ )
82
+ return f"""
83
+ [data-layer="zones"] > [id] {{ fill: {palette["zone"]}; }}
84
+ [data-layer="rooms"] > [id] {{ fill: {palette["room"]}; stroke: {palette["room.outline"]}; stroke-width: 7; }}
85
+ {room_rules}
86
+ [data-layer="floorOpenings"] > [id] {{ fill: {palette["floorOpening"]}; stroke: {palette["floorOpening.outline"]}; stroke-width: 16; stroke-dasharray: 60 40; }}
87
+ [data-layer="furniture"] > [id] {{ fill: {palette["furniture"]}; stroke: {palette["furniture.outline"]}; stroke-width: 12; }}
88
+ [data-layer="buildings"] [data-type="roof"] > [data-role="ridge"] {{ fill: none; stroke-width: 20; }}
89
+ [data-layer="buildings"] > [id] {{ fill: {palette["building"]}; stroke: {palette["building.outline"]}; stroke-width: 12; }}
90
+ [data-layer="buildings"] > [id][data-type="inspection_hatch"] {{ fill: none; }}
91
+ [data-layer="walls"] > [id] {{ fill: {palette["wall"]}; stroke: {palette["wall"]}; }}
92
+ [data-layer="walls"] > [id][data-load-bearing="true"] {{ fill: {palette["wall.load_bearing"]}; stroke: {palette["wall.load_bearing"]}; }}
93
+ [data-layer="separators"] {{ fill: none; stroke: {palette["separator"]}; stroke-dasharray: 80 80; }}
94
+ [data-layer="openings"] {{ fill: none; }}
95
+ {opening_rules}
96
+ [data-layer="dimensions"] {{ display: none; }}
97
+ [data-layer="walls"] > [id] > *, [data-layer="furniture"] > [id] > *,
98
+ [data-layer="buildings"] > [id] > *, [data-layer="openings"] > [id] > * {{ stroke: inherit; }}
99
+ [data-render-layer="dimensions"] {{ fill: none; stroke: {palette["dimension"]}; pointer-events: none; }}
100
+ [data-render-layer="dimensions"] text {{ fill: {palette["dimension"]}; stroke: {palette["background"]};
101
+ stroke-width: 3px; paint-order: stroke; font-family: "Noto Sans SC", sans-serif;
102
+ text-anchor: middle; dominant-baseline: central; pointer-events: none; }}
103
+ [data-render-label] {{ fill: {palette["label"]}; stroke: {palette["background"]}; stroke-width: 3px;
104
+ paint-order: stroke; font-family: "Noto Sans SC", sans-serif; text-anchor: middle; dominant-baseline: central;
105
+ pointer-events: none; }}
106
+ """
107
+
108
+
109
+ def rich_stylesheet(colors: Mapping[str, str] | None = None) -> str:
110
+ """Opening symbols and interaction use the same rules on screen and in exports."""
111
+ palette = {**DEFAULT_RENDER_COLORS, **(colors or {})}
112
+ return render_stylesheet(palette) + f"""
113
+ svg {{ background: {palette['background']}; }}
114
+ [id] {{ cursor: pointer; outline: none; transition: fill 120ms ease, stroke 120ms ease; }}
115
+ [data-layer] > [id]:hover, [data-layer] > [id]:focus-visible, [data-layer] > [id].is-hovered {{ fill: {palette['hover.fill']}; stroke: {palette['hover.stroke']}; }}
116
+ [data-layer] > [id][aria-pressed="true"], [data-layer] > [id].is-selected {{ fill: {palette['selected.fill']}; stroke: {palette['selected.stroke']}; }}
117
+ [data-layer="openings"] > [id] > [data-opening-span] {{ display: none; }}
118
+ [data-layer="openings"] > [id] > .opening-cutout {{ stroke: {palette['background']}; }}
119
+ .opening-jamb, .opening-panel, .opening-edge, .door-leaf, .door-swing, .furniture-direction {{ fill: none; pointer-events: none; }}
120
+ .opening-jamb, .opening-panel {{ stroke-width: 16; }}
121
+ .opening-edge {{ stroke-width: 12; stroke-dasharray: 50 50; }}
122
+ [data-layer="openings"] .opening-type-label {{ fill: {palette['opening.door']}; stroke: {palette['background']};
123
+ stroke-width: 16; paint-order: stroke; dominant-baseline: central; font-family: "Noto Sans SC", sans-serif; }}
124
+ .door-leaf, .door-swing {{ stroke-width: 24; }}
125
+ .furniture-direction {{ stroke: {palette['selected.stroke']}; stroke-width: 18; stroke-linecap: round; stroke-linejoin: round; }}
126
+ """