rikhoffbauer2 commited on
Commit
d68baaa
Β·
verified Β·
1 Parent(s): fbca0dd

Add 3D reconstruction module (trimesh + manifold3d)"

Browse files
Files changed (1) hide show
  1. floorplan/reconstruction.py +484 -0
floorplan/reconstruction.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 3D model generation from structured floor plan data.
3
+
4
+ Converts a FloorPlan schema into a 3D mesh scene with:
5
+ - Extruded walls with proper thickness
6
+ - Door and window openings (boolean-subtracted from walls)
7
+ - Floor slabs and ceilings per room
8
+ - PBR materials for each element type
9
+ - Export to GLB/glTF (for Three.js/web) and OBJ
10
+
11
+ Uses trimesh + manifold3d for watertight boolean geometry.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import Optional
18
+
19
+ import numpy as np
20
+ import trimesh
21
+ from shapely.geometry import LineString, Polygon
22
+ from trimesh.visual.material import PBRMaterial
23
+
24
+ from .schema import FloorPlan, Wall, Opening, OpeningType, Room
25
+ from .geometry import (
26
+ wall_to_polygon,
27
+ interpolate_along_centerline,
28
+ normal_at_distance,
29
+ compute_centerline_length,
30
+ detect_rooms_from_walls,
31
+ )
32
+
33
+
34
+ # ── Architectural constants (meters) ──────────────────────────
35
+
36
+ WALL_HEIGHT = 2.8 # Standard residential ceiling height
37
+ FLOOR_THICKNESS = 0.12 # Floor slab thickness
38
+ CEILING_THICKNESS = 0.08 # Ceiling thickness
39
+
40
+ DOOR_HEIGHT = 2.1 # Standard door height
41
+ DOOR_DEFAULT_WIDTH = 0.9 # Standard single door width
42
+
43
+ WINDOW_SILL_HEIGHT = 0.9 # Window sill from floor
44
+ WINDOW_HEAD_HEIGHT = 2.1 # Window top from floor
45
+ WINDOW_DEFAULT_WIDTH = 1.2 # Standard window width
46
+
47
+ BOOL_MARGIN = 0.06 # Extra margin for clean boolean cuts
48
+
49
+
50
+ # ── Materials (PBR) ───────────────────────────────────────────
51
+
52
+ def _wall_material() -> PBRMaterial:
53
+ return PBRMaterial(
54
+ baseColorFactor=[0.92, 0.89, 0.85, 1.0], # Warm white
55
+ roughnessFactor=0.85,
56
+ metallicFactor=0.0,
57
+ name="wall",
58
+ )
59
+
60
+
61
+ def _floor_material() -> PBRMaterial:
62
+ return PBRMaterial(
63
+ baseColorFactor=[0.76, 0.69, 0.57, 1.0], # Light wood
64
+ roughnessFactor=0.7,
65
+ metallicFactor=0.0,
66
+ name="floor",
67
+ )
68
+
69
+
70
+ def _ceiling_material() -> PBRMaterial:
71
+ return PBRMaterial(
72
+ baseColorFactor=[0.97, 0.97, 0.97, 1.0], # Near-white
73
+ roughnessFactor=0.9,
74
+ metallicFactor=0.0,
75
+ name="ceiling",
76
+ )
77
+
78
+
79
+ def _door_frame_material() -> PBRMaterial:
80
+ return PBRMaterial(
81
+ baseColorFactor=[0.55, 0.35, 0.20, 1.0], # Dark wood
82
+ roughnessFactor=0.6,
83
+ metallicFactor=0.0,
84
+ name="door_frame",
85
+ )
86
+
87
+
88
+ def _window_frame_material() -> PBRMaterial:
89
+ return PBRMaterial(
90
+ baseColorFactor=[0.85, 0.85, 0.85, 1.0], # Light grey aluminum
91
+ roughnessFactor=0.3,
92
+ metallicFactor=0.5,
93
+ name="window_frame",
94
+ )
95
+
96
+
97
+ def _glass_material() -> PBRMaterial:
98
+ return PBRMaterial(
99
+ baseColorFactor=[0.75, 0.85, 0.95, 0.3], # Translucent blue
100
+ roughnessFactor=0.1,
101
+ metallicFactor=0.1,
102
+ name="glass",
103
+ )
104
+
105
+
106
+ # ── Wall mesh construction ────────────────────────────────────
107
+
108
+ def _wall_segment_to_mesh(wall: Wall, height: float = WALL_HEIGHT) -> trimesh.Trimesh:
109
+ """Extrude a single wall's 2D polygon into a 3D wall mesh."""
110
+ poly = wall_to_polygon(wall)
111
+ if not poly.is_valid:
112
+ poly = poly.buffer(0)
113
+ mesh = trimesh.creation.extrude_polygon(poly, height=height)
114
+ return mesh
115
+
116
+
117
+ def _make_opening_cutter(
118
+ wall: Wall,
119
+ opening: Opening,
120
+ z_bottom: float,
121
+ z_top: float,
122
+ ) -> trimesh.Trimesh:
123
+ """Create a box mesh aligned to a wall at an opening position, for boolean subtraction.
124
+
125
+ The box is oriented along the wall direction, spanning the full wall thickness
126
+ (with margin) to ensure a clean cut.
127
+ """
128
+ coords = wall.centerline_coords
129
+ half_len = opening.length / 2.0
130
+ mid_dist = opening.start + half_len
131
+
132
+ # Get the center point and direction at the opening's midpoint
133
+ center_xy = interpolate_along_centerline(coords, mid_dist)
134
+ normal = normal_at_distance(coords, mid_dist)
135
+
136
+ # Wall direction is perpendicular to normal (rotate normal 90Β° CW)
137
+ wall_dir = (normal[1], -normal[0])
138
+ angle = math.atan2(wall_dir[1], wall_dir[0])
139
+
140
+ # Create rotation around Z axis
141
+ T = trimesh.transformations.rotation_matrix(angle, [0, 0, 1])
142
+ # Position at center of opening
143
+ T[:3, 3] = [
144
+ center_xy[0],
145
+ center_xy[1],
146
+ (z_bottom + z_top) / 2.0,
147
+ ]
148
+
149
+ cutter = trimesh.creation.box(
150
+ extents=[
151
+ opening.length + BOOL_MARGIN,
152
+ wall.thickness + BOOL_MARGIN * 4, # Extra depth for clean cut
153
+ (z_top - z_bottom) + BOOL_MARGIN,
154
+ ],
155
+ transform=T,
156
+ )
157
+ return cutter
158
+
159
+
160
+ def _make_door_frame(
161
+ wall: Wall,
162
+ opening: Opening,
163
+ height: float = DOOR_HEIGHT,
164
+ ) -> trimesh.Trimesh:
165
+ """Create a simple door frame mesh (thin border around the opening)."""
166
+ coords = wall.centerline_coords
167
+ mid_dist = opening.start + opening.length / 2.0
168
+ center_xy = interpolate_along_centerline(coords, mid_dist)
169
+ normal = normal_at_distance(coords, mid_dist)
170
+ wall_dir = (normal[1], -normal[0])
171
+ angle = math.atan2(wall_dir[1], wall_dir[0])
172
+
173
+ frame_width = 0.05 # 5cm frame
174
+
175
+ T = trimesh.transformations.rotation_matrix(angle, [0, 0, 1])
176
+ T[:3, 3] = [center_xy[0], center_xy[1], height / 2.0]
177
+
178
+ # Outer box
179
+ outer = trimesh.creation.box(
180
+ extents=[opening.length + frame_width * 2, wall.thickness, height],
181
+ transform=T,
182
+ )
183
+ # Inner cutout
184
+ inner = trimesh.creation.box(
185
+ extents=[opening.length, wall.thickness + 0.02, height - frame_width],
186
+ transform=T.copy(),
187
+ )
188
+
189
+ try:
190
+ frame = trimesh.boolean.difference([outer, inner], engine="manifold")
191
+ return frame
192
+ except Exception:
193
+ return outer # Fallback: just the outer box
194
+
195
+
196
+ def _make_window_glass(
197
+ wall: Wall,
198
+ opening: Opening,
199
+ sill_height: float = WINDOW_SILL_HEIGHT,
200
+ head_height: float = WINDOW_HEAD_HEIGHT,
201
+ ) -> trimesh.Trimesh:
202
+ """Create a thin glass pane at a window opening."""
203
+ coords = wall.centerline_coords
204
+ mid_dist = opening.start + opening.length / 2.0
205
+ center_xy = interpolate_along_centerline(coords, mid_dist)
206
+ normal = normal_at_distance(coords, mid_dist)
207
+ wall_dir = (normal[1], -normal[0])
208
+ angle = math.atan2(wall_dir[1], wall_dir[0])
209
+
210
+ glass_thickness = 0.01 # 1cm
211
+
212
+ T = trimesh.transformations.rotation_matrix(angle, [0, 0, 1])
213
+ T[:3, 3] = [
214
+ center_xy[0],
215
+ center_xy[1],
216
+ (sill_height + head_height) / 2.0,
217
+ ]
218
+
219
+ glass = trimesh.creation.box(
220
+ extents=[opening.length - 0.04, glass_thickness, head_height - sill_height - 0.04],
221
+ transform=T,
222
+ )
223
+ return glass
224
+
225
+
226
+ # ── Floor and ceiling slabs ───────────────────────────────────
227
+
228
+ def _floor_slab(room_polygon: Polygon, thickness: float = FLOOR_THICKNESS) -> trimesh.Trimesh:
229
+ """Create a floor slab from a room's 2D polygon."""
230
+ if not room_polygon.is_valid:
231
+ room_polygon = room_polygon.buffer(0)
232
+ mesh = trimesh.creation.extrude_polygon(room_polygon, height=thickness)
233
+ mesh.apply_translation([0, 0, -thickness])
234
+ return mesh
235
+
236
+
237
+ def _ceiling_slab(
238
+ room_polygon: Polygon,
239
+ wall_height: float = WALL_HEIGHT,
240
+ thickness: float = CEILING_THICKNESS,
241
+ ) -> trimesh.Trimesh:
242
+ """Create a ceiling slab from a room's 2D polygon."""
243
+ if not room_polygon.is_valid:
244
+ room_polygon = room_polygon.buffer(0)
245
+ mesh = trimesh.creation.extrude_polygon(room_polygon, height=thickness)
246
+ mesh.apply_translation([0, 0, wall_height])
247
+ return mesh
248
+
249
+
250
+ # ── Main 3D generation pipeline ──────────────────────────────
251
+
252
+ def generate_3d_model(
253
+ floorplan: FloorPlan,
254
+ room_polygons: Optional[list[Polygon]] = None,
255
+ wall_height: float = WALL_HEIGHT,
256
+ include_floors: bool = True,
257
+ include_ceilings: bool = True,
258
+ include_door_frames: bool = True,
259
+ include_window_glass: bool = True,
260
+ pixels_per_meter: Optional[float] = None,
261
+ ) -> trimesh.Scene:
262
+ """Convert a FloorPlan into a 3D trimesh Scene.
263
+
264
+ Args:
265
+ floorplan: The structured floor plan data
266
+ room_polygons: Pre-computed room polygons (from geometry engine).
267
+ If None, will be detected automatically.
268
+ wall_height: Height of walls in meters
269
+ include_floors: Whether to generate floor slabs
270
+ include_ceilings: Whether to generate ceiling slabs
271
+ include_door_frames: Whether to generate door frame meshes
272
+ include_window_glass: Whether to generate glass pane meshes
273
+ pixels_per_meter: If set, all coordinates will be divided by this value
274
+ to convert from pixel space to meters
275
+
276
+ Returns:
277
+ trimesh.Scene with named geometry nodes
278
+ """
279
+ scene = trimesh.Scene()
280
+
281
+ if not floorplan.walls:
282
+ return scene
283
+
284
+ # ── Scale from pixels to meters if needed ──
285
+ fp = floorplan
286
+ if pixels_per_meter and pixels_per_meter != 1.0:
287
+ fp = _scale_floorplan(floorplan, 1.0 / pixels_per_meter)
288
+
289
+ # ── Step 1: Extrude each wall independently ──
290
+ wall_meshes = []
291
+ for wall in fp.walls:
292
+ try:
293
+ mesh = _wall_segment_to_mesh(wall, height=wall_height)
294
+ if mesh.is_volume:
295
+ wall_meshes.append(mesh)
296
+ else:
297
+ # Try to repair
298
+ trimesh.repair.fix_winding(mesh)
299
+ trimesh.repair.fix_normals(mesh)
300
+ mesh.fill_holes()
301
+ wall_meshes.append(mesh)
302
+ except Exception as e:
303
+ print(f"Warning: could not extrude wall {wall.id}: {e}")
304
+ continue
305
+
306
+ if not wall_meshes:
307
+ return scene
308
+
309
+ # ── Step 2: Union all wall meshes ──
310
+ try:
311
+ if len(wall_meshes) == 1:
312
+ walls_combined = wall_meshes[0]
313
+ else:
314
+ walls_combined = trimesh.boolean.union(wall_meshes, engine="manifold")
315
+ except Exception as e:
316
+ print(f"Warning: wall union failed ({e}), concatenating instead")
317
+ walls_combined = trimesh.util.concatenate(wall_meshes)
318
+
319
+ # ── Step 3: Cut door/window openings ──
320
+ opening_cutters = []
321
+ door_frames = []
322
+ window_glasses = []
323
+
324
+ for wall in fp.walls:
325
+ for opening in wall.openings:
326
+ if opening.type == OpeningType.DOOR:
327
+ z_bottom = 0.0
328
+ z_top = opening.head_height or DOOR_HEIGHT
329
+ cutter = _make_opening_cutter(wall, opening, z_bottom, z_top)
330
+ opening_cutters.append(cutter)
331
+
332
+ if include_door_frames:
333
+ try:
334
+ frame = _make_door_frame(wall, opening, height=z_top)
335
+ door_frames.append(frame)
336
+ except Exception:
337
+ pass
338
+
339
+ elif opening.type == OpeningType.WINDOW:
340
+ sill = opening.sill_height or WINDOW_SILL_HEIGHT
341
+ head = opening.head_height or WINDOW_HEAD_HEIGHT
342
+ cutter = _make_opening_cutter(wall, opening, sill, head)
343
+ opening_cutters.append(cutter)
344
+
345
+ if include_window_glass:
346
+ try:
347
+ glass = _make_window_glass(wall, opening, sill, head)
348
+ window_glasses.append(glass)
349
+ except Exception:
350
+ pass
351
+
352
+ # Apply boolean subtraction for openings
353
+ if opening_cutters:
354
+ try:
355
+ all_cutters = trimesh.boolean.union(opening_cutters, engine="manifold")
356
+ walls_combined = trimesh.boolean.difference(
357
+ [walls_combined, all_cutters], engine="manifold"
358
+ )
359
+ except Exception as e:
360
+ print(f"Warning: opening boolean failed ({e}), cutting individually")
361
+ for cutter in opening_cutters:
362
+ try:
363
+ walls_combined = trimesh.boolean.difference(
364
+ [walls_combined, cutter], engine="manifold"
365
+ )
366
+ except Exception:
367
+ continue
368
+
369
+ # Apply wall material
370
+ walls_combined.visual = trimesh.visual.TextureVisuals(material=_wall_material())
371
+ scene.add_geometry(walls_combined, node_name="walls", geom_name="walls")
372
+
373
+ # ── Step 4: Add door frames ──
374
+ for i, frame in enumerate(door_frames):
375
+ frame.visual = trimesh.visual.TextureVisuals(material=_door_frame_material())
376
+ scene.add_geometry(frame, node_name=f"door_frame_{i}", geom_name=f"door_frame_{i}")
377
+
378
+ # ── Step 5: Add window glass ──
379
+ for i, glass in enumerate(window_glasses):
380
+ glass.visual = trimesh.visual.TextureVisuals(material=_glass_material())
381
+ scene.add_geometry(glass, node_name=f"window_glass_{i}", geom_name=f"window_glass_{i}")
382
+
383
+ # ── Step 6: Detect rooms and add floors/ceilings ──
384
+ if room_polygons is None:
385
+ room_polygons = detect_rooms_from_walls(fp.walls)
386
+ else:
387
+ # Scale room polygons if we scaled the floor plan
388
+ if pixels_per_meter and pixels_per_meter != 1.0:
389
+ from shapely import affinity
390
+ scale_factor = 1.0 / pixels_per_meter
391
+ room_polygons = [
392
+ affinity.scale(rp, xfact=scale_factor, yfact=scale_factor, origin=(0, 0))
393
+ for rp in room_polygons
394
+ ]
395
+
396
+ for i, rpoly in enumerate(room_polygons):
397
+ if not rpoly.is_valid:
398
+ rpoly = rpoly.buffer(0)
399
+ if rpoly.area < 0.5: # Skip tiny fragments
400
+ continue
401
+
402
+ if include_floors:
403
+ try:
404
+ floor = _floor_slab(rpoly)
405
+ floor.visual = trimesh.visual.TextureVisuals(material=_floor_material())
406
+ scene.add_geometry(floor, node_name=f"floor_{i}", geom_name=f"floor_{i}")
407
+ except Exception as e:
408
+ print(f"Warning: floor slab {i} failed: {e}")
409
+
410
+ if include_ceilings:
411
+ try:
412
+ ceiling = _ceiling_slab(rpoly, wall_height=wall_height)
413
+ ceiling.visual = trimesh.visual.TextureVisuals(material=_ceiling_material())
414
+ scene.add_geometry(ceiling, node_name=f"ceiling_{i}", geom_name=f"ceiling_{i}")
415
+ except Exception as e:
416
+ print(f"Warning: ceiling slab {i} failed: {e}")
417
+
418
+ return scene
419
+
420
+
421
+ def _scale_floorplan(fp: FloorPlan, scale: float) -> FloorPlan:
422
+ """Create a scaled copy of a floor plan (all coordinates multiplied by scale)."""
423
+ from .schema import Point2D, Wall, Opening, Room
424
+
425
+ new_walls = []
426
+ for wall in fp.walls:
427
+ new_centerline = [
428
+ Point2D(x=pt.x * scale, y=pt.y * scale) for pt in wall.centerline
429
+ ]
430
+ new_openings = [
431
+ Opening(
432
+ id=o.id,
433
+ type=o.type,
434
+ start=o.start * scale,
435
+ length=o.length * scale,
436
+ swing=o.swing,
437
+ sill_height=o.sill_height,
438
+ head_height=o.head_height,
439
+ )
440
+ for o in wall.openings
441
+ ]
442
+ new_walls.append(Wall(
443
+ id=wall.id,
444
+ centerline=new_centerline,
445
+ thickness=wall.thickness * scale,
446
+ openings=new_openings,
447
+ ))
448
+
449
+ # Don't copy rooms β€” they'll be recomputed from scaled walls
450
+ return FloorPlan(
451
+ scale=fp.scale,
452
+ origin=Point2D(x=fp.origin.x * scale, y=fp.origin.y * scale),
453
+ walls=new_walls,
454
+ rooms=[], # Will be recomputed
455
+ )
456
+
457
+
458
+ # ── Export helpers ────────────────────────────────────────────
459
+
460
+ def export_glb(scene: trimesh.Scene, path: str) -> int:
461
+ """Export scene to GLB (binary glTF). Returns file size in bytes."""
462
+ glb_bytes = scene.export(file_type="glb")
463
+ with open(path, "wb") as f:
464
+ f.write(glb_bytes)
465
+ return len(glb_bytes)
466
+
467
+
468
+ def export_obj(scene: trimesh.Scene, path: str) -> None:
469
+ """Export scene to OBJ (Wavefront). Merges all geometry."""
470
+ combined = trimesh.util.concatenate(list(scene.geometry.values()))
471
+ combined.export(path)
472
+
473
+
474
+ def export_gltf(scene: trimesh.Scene, directory: str) -> dict:
475
+ """Export scene to glTF (JSON + binary buffer). Returns dict of filenames."""
476
+ import os
477
+ os.makedirs(directory, exist_ok=True)
478
+ gltf_dict = scene.export(file_type="gltf")
479
+ for filename, data in gltf_dict.items():
480
+ filepath = os.path.join(directory, filename)
481
+ mode = "wb" if isinstance(data, bytes) else "w"
482
+ with open(filepath, mode) as f:
483
+ f.write(data)
484
+ return {k: os.path.join(directory, k) for k in gltf_dict}