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,20 @@
|
|
|
1
|
+
"""Floor plan data structures and format conversion; no application services."""
|
|
2
|
+
from .fpi import FpiImport, FpiSupplement, export_fpi, import_fpi
|
|
3
|
+
from .model import FloorplanScene, read_json, scene_from_dict, scene_to_dict
|
|
4
|
+
from .rich_svg import enrich_svg, scene_to_rich_svg
|
|
5
|
+
from .svg import scene_to_svg, svg_to_scene
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FloorplanScene",
|
|
9
|
+
"FpiImport",
|
|
10
|
+
"FpiSupplement",
|
|
11
|
+
"enrich_svg",
|
|
12
|
+
"export_fpi",
|
|
13
|
+
"import_fpi",
|
|
14
|
+
"read_json",
|
|
15
|
+
"scene_from_dict",
|
|
16
|
+
"scene_to_dict",
|
|
17
|
+
"scene_to_rich_svg",
|
|
18
|
+
"scene_to_svg",
|
|
19
|
+
"svg_to_scene",
|
|
20
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"unit": "mm",
|
|
3
|
+
"source": "kam-service Constants.java and AbstractFpiBayWindowSillStrategy.java",
|
|
4
|
+
"bay_window_sill": {
|
|
5
|
+
"windowHeight": 1700,
|
|
6
|
+
"offset": 450,
|
|
7
|
+
"upperFloorThickness": 120,
|
|
8
|
+
"lowerFloorThickness": 120,
|
|
9
|
+
"wallThickness": 120,
|
|
10
|
+
"beamThickness": 120
|
|
11
|
+
},
|
|
12
|
+
"beam": {
|
|
13
|
+
"height": 600
|
|
14
|
+
},
|
|
15
|
+
"beamSource": "kam-service@bb34fd64d82800b6a507006a045891086295d074 kammodel/constrants/Constant.java DEFAULT_BEAM_HEIGHT"
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Bundled component defaults; explicit source values always take precedence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@lru_cache(maxsize=1)
|
|
10
|
+
def component_defaults() -> dict[str, Any]:
|
|
11
|
+
return dict(json.loads(files(__package__).joinpath("component-defaults.json").read_text()))
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Dimensions that make the current Scene's geometric scale readable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from statistics import median
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from shapely.errors import GEOSException # type: ignore[import-untyped]
|
|
11
|
+
from shapely.geometry import LineString # type: ignore[import-untyped]
|
|
12
|
+
from shapely.geometry import Point as ShapelyPoint
|
|
13
|
+
from shapely.ops import unary_union # type: ignore[import-untyped]
|
|
14
|
+
|
|
15
|
+
from .geometry import polygon_parts
|
|
16
|
+
from .model import DimensionAnnotation, FloorplanScene, Wall
|
|
17
|
+
from .shape_geometry import geometry_shape, wall_shape
|
|
18
|
+
from .shapes import LineGeometry, Point
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scene_shell(scene: FloorplanScene) -> Any | None:
|
|
22
|
+
"""Return the largest connected room-and-wall shell used for exterior checks."""
|
|
23
|
+
shapes = [geometry_shape(room.geometry) for room in scene.rooms]
|
|
24
|
+
shapes.extend(wall_shape(wall) for wall in scene.walls)
|
|
25
|
+
# A crossed outline has no reliable exterior. Keep the source geometry visible
|
|
26
|
+
# and use the existing no-shell label placement instead of failing the preview.
|
|
27
|
+
if any(not shape.is_valid for shape in shapes):
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
polygons = polygon_parts(unary_union(shapes).buffer(0)) if shapes else []
|
|
31
|
+
except GEOSException:
|
|
32
|
+
return None
|
|
33
|
+
return max(polygons, key=lambda polygon: polygon.area, default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _typical_thickness(scene: FloorplanScene) -> float:
|
|
37
|
+
values = [wall.thickness for wall in scene.walls if wall.thickness is not None and wall.thickness > 0]
|
|
38
|
+
return float(median(values)) if values else 200.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _wall_outward_normal(shell: Any, wall: Wall, fallback_thickness: float) -> tuple[Point, int] | None:
|
|
42
|
+
if not isinstance(wall.geometry, LineGeometry):
|
|
43
|
+
return None
|
|
44
|
+
start, end = wall.geometry.start, wall.geometry.end
|
|
45
|
+
dx, dy = end[0] - start[0], end[1] - start[1]
|
|
46
|
+
length = math.hypot(dx, dy)
|
|
47
|
+
if length < 0.5:
|
|
48
|
+
return None
|
|
49
|
+
normal = (-dy / length, dx / length)
|
|
50
|
+
half_thickness = (wall.thickness or fallback_thickness) / 2
|
|
51
|
+
if LineString((start, end)).distance(shell.exterior) > half_thickness + 10:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
outside_signs: list[int] = []
|
|
55
|
+
distance = half_thickness + 5
|
|
56
|
+
for ratio in (0.25, 0.5, 0.75):
|
|
57
|
+
point = (start[0] + dx * ratio, start[1] + dy * ratio)
|
|
58
|
+
plus = shell.covers(
|
|
59
|
+
ShapelyPoint(point[0] + normal[0] * distance, point[1] + normal[1] * distance)
|
|
60
|
+
)
|
|
61
|
+
minus = shell.covers(
|
|
62
|
+
ShapelyPoint(point[0] - normal[0] * distance, point[1] - normal[1] * distance)
|
|
63
|
+
)
|
|
64
|
+
if plus != minus:
|
|
65
|
+
outside_signs.append(1 if not plus else -1)
|
|
66
|
+
if len(outside_signs) < 2 or abs(sum(outside_signs)) != len(outside_signs):
|
|
67
|
+
return None
|
|
68
|
+
sign = outside_signs[0]
|
|
69
|
+
return (normal[0] * sign, normal[1] * sign), len(outside_signs)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dimension_outward_normal(scene: FloorplanScene, dimension: DimensionAnnotation) -> Point | None:
|
|
73
|
+
"""Prefer the referenced exterior wall; otherwise face away from the plan center."""
|
|
74
|
+
wall = next((item for item in scene.walls if item.id == dimension.refId), None)
|
|
75
|
+
shell = scene_shell(scene)
|
|
76
|
+
if wall is not None and shell is not None:
|
|
77
|
+
result = _wall_outward_normal(shell, wall, _typical_thickness(scene))
|
|
78
|
+
if result is not None:
|
|
79
|
+
return result[0]
|
|
80
|
+
start, end = dimension.geometry.start, dimension.geometry.end
|
|
81
|
+
dx, dy = end[0] - start[0], end[1] - start[1]
|
|
82
|
+
length = math.hypot(dx, dy)
|
|
83
|
+
if length == 0:
|
|
84
|
+
return None
|
|
85
|
+
normal = (-dy / length, dx / length)
|
|
86
|
+
if shell is not None:
|
|
87
|
+
center = shell.centroid
|
|
88
|
+
projection = ((start[0] + end[0]) / 2 - center.x) * normal[0] + (
|
|
89
|
+
(start[1] + end[1]) / 2 - center.y
|
|
90
|
+
) * normal[1]
|
|
91
|
+
if projection < 0:
|
|
92
|
+
normal = (-normal[0], -normal[1])
|
|
93
|
+
return normal
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def with_default_dimension(scene: FloorplanScene) -> FloorplanScene:
|
|
97
|
+
"""Add one useful exterior-wall dimension when the Scene has none."""
|
|
98
|
+
if scene.dimensionAnnotations:
|
|
99
|
+
return scene
|
|
100
|
+
shell = scene_shell(scene)
|
|
101
|
+
if shell is None:
|
|
102
|
+
return scene
|
|
103
|
+
fallback_thickness = _typical_thickness(scene)
|
|
104
|
+
candidates: list[tuple[float, int, int, Wall]] = []
|
|
105
|
+
for index, wall in enumerate(scene.walls):
|
|
106
|
+
result = _wall_outward_normal(shell, wall, fallback_thickness)
|
|
107
|
+
if result is None or not isinstance(wall.geometry, LineGeometry):
|
|
108
|
+
continue
|
|
109
|
+
length = math.dist(wall.geometry.start, wall.geometry.end)
|
|
110
|
+
if round(length) > 0:
|
|
111
|
+
candidates.append((length, result[1], -index, wall))
|
|
112
|
+
if not candidates:
|
|
113
|
+
return scene
|
|
114
|
+
|
|
115
|
+
wall = max(candidates)[3]
|
|
116
|
+
assert isinstance(wall.geometry, LineGeometry)
|
|
117
|
+
used_ids = {
|
|
118
|
+
item.id
|
|
119
|
+
for collection in (
|
|
120
|
+
scene.rooms,
|
|
121
|
+
scene.walls,
|
|
122
|
+
scene.separators,
|
|
123
|
+
scene.openings,
|
|
124
|
+
scene.buildings,
|
|
125
|
+
scene.floorOpenings,
|
|
126
|
+
scene.furniture,
|
|
127
|
+
scene.zones,
|
|
128
|
+
)
|
|
129
|
+
for item in collection
|
|
130
|
+
}
|
|
131
|
+
number = 1
|
|
132
|
+
while f"D{number}" in used_ids:
|
|
133
|
+
number += 1
|
|
134
|
+
dimension = DimensionAnnotation(
|
|
135
|
+
f"D{number}",
|
|
136
|
+
round(math.dist(wall.geometry.start, wall.geometry.end)),
|
|
137
|
+
wall.geometry,
|
|
138
|
+
wall.id,
|
|
139
|
+
)
|
|
140
|
+
return replace(scene, dimensionAnnotations=(dimension,))
|