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,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")
|
floorplan_scene/py.typed
ADDED
|
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
|
+
"""
|