rikhoffbauer2 commited on
Commit
a51ee55
Β·
verified Β·
1 Parent(s): 75c5ed1

Upload floorplan/geometry.py

Browse files
Files changed (1) hide show
  1. floorplan/geometry.py +331 -0
floorplan/geometry.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Geometry engine β€” compute wall polygons, detect rooms, and handle centerline operations.
3
+
4
+ All operations use Shapely for robust computational geometry.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import Optional
11
+
12
+ import numpy as np
13
+ from shapely import ops
14
+ from shapely.geometry import (
15
+ LineString,
16
+ MultiLineString,
17
+ MultiPolygon,
18
+ Point,
19
+ Polygon,
20
+ )
21
+ from shapely.ops import polygonize, unary_union
22
+
23
+ from .schema import FloorPlan, Room, RoomLabel, Wall, WallFaceRef, WallSide, Point2D
24
+
25
+
26
+ # ──────────────────────────────────────────────
27
+ # Centerline utilities
28
+ # ──────────────────────────────────────────────
29
+
30
+ def compute_centerline_length(coords: list[tuple[float, float]]) -> float:
31
+ """Total length of a polyline defined by coordinate pairs."""
32
+ return LineString(coords).length
33
+
34
+
35
+ def interpolate_along_centerline(
36
+ coords: list[tuple[float, float]],
37
+ distance: float,
38
+ ) -> tuple[float, float]:
39
+ """Find the point at a given distance along a centerline polyline."""
40
+ line = LineString(coords)
41
+ pt = line.interpolate(distance)
42
+ return (pt.x, pt.y)
43
+
44
+
45
+ def normal_at_distance(
46
+ coords: list[tuple[float, float]],
47
+ distance: float,
48
+ ) -> tuple[float, float]:
49
+ """Unit normal vector (pointing LEFT of travel direction) at a distance along centerline."""
50
+ line = LineString(coords)
51
+ eps = min(0.001, line.length * 0.001)
52
+
53
+ d0 = max(0, distance - eps)
54
+ d1 = min(line.length, distance + eps)
55
+ p0 = line.interpolate(d0)
56
+ p1 = line.interpolate(d1)
57
+
58
+ dx = p1.x - p0.x
59
+ dy = p1.y - p0.y
60
+ length = math.hypot(dx, dy)
61
+ if length < 1e-12:
62
+ return (0.0, 1.0)
63
+
64
+ # Left-pointing normal: rotate direction 90Β° CCW
65
+ nx = -dy / length
66
+ ny = dx / length
67
+ return (nx, ny)
68
+
69
+
70
+ def centerline_to_linestring(wall: Wall) -> LineString:
71
+ """Convert a Wall's centerline to a Shapely LineString."""
72
+ return LineString(wall.centerline_coords)
73
+
74
+
75
+ # ──────────────────────────────────────────────
76
+ # Wall polygon computation
77
+ # ──────────────────────────────────────────────
78
+
79
+ def wall_to_polygon(wall: Wall) -> Polygon:
80
+ """Compute the thick wall polygon by buffering the centerline.
81
+
82
+ Uses flat end caps (cap_style=2) so wall ends are square-cut,
83
+ enabling clean joins with adjacent walls.
84
+ """
85
+ line = centerline_to_linestring(wall)
86
+ half_t = wall.thickness / 2.0
87
+ # cap_style: 1=round, 2=flat, 3=square
88
+ # join_style: 1=round, 2=mitre, 3=bevel
89
+ poly = line.buffer(half_t, cap_style=2, join_style=2)
90
+ if not poly.is_valid:
91
+ poly = poly.buffer(0) # fix self-intersections
92
+ return poly
93
+
94
+
95
+ def wall_to_left_right_lines(wall: Wall) -> tuple[LineString, LineString]:
96
+ """Compute the left and right offset lines of a wall.
97
+
98
+ Left/right determined by walking along centerline from first to last point.
99
+ Left = your left side, Right = your right side.
100
+
101
+ Returns (left_line, right_line).
102
+ """
103
+ line = centerline_to_linestring(wall)
104
+ half_t = wall.thickness / 2.0
105
+
106
+ left_line = line.parallel_offset(half_t, side="left")
107
+ right_line = line.parallel_offset(half_t, side="right")
108
+
109
+ # Shapely's parallel_offset reverses direction for right side β€” fix it
110
+ if isinstance(right_line, LineString) and len(right_line.coords) >= 2:
111
+ # Check if right_line goes in same direction as centerline
112
+ cl_start = np.array(line.coords[0])
113
+ r_start = np.array(right_line.coords[0])
114
+ r_end = np.array(right_line.coords[-1])
115
+ if np.linalg.norm(r_end - cl_start) < np.linalg.norm(r_start - cl_start):
116
+ right_line = LineString(list(right_line.coords)[::-1])
117
+
118
+ return (left_line, right_line)
119
+
120
+
121
+ def wall_union(walls: list[Wall]) -> Polygon | MultiPolygon:
122
+ """Union of all wall polygons."""
123
+ polys = [wall_to_polygon(w) for w in walls]
124
+ return unary_union(polys)
125
+
126
+
127
+ # ──────────────────────────────────────────────
128
+ # Opening geometry
129
+ # ──────────────────────────────────────────────
130
+
131
+ def opening_to_gap_polygon(wall: Wall, opening_idx: int) -> Polygon:
132
+ """Compute the polygon of a door/window gap cut through a wall.
133
+
134
+ The gap is a rectangle perpendicular to the wall at the opening location,
135
+ spanning the full wall thickness.
136
+ """
137
+ opening = wall.openings[opening_idx]
138
+ coords = wall.centerline_coords
139
+ half_t = wall.thickness / 2.0
140
+
141
+ # Get start and end points along centerline
142
+ start_pt = interpolate_along_centerline(coords, opening.start)
143
+ end_pt = interpolate_along_centerline(coords, opening.start + opening.length)
144
+
145
+ # Get normals at start and end
146
+ n_start = normal_at_distance(coords, opening.start)
147
+ n_end = normal_at_distance(coords, opening.start + opening.length)
148
+
149
+ # Build the gap rectangle: offset start/end points by Β±half_thickness along normal
150
+ margin = half_t * 1.1 # slight margin for clean boolean ops
151
+ p1 = (start_pt[0] + n_start[0] * margin, start_pt[1] + n_start[1] * margin)
152
+ p2 = (end_pt[0] + n_end[0] * margin, end_pt[1] + n_end[1] * margin)
153
+ p3 = (end_pt[0] - n_end[0] * margin, end_pt[1] - n_end[1] * margin)
154
+ p4 = (start_pt[0] - n_start[0] * margin, start_pt[1] - n_start[1] * margin)
155
+
156
+ return Polygon([p1, p2, p3, p4])
157
+
158
+
159
+ def wall_polygon_with_openings(wall: Wall) -> Polygon | MultiPolygon:
160
+ """Wall polygon with opening gaps cut out."""
161
+ poly = wall_to_polygon(wall)
162
+ for i in range(len(wall.openings)):
163
+ gap = opening_to_gap_polygon(wall, i)
164
+ poly = poly.difference(gap)
165
+ if not poly.is_valid:
166
+ poly = poly.buffer(0)
167
+ return poly
168
+
169
+
170
+ # ──────────────────────────────────────────────
171
+ # Room detection
172
+ # ──────────────────────────────────────────────
173
+
174
+ def detect_rooms_from_walls(
175
+ walls: list[Wall],
176
+ min_area: float = 1.0,
177
+ floor_boundary: Optional[Polygon] = None,
178
+ ) -> list[Polygon]:
179
+ """Detect rooms as enclosed regions between walls.
180
+
181
+ Algorithm:
182
+ 1. Build centerline graph from all walls
183
+ 2. Use Shapely polygonize() to find all enclosed faces
184
+ 3. Filter by minimum area
185
+
186
+ For thick walls, we also try the subtraction approach:
187
+ 1. Union all wall polygons
188
+ 2. Subtract from floor boundary (or convex hull)
189
+ 3. Remaining polygons = rooms
190
+
191
+ Returns both approaches merged and deduplicated.
192
+ """
193
+ rooms: list[Polygon] = []
194
+
195
+ # Approach 1: Centerline polygonize
196
+ centerlines = [centerline_to_linestring(w) for w in walls]
197
+ merged_lines = unary_union(centerlines)
198
+
199
+ # Ensure we have a collection of lines for polygonize
200
+ if isinstance(merged_lines, LineString):
201
+ merged_lines = MultiLineString([merged_lines])
202
+
203
+ centerline_rooms = list(polygonize(merged_lines))
204
+ rooms.extend([r for r in centerline_rooms if r.area >= min_area])
205
+
206
+ # Approach 2: Wall subtraction (handles thick walls better)
207
+ if walls:
208
+ all_walls = wall_union(walls)
209
+ if floor_boundary is None:
210
+ # Use convex hull of all wall polygons + some margin
211
+ floor_boundary = all_walls.convex_hull.buffer(0.01)
212
+
213
+ floor_minus_walls = floor_boundary.difference(all_walls)
214
+ if not floor_minus_walls.is_valid:
215
+ floor_minus_walls = floor_minus_walls.buffer(0)
216
+
217
+ if isinstance(floor_minus_walls, MultiPolygon):
218
+ subtraction_rooms = [
219
+ g for g in floor_minus_walls.geoms if g.area >= min_area
220
+ ]
221
+ elif isinstance(floor_minus_walls, Polygon) and floor_minus_walls.area >= min_area:
222
+ subtraction_rooms = [floor_minus_walls]
223
+ else:
224
+ subtraction_rooms = []
225
+
226
+ # If centerline approach found rooms, use those (more precise topology)
227
+ # If not, fall back to subtraction approach
228
+ if not rooms and subtraction_rooms:
229
+ rooms = subtraction_rooms
230
+ elif subtraction_rooms and not rooms:
231
+ rooms = subtraction_rooms
232
+
233
+ return rooms
234
+
235
+
236
+ def assign_room_wall_faces(
237
+ room_polygon: Polygon,
238
+ walls: list[Wall],
239
+ tolerance: float = 0.05,
240
+ ) -> list[WallFaceRef]:
241
+ """Determine which wall faces form a room's boundary.
242
+
243
+ For each wall, check if the left or right offset line is adjacent to
244
+ (within tolerance of) the room polygon boundary.
245
+ """
246
+ boundary_refs: list[WallFaceRef] = []
247
+ room_boundary = room_polygon.boundary
248
+
249
+ for wall in walls:
250
+ left_line, right_line = wall_to_left_right_lines(wall)
251
+
252
+ # Check if left face touches the room
253
+ if isinstance(left_line, LineString) and left_line.length > 0:
254
+ dist_left = left_line.distance(room_boundary)
255
+ if dist_left < tolerance:
256
+ boundary_refs.append(
257
+ WallFaceRef(wall_id=wall.id, side=WallSide.LEFT)
258
+ )
259
+ continue # a wall typically only has one face per room
260
+
261
+ # Check if right face touches the room
262
+ if isinstance(right_line, LineString) and right_line.length > 0:
263
+ dist_right = right_line.distance(room_boundary)
264
+ if dist_right < tolerance:
265
+ boundary_refs.append(
266
+ WallFaceRef(wall_id=wall.id, side=WallSide.RIGHT)
267
+ )
268
+
269
+ return boundary_refs
270
+
271
+
272
+ def build_rooms(
273
+ walls: list[Wall],
274
+ min_area: float = 1.0,
275
+ floor_boundary: Optional[Polygon] = None,
276
+ ) -> tuple[list[Room], list[Polygon]]:
277
+ """Full room detection pipeline: find room polygons, assign wall faces.
278
+
279
+ Returns (rooms, room_polygons) β€” the Room objects and their corresponding Shapely polygons.
280
+ """
281
+ room_polygons = detect_rooms_from_walls(
282
+ walls, min_area=min_area, floor_boundary=floor_boundary
283
+ )
284
+
285
+ rooms: list[Room] = []
286
+ for i, rpoly in enumerate(room_polygons):
287
+ wall_faces = assign_room_wall_faces(rpoly, walls)
288
+ room = Room(
289
+ id=f"r{i + 1}",
290
+ label=RoomLabel.UNKNOWN,
291
+ boundary=wall_faces if len(wall_faces) >= 2 else wall_faces,
292
+ area=round(rpoly.area, 2),
293
+ )
294
+ rooms.append(room)
295
+
296
+ return rooms, room_polygons
297
+
298
+
299
+ # ──────────────────────────────────────────────
300
+ # Floor plan assembly
301
+ # ──────────────────────────────────────────────
302
+
303
+ def compute_floor_plan_geometry(floorplan: FloorPlan) -> dict:
304
+ """Compute all derived geometry for a floor plan.
305
+
306
+ Returns dict with:
307
+ - wall_polygons: {wall_id: Polygon}
308
+ - wall_polygons_with_openings: {wall_id: Polygon|MultiPolygon}
309
+ - room_polygons: [Polygon]
310
+ - wall_union: Polygon|MultiPolygon
311
+ """
312
+ wall_polys = {}
313
+ wall_polys_openings = {}
314
+
315
+ for wall in floorplan.walls:
316
+ wall_polys[wall.id] = wall_to_polygon(wall)
317
+ if wall.openings:
318
+ wall_polys_openings[wall.id] = wall_polygon_with_openings(wall)
319
+ else:
320
+ wall_polys_openings[wall.id] = wall_polys[wall.id]
321
+
322
+ all_walls = unary_union(list(wall_polys.values())) if wall_polys else Polygon()
323
+
324
+ room_polygons = detect_rooms_from_walls(floorplan.walls)
325
+
326
+ return {
327
+ "wall_polygons": wall_polys,
328
+ "wall_polygons_with_openings": wall_polys_openings,
329
+ "room_polygons": room_polygons,
330
+ "wall_union": all_walls,
331
+ }