voxcity 0.7.0__py3-none-any.whl → 1.0.13__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.
Files changed (81) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -1,807 +1,807 @@
1
- """Mesh generation utilities for voxel and 2D grid visualization.
2
-
3
- Orientation contract:
4
- - Mesh builders expect 2D inputs (e.g., simulation grids, building_id grids)
5
- to be provided in north_up orientation (row 0 = north/top) with columns
6
- increasing eastward (col 0 = west/left). Any internal flips are
7
- implementation details to match mesh coordinates.
8
- """
9
-
10
- import numpy as np
11
- import os
12
- import trimesh
13
- import matplotlib.colors as mcolors
14
- import matplotlib.cm as cm
15
- import matplotlib.pyplot as plt
16
- from ..utils.orientation import ensure_orientation, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP
17
-
18
- def create_voxel_mesh(voxel_array, class_id, meshsize=1.0, building_id_grid=None, mesh_type=None):
19
- """
20
- Create a 3D mesh from voxels preserving sharp edges, scaled by meshsize.
21
-
22
- This function converts a 3D voxel array into a triangulated mesh, where each voxel
23
- face is converted into two triangles. The function preserves sharp edges between
24
- different classes and handles special cases for buildings.
25
-
26
- Parameters
27
- ----------
28
- voxel_array : np.ndarray (3D)
29
- The voxel array of shape (X, Y, Z) where each cell contains a class ID.
30
- - 0: typically represents void/air
31
- - -2: typically represents trees
32
- - -3: typically represents buildings
33
- Other values can represent different classes as defined by the application.
34
-
35
- class_id : int
36
- The ID of the class to extract. Only voxels with this ID will be included
37
- in the output mesh.
38
-
39
- meshsize : float, default=1.0
40
- The real-world size of each voxel in meters, applied uniformly to x, y, and z
41
- dimensions. Used to scale the output mesh to real-world coordinates.
42
-
43
- building_id_grid : np.ndarray (2D), optional
44
- 2D grid of building IDs with shape (X, Y). Only used when class_id=-3 (buildings).
45
- Each cell contains a unique identifier for the building at that location.
46
- This allows tracking which faces belong to which building.
47
-
48
- mesh_type : str, optional
49
- Type of mesh to create, controlling which faces are included:
50
- - None (default): create faces at boundaries between different classes
51
- - 'building_solar' or 'open_air': only create faces at boundaries between
52
- buildings (-3) and either void (0) or trees (-2). Useful for
53
- solar analysis where only exposed surfaces matter.
54
-
55
- Returns
56
- -------
57
- mesh : trimesh.Trimesh or None
58
- The resulting triangulated mesh for the given class_id. Returns None if no
59
- voxels of the specified class are found.
60
-
61
- The mesh includes:
62
- - vertices: 3D coordinates of each vertex
63
- - faces: triangles defined by vertex indices
64
- - face_normals: normal vectors for each face
65
- - metadata: If class_id=-3, includes 'building_id' mapping faces to buildings
66
-
67
- Examples
68
- --------
69
- Basic usage for a simple voxel array:
70
- >>> voxels = np.zeros((10, 10, 10))
71
- >>> voxels[4:7, 4:7, 0:5] = 1 # Create a simple column
72
- >>> mesh = create_voxel_mesh(voxels, class_id=1, meshsize=0.5)
73
-
74
- Creating a building mesh with IDs:
75
- >>> building_ids = np.zeros((10, 10))
76
- >>> building_ids[4:7, 4:7] = 1 # Mark building #1
77
- >>> mesh = create_voxel_mesh(voxels, class_id=-3,
78
- ... building_id_grid=building_ids,
79
- ... meshsize=1.0)
80
-
81
- Notes
82
- -----
83
- - The function creates faces only at boundaries between different classes or at
84
- the edges of the voxel array.
85
- - Each face is split into two triangles for compatibility with graphics engines.
86
- - Face normals are computed to ensure correct lighting and rendering.
87
- - For buildings (class_id=-3), building IDs are tracked to maintain building identity.
88
- - The mesh preserves sharp edges, which is important for architectural visualization.
89
- """
90
- # Find voxels of the current class
91
- voxel_coords = np.argwhere(voxel_array == class_id)
92
-
93
- if building_id_grid is not None:
94
- building_id_grid_flipud = ensure_orientation(
95
- building_id_grid,
96
- ORIENTATION_NORTH_UP,
97
- ORIENTATION_SOUTH_UP,
98
- )
99
-
100
- if len(voxel_coords) == 0:
101
- return None
102
-
103
- # Define the 6 faces of a unit cube (local coordinates 0..1)
104
- unit_faces = np.array([
105
- # Front
106
- [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
107
- # Back
108
- [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]],
109
- # Right
110
- [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]],
111
- # Left
112
- [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
113
- # Top
114
- [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]],
115
- # Bottom
116
- [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]
117
- ])
118
-
119
- # Define face normals
120
- face_normals = np.array([
121
- [0, 0, 1], # Front
122
- [0, 0, -1], # Back
123
- [1, 0, 0], # Right
124
- [-1, 0, 0], # Left
125
- [0, 1, 0], # Top
126
- [0, -1, 0] # Bottom
127
- ])
128
-
129
- vertices = []
130
- faces = []
131
- face_normals_list = []
132
- building_ids = [] # List to store building IDs for each face
133
-
134
- for x, y, z in voxel_coords:
135
- # For buildings, get the building ID from the grid
136
- building_id = None
137
- if class_id == -3 and building_id_grid is not None:
138
- building_id = building_id_grid_flipud[x, y]
139
-
140
- # Check each face of the current voxel
141
- adjacent_coords = [
142
- (x, y, z+1), # Front
143
- (x, y, z-1), # Back
144
- (x+1, y, z), # Right
145
- (x-1, y, z), # Left
146
- (x, y+1, z), # Top
147
- (x, y-1, z) # Bottom
148
- ]
149
-
150
- # Only create faces where there's a transition based on mesh_type
151
- for face_idx, adj_coord in enumerate(adjacent_coords):
152
- try:
153
- # If adj_coord is outside array bounds, it's a boundary => face is visible
154
- if adj_coord[0] < 0 or adj_coord[1] < 0 or adj_coord[2] < 0:
155
- is_boundary = True
156
- else:
157
- adj_value = voxel_array[adj_coord]
158
-
159
- if class_id == -3 and mesh_type in ('building_solar', 'open_air'):
160
- # Only create faces at boundaries with void (0) or trees (-2)
161
- is_boundary = (adj_value == 0 or adj_value == -2)
162
- else:
163
- # Default behavior - create faces at any class change
164
- is_boundary = (adj_value == 0 or adj_value != class_id)
165
- except IndexError:
166
- # Out of range => boundary
167
- is_boundary = True
168
-
169
- if is_boundary:
170
- # Local face in (0..1) for x,y,z, then shift by voxel coords
171
- face_verts = (unit_faces[face_idx] + np.array([x, y, z])) * meshsize
172
- current_vert_count = len(vertices)
173
-
174
- vertices.extend(face_verts)
175
- # Convert quad to two triangles
176
- faces.extend([
177
- [current_vert_count, current_vert_count + 1, current_vert_count + 2],
178
- [current_vert_count, current_vert_count + 2, current_vert_count + 3]
179
- ])
180
- # Add face normals for both triangles
181
- face_normals_list.extend([face_normals[face_idx], face_normals[face_idx]])
182
-
183
- # Store building ID for both triangles if this is a building
184
- if class_id == -3 and building_id_grid is not None:
185
- building_ids.extend([building_id, building_id])
186
-
187
- if not vertices:
188
- return None
189
-
190
- vertices = np.array(vertices)
191
- faces = np.array(faces)
192
- face_normals_list = np.array(face_normals_list)
193
-
194
- # Create mesh
195
- mesh = trimesh.Trimesh(
196
- vertices=vertices,
197
- faces=faces,
198
- face_normals=face_normals_list
199
- )
200
-
201
- # Merge vertices that are at the same position
202
- mesh.merge_vertices()
203
-
204
- # Ensure metadata dict exists
205
- if not hasattr(mesh, 'metadata') or mesh.metadata is None:
206
- mesh.metadata = {}
207
-
208
- # Store intended per-triangle normals to avoid reliance on auto-computed normals
209
- mesh.metadata['provided_face_normals'] = face_normals_list
210
-
211
- # Add building IDs as metadata for buildings
212
- if class_id == -3 and building_id_grid is not None and building_ids:
213
- mesh.metadata['building_id'] = np.array(building_ids)
214
-
215
- return mesh
216
-
217
- def create_sim_surface_mesh(sim_grid, dem_grid,
218
- meshsize=1.0, z_offset=1.5,
219
- cmap_name='viridis',
220
- vmin=None, vmax=None):
221
- """
222
- Create a colored planar surface mesh from simulation data, positioned above a DEM.
223
-
224
- This function generates a 3D visualization mesh for 2D simulation results (like
225
- Green View Index, solar radiation, etc.). The mesh is positioned above the Digital
226
- Elevation Model (DEM) by a specified offset, and colored according to the simulation
227
- values using a matplotlib colormap.
228
-
229
- Parameters
230
- ----------
231
- sim_grid : 2D np.ndarray
232
- 2D array of simulation values (e.g., Green View Index, solar radiation).
233
- NaN values in this grid will be skipped in the output mesh.
234
- The grid should be oriented with north at the top.
235
-
236
- dem_grid : 2D np.ndarray
237
- 2D array of ground elevations in meters. Must have the same shape as sim_grid.
238
- Used to position the visualization mesh at the correct height above terrain.
239
-
240
- meshsize : float, default=1.0
241
- Size of each cell in meters. Applied uniformly to x and y dimensions.
242
- Determines the resolution of the output mesh.
243
-
244
- z_offset : float, default=1.5
245
- Additional height offset in meters added to dem_grid elevations.
246
- Used to position the visualization above ground level for better visibility.
247
-
248
- cmap_name : str, default='viridis'
249
- Matplotlib colormap name used for coloring the mesh based on sim_grid values.
250
- Common options:
251
- - 'viridis': Default, perceptually uniform, colorblind-friendly
252
- - 'RdYlBu': Red-Yellow-Blue, good for diverging data
253
- - 'jet': Rainbow colormap (not recommended for scientific visualization)
254
-
255
- vmin : float, optional
256
- Minimum value for color mapping. If None, uses min of sim_grid (excluding NaN).
257
- Used to control the range of the colormap.
258
-
259
- vmax : float, optional
260
- Maximum value for color mapping. If None, uses max of sim_grid (excluding NaN).
261
- Used to control the range of the colormap.
262
-
263
- Returns
264
- -------
265
- mesh : trimesh.Trimesh or None
266
- A single mesh containing one colored square face (two triangles) per non-NaN cell.
267
- Returns None if there are no valid (non-NaN) cells in sim_grid.
268
-
269
- The mesh includes:
270
- - vertices: 3D coordinates of each vertex
271
- - faces: triangles defined by vertex indices
272
- - face_colors: RGBA colors for each face based on sim_grid values
273
- - visual: trimesh.visual.ColorVisuals object storing the face colors
274
-
275
- Examples
276
- --------
277
- Basic usage with Green View Index data:
278
- >>> gvi = np.array([[0.5, 0.6], [0.4, 0.8]]) # GVI values
279
- >>> dem = np.array([[10.0, 10.2], [9.8, 10.1]]) # Ground heights
280
- >>> mesh = create_sim_surface_mesh(gvi, dem, meshsize=1.0, z_offset=1.5)
281
-
282
- Custom color range and colormap:
283
- >>> mesh = create_sim_surface_mesh(gvi, dem,
284
- ... cmap_name='RdYlBu',
285
- ... vmin=0.0, vmax=1.0)
286
-
287
- Notes
288
- -----
289
- - The function automatically creates a matplotlib colorbar figure for visualization
290
- - Both input grids are flipped vertically to match the voxel_array orientation
291
- - Each grid cell is converted to two triangles for compatibility with 3D engines
292
- - The mesh is positioned at dem_grid + z_offset to float above the terrain
293
- - Face colors are interpolated from the colormap based on sim_grid values
294
- """
295
- # Flip arrays vertically using orientation helper
296
- sim_grid_flipped = ensure_orientation(sim_grid, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP)
297
- dem_grid_flipped = ensure_orientation(dem_grid, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP)
298
-
299
- # Identify valid (non-NaN) values
300
- valid_mask = ~np.isnan(sim_grid_flipped)
301
- valid_values = sim_grid_flipped[valid_mask]
302
- if valid_values.size == 0:
303
- return None
304
-
305
- # If vmin/vmax not provided, use actual min/max of the valid sim data
306
- if vmin is None:
307
- vmin = np.nanmin(valid_values)
308
- if vmax is None:
309
- vmax = np.nanmax(valid_values)
310
-
311
- # Prepare the colormap and create colorbar
312
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
313
- scalar_map = cm.ScalarMappable(norm=norm, cmap=cmap_name)
314
-
315
- # Create a figure just for the colorbar
316
- fig, ax = plt.subplots(figsize=(6, 1))
317
- plt.colorbar(scalar_map, cax=ax, orientation='horizontal')
318
- plt.tight_layout()
319
- plt.close()
320
-
321
- vertices = []
322
- faces = []
323
- face_colors = []
324
-
325
- vert_index = 0
326
- nrows, ncols = sim_grid_flipped.shape
327
-
328
- # Build a quad (two triangles) for each valid cell
329
- for x in range(nrows):
330
- for y in range(ncols):
331
- val = sim_grid_flipped[x, y]
332
- if np.isnan(val):
333
- continue
334
-
335
- # Match voxel ground rounding: int(dem/mesh + 0.5) + 1 == int(dem/mesh + 1.5)
336
- # Then lower the plane by one mesh layer as requested
337
- z_base = meshsize * int(dem_grid_flipped[x, y] / meshsize + 1.5) + z_offset - meshsize
338
-
339
- # 4 corners in (x,y)*meshsize
340
- v0 = [ x * meshsize, y * meshsize, z_base ]
341
- v1 = [(x + 1) * meshsize, y * meshsize, z_base ]
342
- v2 = [(x + 1) * meshsize, (y + 1) * meshsize, z_base ]
343
- v3 = [ x * meshsize, (y + 1) * meshsize, z_base ]
344
-
345
- vertices.extend([v0, v1, v2, v3])
346
- faces.extend([
347
- [vert_index, vert_index + 1, vert_index + 2],
348
- [vert_index, vert_index + 2, vert_index + 3]
349
- ])
350
-
351
- # Get color from colormap
352
- color_rgba = np.array(scalar_map.to_rgba(val)) # shape (4,)
353
-
354
- # Each cell has 2 faces => add the color twice
355
- face_colors.append(color_rgba)
356
- face_colors.append(color_rgba)
357
-
358
- vert_index += 4
359
-
360
- if len(vertices) == 0:
361
- return None
362
-
363
- vertices = np.array(vertices, dtype=float)
364
- faces = np.array(faces, dtype=int)
365
- face_colors = np.array(face_colors, dtype=float)
366
-
367
- mesh = trimesh.Trimesh(
368
- vertices=vertices,
369
- faces=faces,
370
- face_colors=face_colors,
371
- process=False # skip auto merge if you want to preserve quads
372
- )
373
-
374
- return mesh
375
-
376
- def create_city_meshes(
377
- voxel_array,
378
- vox_dict,
379
- meshsize=1.0,
380
- include_classes=None,
381
- exclude_classes=None,
382
- ):
383
- """
384
- Create a collection of colored 3D meshes representing different city elements.
385
-
386
- This function processes a voxelized city model and creates separate meshes for
387
- different urban elements (buildings, trees, etc.), each with its own color.
388
- The function preserves sharp edges and applies appropriate colors based on the
389
- provided color dictionary.
390
-
391
- Parameters
392
- ----------
393
- voxel_array : np.ndarray (3D)
394
- 3D array representing the voxelized city model. Each voxel contains a class ID
395
- that maps to an urban element type:
396
- - 0: Void/air (automatically skipped)
397
- - -2: Trees
398
- - -3: Buildings
399
- Other values can represent different urban elements as defined in vox_dict.
400
-
401
- vox_dict : dict
402
- Dictionary mapping class IDs to RGB colors. Each entry should be:
403
- {class_id: [R, G, B]} where R, G, B are 0-255 integer values.
404
- Example: {-3: [200, 200, 200], -2: [0, 255, 0]} for grey buildings and
405
- green trees. The key 0 (air) is automatically excluded.
406
-
407
- meshsize : float, default=1.0
408
- Size of each voxel in meters, applied uniformly to x, y, and z dimensions.
409
- Used to scale the output meshes to real-world coordinates.
410
-
411
- Returns
412
- -------
413
- meshes : dict
414
- Dictionary mapping class IDs to their corresponding trimesh.Trimesh objects.
415
- Each mesh includes:
416
- - vertices: 3D coordinates scaled by meshsize
417
- - faces: triangulated faces preserving sharp edges
418
- - face_colors: RGBA colors from vox_dict
419
- - visual: trimesh.visual.ColorVisuals object storing the face colors
420
-
421
- Classes with no voxels are automatically excluded from the output.
422
-
423
- Examples
424
- --------
425
- Basic usage with buildings and trees:
426
- >>> voxels = np.zeros((10, 10, 10))
427
- >>> voxels[4:7, 4:7, 0:5] = -3 # Add a building
428
- >>> voxels[2:4, 2:4, 0:3] = -2 # Add some trees
429
- >>> colors = {
430
- ... -3: [200, 200, 200], # Grey buildings
431
- ... -2: [0, 255, 0] # Green trees
432
- ... }
433
- >>> meshes = create_city_meshes(voxels, colors, meshsize=1.0)
434
-
435
- Notes
436
- -----
437
- - The function automatically skips class_id=0 (typically air/void)
438
- - Each urban element type gets its own separate mesh for efficient rendering
439
- - Colors are converted from RGB [0-255] to RGBA [0-1] format
440
- - Sharp edges are preserved to maintain architectural features
441
- - Empty classes (no voxels) are automatically excluded from the output
442
- - Errors during mesh creation for a class are caught and reported
443
- """
444
- meshes = {}
445
-
446
- # Convert RGB colors to hex for material properties
447
- color_dict = {k: mcolors.rgb2hex([v[0]/255, v[1]/255, v[2]/255])
448
- for k, v in vox_dict.items() if k != 0} # Exclude air
449
-
450
- # Determine which classes to process
451
- unique_classes = np.unique(voxel_array)
452
-
453
- if include_classes is not None:
454
- # Only keep classes explicitly requested (and present in the data)
455
- class_iterable = [c for c in include_classes if c in unique_classes]
456
- else:
457
- class_iterable = list(unique_classes)
458
-
459
- exclude_set = set(exclude_classes) if exclude_classes is not None else set()
460
-
461
- # Create vertices and faces for each object class
462
- for class_id in class_iterable:
463
- if class_id == 0: # Skip air
464
- continue
465
-
466
- if class_id in exclude_set:
467
- # Explicitly skipped (e.g., will be replaced with custom mesh)
468
- continue
469
-
470
- try:
471
- mesh = create_voxel_mesh(voxel_array, class_id, meshsize=meshsize)
472
-
473
- if mesh is None:
474
- continue
475
-
476
- # Convert hex color to RGBA
477
- if class_id not in color_dict:
478
- # Color not provided; skip silently for robustness
479
- continue
480
- rgb_color = np.array(mcolors.hex2color(color_dict[class_id]))
481
- rgba_color = np.concatenate([rgb_color, [1.0]])
482
-
483
- # Assign color to all faces
484
- mesh.visual.face_colors = np.tile(rgba_color, (len(mesh.faces), 1))
485
-
486
- meshes[class_id] = mesh
487
-
488
- except ValueError as e:
489
- print(f"Skipping class {class_id}: {e}")
490
-
491
- return meshes
492
-
493
- def export_meshes(meshes, output_directory, base_filename):
494
- """
495
- Export a collection of meshes to both OBJ (with MTL) and STL formats.
496
-
497
- This function exports meshes in two ways:
498
- 1. A single combined OBJ file with materials (and associated MTL file)
499
- 2. Separate STL files for each mesh, named with their class IDs
500
-
501
- Parameters
502
- ----------
503
- meshes : dict
504
- Dictionary mapping class IDs to trimesh.Trimesh objects.
505
- Each mesh should have:
506
- - vertices: 3D coordinates
507
- - faces: triangulated faces
508
- - face_colors: RGBA colors (if using materials)
509
-
510
- output_directory : str
511
- Directory path where the output files will be saved.
512
- Will be created if it doesn't exist.
513
-
514
- base_filename : str
515
- Base name for the output files (without extension).
516
- Will be used to create:
517
- - {base_filename}.obj : Combined mesh with materials
518
- - {base_filename}.mtl : Material definitions for OBJ
519
- - {base_filename}_{class_id}.stl : Individual STL files
520
-
521
- Returns
522
- -------
523
- None
524
- Files are written directly to the specified output directory.
525
-
526
- Examples
527
- --------
528
- >>> meshes = {
529
- ... -3: building_mesh, # Building mesh with grey color
530
- ... -2: tree_mesh # Tree mesh with green color
531
- ... }
532
- >>> export_meshes(meshes, 'output/models', 'city_model')
533
-
534
- This will create:
535
- - output/models/city_model.obj
536
- - output/models/city_model.mtl
537
- - output/models/city_model_-3.stl
538
- - output/models/city_model_-2.stl
539
-
540
- Notes
541
- -----
542
- - OBJ/MTL format preserves colors and materials but is more complex
543
- - STL format is simpler but doesn't support colors
544
- - STL files are exported separately for each class for easier processing
545
- - The OBJ file combines all meshes while preserving their materials
546
- - File extensions are automatically added to the base filename
547
- """
548
- # Export combined mesh as OBJ with materials
549
- combined_mesh = trimesh.util.concatenate(list(meshes.values()))
550
- combined_mesh.export(f"{output_directory}/{base_filename}.obj")
551
-
552
- # Export individual meshes as STL
553
- for class_id, mesh in meshes.items():
554
- # Convert class_id to a string for filename
555
- mesh.export(f"{output_directory}/{base_filename}_{class_id}.stl")
556
-
557
- def split_vertices_manual(mesh):
558
- """
559
- Split a mesh into independent faces by duplicating shared vertices.
560
-
561
- This function imitates trimesh's split_vertices() functionality but ensures
562
- complete face independence by giving each face its own copy of vertices.
563
- This is particularly useful for rendering applications where smooth shading
564
- between faces is undesirable, such as architectural visualization in Rhino.
565
-
566
- Parameters
567
- ----------
568
- mesh : trimesh.Trimesh
569
- Input mesh to split. Should have:
570
- - vertices: array of vertex coordinates
571
- - faces: array of vertex indices forming triangles
572
- - visual: Optional ColorVisuals object with face colors
573
-
574
- Returns
575
- -------
576
- out_mesh : trimesh.Trimesh
577
- New mesh where each face is completely independent, with:
578
- - Duplicated vertices for each face
579
- - No vertex sharing between faces
580
- - Preserved face colors if present in input
581
- - Each face as a separate component
582
-
583
- Examples
584
- --------
585
- Basic usage:
586
- >>> vertices = np.array([[0,0,0], [1,0,0], [1,1,0], [0,1,0]])
587
- >>> faces = np.array([[0,1,2], [0,2,3]]) # Two triangles sharing vertices
588
- >>> mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
589
- >>> split_mesh = split_vertices_manual(mesh)
590
- >>> print(f"Original vertices: {len(mesh.vertices)}") # 4 vertices
591
- >>> print(f"Split vertices: {len(split_mesh.vertices)}") # 6 vertices
592
-
593
- With face colors:
594
- >>> colors = np.array([[255,0,0,255], [0,255,0,255]]) # Red and green faces
595
- >>> mesh.visual = trimesh.visual.ColorVisuals(mesh, face_colors=colors)
596
- >>> split_mesh = split_vertices_manual(mesh) # Colors are preserved
597
-
598
- Notes
599
- -----
600
- - Each output face has exactly 3 unique vertices
601
- - Face colors are preserved in the output mesh
602
- - Useful for:
603
- - Preventing smooth shading artifacts
604
- - Ensuring face color independence
605
- - Preparing meshes for CAD software
606
- - Creating sharp edges in architectural models
607
- - Memory usage increases as vertices are duplicated
608
- """
609
- new_meshes = []
610
-
611
- # For each face, build a small, one-face mesh
612
- for face_idx, face in enumerate(mesh.faces):
613
- face_coords = mesh.vertices[face]
614
-
615
- # Create mini-mesh without colors first
616
- mini_mesh = trimesh.Trimesh(
617
- vertices=face_coords,
618
- faces=[[0, 1, 2]],
619
- process=False # skip merging/cleaning
620
- )
621
-
622
- # If the mesh has per-face colors, set the face color properly
623
- if (mesh.visual.face_colors is not None
624
- and len(mesh.visual.face_colors) == len(mesh.faces)):
625
- # Create a visual object with the face color (for one face)
626
- face_color = mesh.visual.face_colors[face_idx]
627
- color_visual = trimesh.visual.ColorVisuals(
628
- mesh=mini_mesh,
629
- face_colors=np.array([face_color]), # One face, one color
630
- vertex_colors=None
631
- )
632
- mini_mesh.visual = color_visual
633
-
634
- new_meshes.append(mini_mesh)
635
-
636
- # Concatenate all the single-face meshes
637
- out_mesh = trimesh.util.concatenate(new_meshes)
638
- return out_mesh
639
-
640
- def save_obj_from_colored_mesh(meshes, output_path, base_filename, max_materials=None):
641
- """
642
- Memory-safe OBJ/MTL exporter.
643
- - Streams vertices/faces to disk (no concatenate, no per-face mini-meshes).
644
- - Uses face colors -> materials (no vertex splitting).
645
- - Optional color quantization to reduce material count.
646
- """
647
- import os
648
- import numpy as np
649
-
650
- os.makedirs(output_path, exist_ok=True)
651
- obj_path = os.path.join(output_path, f"{base_filename}.obj")
652
- mtl_path = os.path.join(output_path, f"{base_filename}.mtl")
653
-
654
- # --------------- helpers ---------------
655
- def to_uint8_rgba(arr):
656
- arr = np.asarray(arr)
657
- if arr.dtype != np.uint8:
658
- # Handle float [0..1] or int [0..255]
659
- if arr.dtype.kind == 'f':
660
- arr = np.clip(arr, 0.0, 1.0)
661
- arr = (arr * 255.0 + 0.5).astype(np.uint8)
662
- else:
663
- arr = arr.astype(np.uint8)
664
- if arr.shape[1] == 3:
665
- alpha = np.full((arr.shape[0], 1), 255, dtype=np.uint8)
666
- arr = np.concatenate([arr, alpha], axis=1)
667
- return arr
668
-
669
- # First pass: build material palette
670
- # We avoid collecting all colors at once—scan per mesh and update a dict.
671
- color_to_id = {}
672
- ordered_colors = [] # list of RGBA uint8 tuples in material order
673
-
674
- # Optional quantizer (lazy-init)
675
- quantizer = None
676
- if max_materials is not None:
677
- try:
678
- from sklearn.cluster import MiniBatchKMeans
679
- quantizer = MiniBatchKMeans(n_clusters=max_materials, random_state=42, batch_size=8192)
680
- # Partial-fit streaming pass over colors
681
- for m in meshes.values():
682
- fc = getattr(m.visual, "face_colors", None)
683
- if fc is None:
684
- continue
685
- fc = to_uint8_rgba(fc)
686
- if fc.size == 0:
687
- continue
688
- # Use only RGB for clustering
689
- quantizer.partial_fit(fc[:, :3].astype(np.float32))
690
- except ImportError:
691
- raise ImportError("scikit-learn is required for color quantization. Install it with: pip install scikit-learn")
692
-
693
- # Assign material ids during a second scan, but still streaming to avoid big unions
694
- def get_material_id(rgba):
695
- key = (int(rgba[0]), int(rgba[1]), int(rgba[2]), int(rgba[3]))
696
- mid = color_to_id.get(key)
697
- if mid is None:
698
- mid = len(ordered_colors)
699
- color_to_id[key] = mid
700
- ordered_colors.append(key)
701
- return mid
702
-
703
- # 2nd pass if quantizing: we need color centroids
704
- centers_u8 = None
705
- if quantizer is not None:
706
- centers = quantizer.cluster_centers_.astype(np.float32) # RGB float
707
- centers = np.clip(centers, 0.0, 255.0).astype(np.uint8)
708
- # Build a quick LUT fun
709
- def quantize_rgb(rgb_u8):
710
- # rgb_u8: (N,3) uint8 -> labels -> centers
711
- labels = quantizer.predict(rgb_u8.astype(np.float32))
712
- return centers[labels]
713
- # We'll convert each mesh's face colors to quantized RGB on the fly
714
- centers_u8 = centers
715
-
716
- # Build materials palette by scanning once (still O(total faces) but tiny memory)
717
- for m in meshes.values():
718
- fc = getattr(m.visual, "face_colors", None)
719
- if fc is None:
720
- # No colors: assign default grey
721
- rgba = np.array([[200,200,200,255]], dtype=np.uint8)
722
- get_material_id(rgba[0])
723
- continue
724
- fc = to_uint8_rgba(fc)
725
- if quantizer is not None:
726
- q_rgb = quantize_rgb(fc[:, :3])
727
- fc = np.concatenate([q_rgb, fc[:, 3:4]], axis=1)
728
- # Iterate unique colors in this mesh to limit get_material_id calls
729
- # but don't materialize huge sets; unique per mesh is fine.
730
- uniq = np.unique(fc, axis=0)
731
- for rgba in uniq:
732
- get_material_id(rgba)
733
-
734
- # Write MTL
735
- with open(mtl_path, "w") as mtl:
736
- for i, (r, g, b, a) in enumerate(ordered_colors):
737
- mtl.write(f"newmtl material_{i}\n")
738
- # Match viewport look: diffuse only, no specular. Many viewers assume sRGB.
739
- kd_r, kd_g, kd_b = r/255.0, g/255.0, b/255.0
740
- mtl.write(f"Kd {kd_r:.6f} {kd_g:.6f} {kd_b:.6f}\n")
741
- # Ambient same as diffuse to avoid darkening in some viewers
742
- mtl.write(f"Ka {kd_r:.6f} {kd_g:.6f} {kd_b:.6f}\n")
743
- # No specular highlight
744
- mtl.write("Ks 0.000000 0.000000 0.000000\n")
745
- # Disable lighting model with specular; keep simple shading
746
- mtl.write("illum 1\n")
747
- # Alpha
748
- mtl.write(f"d {a/255.0:.6f}\n\n")
749
-
750
- # Stream OBJ
751
- with open(obj_path, "w") as obj:
752
- obj.write(f"mtllib {os.path.basename(mtl_path)}\n")
753
-
754
- v_offset = 0 # running vertex index offset
755
- # Reusable cache so we don't keep writing 'usemtl' for the same block unnecessarily
756
- current_material = None
757
-
758
- for class_id, m in meshes.items():
759
- verts = np.asarray(m.vertices, dtype=np.float64)
760
- faces = np.asarray(m.faces, dtype=np.int64)
761
- if verts.size == 0 or faces.size == 0:
762
- continue
763
-
764
- # Write vertices
765
- # (We do a single pass; writing text is the bottleneck, but memory-safe.)
766
- for v in verts:
767
- obj.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
768
-
769
- # Prepare face colors (face-level)
770
- fc = getattr(m.visual, "face_colors", None)
771
- if fc is None or len(fc) != len(faces):
772
- # default grey if missing or mismatched
773
- fc = np.tile(np.array([200,200,200,255], dtype=np.uint8), (len(faces), 1))
774
- else:
775
- fc = to_uint8_rgba(fc)
776
-
777
- if quantizer is not None:
778
- q_rgb = quantize_rgb(fc[:, :3])
779
- fc = np.concatenate([q_rgb, fc[:, 3:4]], axis=1)
780
-
781
- # Group faces by material id and stream in order
782
- # Build material id per face quickly
783
- # Convert face colors to material ids
784
- # (Avoid Python loops over faces more than once)
785
- # Map unique colors in this mesh to material ids first:
786
- uniq_colors, inv_idx = np.unique(fc, axis=0, return_inverse=True)
787
- color_to_mid_local = {tuple(c.tolist()): get_material_id(c) for c in uniq_colors}
788
- mids = np.fromiter(
789
- (color_to_mid_local[tuple(c.tolist())] for c in uniq_colors[inv_idx]),
790
- dtype=np.int64,
791
- count=len(inv_idx)
792
- )
793
-
794
- # Write faces grouped by material, but preserve simple ordering
795
- # Cheap approach: emit runs; switching material only when necessary
796
- current_material = None
797
- for i_face, face in enumerate(faces):
798
- mid = int(mids[i_face])
799
- if current_material != mid:
800
- obj.write(f"usemtl material_{mid}\n")
801
- current_material = mid
802
- a, b, c = face + 1 + v_offset # OBJ is 1-based
803
- obj.write(f"f {a} {b} {c}\n")
804
-
805
- v_offset += len(verts)
806
-
1
+ """Mesh generation utilities for voxel and 2D grid visualization.
2
+
3
+ Orientation contract:
4
+ - Mesh builders expect 2D inputs (e.g., simulation grids, building_id grids)
5
+ to be provided in north_up orientation (row 0 = north/top) with columns
6
+ increasing eastward (col 0 = west/left). Any internal flips are
7
+ implementation details to match mesh coordinates.
8
+ """
9
+
10
+ import numpy as np
11
+ import os
12
+ import trimesh
13
+ import matplotlib.colors as mcolors
14
+ import matplotlib.cm as cm
15
+ import matplotlib.pyplot as plt
16
+ from ..utils.orientation import ensure_orientation, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP
17
+
18
+ def create_voxel_mesh(voxel_array, class_id, meshsize=1.0, building_id_grid=None, mesh_type=None):
19
+ """
20
+ Create a 3D mesh from voxels preserving sharp edges, scaled by meshsize.
21
+
22
+ This function converts a 3D voxel array into a triangulated mesh, where each voxel
23
+ face is converted into two triangles. The function preserves sharp edges between
24
+ different classes and handles special cases for buildings.
25
+
26
+ Parameters
27
+ ----------
28
+ voxel_array : np.ndarray (3D)
29
+ The voxel array of shape (X, Y, Z) where each cell contains a class ID.
30
+ - 0: typically represents void/air
31
+ - -2: typically represents trees
32
+ - -3: typically represents buildings
33
+ Other values can represent different classes as defined by the application.
34
+
35
+ class_id : int
36
+ The ID of the class to extract. Only voxels with this ID will be included
37
+ in the output mesh.
38
+
39
+ meshsize : float, default=1.0
40
+ The real-world size of each voxel in meters, applied uniformly to x, y, and z
41
+ dimensions. Used to scale the output mesh to real-world coordinates.
42
+
43
+ building_id_grid : np.ndarray (2D), optional
44
+ 2D grid of building IDs with shape (X, Y). Only used when class_id=-3 (buildings).
45
+ Each cell contains a unique identifier for the building at that location.
46
+ This allows tracking which faces belong to which building.
47
+
48
+ mesh_type : str, optional
49
+ Type of mesh to create, controlling which faces are included:
50
+ - None (default): create faces at boundaries between different classes
51
+ - 'building_solar' or 'open_air': only create faces at boundaries between
52
+ buildings (-3) and either void (0) or trees (-2). Useful for
53
+ solar analysis where only exposed surfaces matter.
54
+
55
+ Returns
56
+ -------
57
+ mesh : trimesh.Trimesh or None
58
+ The resulting triangulated mesh for the given class_id. Returns None if no
59
+ voxels of the specified class are found.
60
+
61
+ The mesh includes:
62
+ - vertices: 3D coordinates of each vertex
63
+ - faces: triangles defined by vertex indices
64
+ - face_normals: normal vectors for each face
65
+ - metadata: If class_id=-3, includes 'building_id' mapping faces to buildings
66
+
67
+ Examples
68
+ --------
69
+ Basic usage for a simple voxel array:
70
+ >>> voxels = np.zeros((10, 10, 10))
71
+ >>> voxels[4:7, 4:7, 0:5] = 1 # Create a simple column
72
+ >>> mesh = create_voxel_mesh(voxels, class_id=1, meshsize=0.5)
73
+
74
+ Creating a building mesh with IDs:
75
+ >>> building_ids = np.zeros((10, 10))
76
+ >>> building_ids[4:7, 4:7] = 1 # Mark building #1
77
+ >>> mesh = create_voxel_mesh(voxels, class_id=-3,
78
+ ... building_id_grid=building_ids,
79
+ ... meshsize=1.0)
80
+
81
+ Notes
82
+ -----
83
+ - The function creates faces only at boundaries between different classes or at
84
+ the edges of the voxel array.
85
+ - Each face is split into two triangles for compatibility with graphics engines.
86
+ - Face normals are computed to ensure correct lighting and rendering.
87
+ - For buildings (class_id=-3), building IDs are tracked to maintain building identity.
88
+ - The mesh preserves sharp edges, which is important for architectural visualization.
89
+ """
90
+ # Find voxels of the current class
91
+ voxel_coords = np.argwhere(voxel_array == class_id)
92
+
93
+ if building_id_grid is not None:
94
+ building_id_grid_flipud = ensure_orientation(
95
+ building_id_grid,
96
+ ORIENTATION_NORTH_UP,
97
+ ORIENTATION_SOUTH_UP,
98
+ )
99
+
100
+ if len(voxel_coords) == 0:
101
+ return None
102
+
103
+ # Define the 6 faces of a unit cube (local coordinates 0..1)
104
+ unit_faces = np.array([
105
+ # Front
106
+ [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
107
+ # Back
108
+ [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]],
109
+ # Right
110
+ [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]],
111
+ # Left
112
+ [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
113
+ # Top
114
+ [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]],
115
+ # Bottom
116
+ [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]
117
+ ])
118
+
119
+ # Define face normals
120
+ face_normals = np.array([
121
+ [0, 0, 1], # Front
122
+ [0, 0, -1], # Back
123
+ [1, 0, 0], # Right
124
+ [-1, 0, 0], # Left
125
+ [0, 1, 0], # Top
126
+ [0, -1, 0] # Bottom
127
+ ])
128
+
129
+ vertices = []
130
+ faces = []
131
+ face_normals_list = []
132
+ building_ids = [] # List to store building IDs for each face
133
+
134
+ for x, y, z in voxel_coords:
135
+ # For buildings, get the building ID from the grid
136
+ building_id = None
137
+ if class_id == -3 and building_id_grid is not None:
138
+ building_id = building_id_grid_flipud[x, y]
139
+
140
+ # Check each face of the current voxel
141
+ adjacent_coords = [
142
+ (x, y, z+1), # Front
143
+ (x, y, z-1), # Back
144
+ (x+1, y, z), # Right
145
+ (x-1, y, z), # Left
146
+ (x, y+1, z), # Top
147
+ (x, y-1, z) # Bottom
148
+ ]
149
+
150
+ # Only create faces where there's a transition based on mesh_type
151
+ for face_idx, adj_coord in enumerate(adjacent_coords):
152
+ try:
153
+ # If adj_coord is outside array bounds, it's a boundary => face is visible
154
+ if adj_coord[0] < 0 or adj_coord[1] < 0 or adj_coord[2] < 0:
155
+ is_boundary = True
156
+ else:
157
+ adj_value = voxel_array[adj_coord]
158
+
159
+ if class_id == -3 and mesh_type in ('building_solar', 'open_air'):
160
+ # Only create faces at boundaries with void (0) or trees (-2)
161
+ is_boundary = (adj_value == 0 or adj_value == -2)
162
+ else:
163
+ # Default behavior - create faces at any class change
164
+ is_boundary = (adj_value == 0 or adj_value != class_id)
165
+ except IndexError:
166
+ # Out of range => boundary
167
+ is_boundary = True
168
+
169
+ if is_boundary:
170
+ # Local face in (0..1) for x,y,z, then shift by voxel coords
171
+ face_verts = (unit_faces[face_idx] + np.array([x, y, z])) * meshsize
172
+ current_vert_count = len(vertices)
173
+
174
+ vertices.extend(face_verts)
175
+ # Convert quad to two triangles
176
+ faces.extend([
177
+ [current_vert_count, current_vert_count + 1, current_vert_count + 2],
178
+ [current_vert_count, current_vert_count + 2, current_vert_count + 3]
179
+ ])
180
+ # Add face normals for both triangles
181
+ face_normals_list.extend([face_normals[face_idx], face_normals[face_idx]])
182
+
183
+ # Store building ID for both triangles if this is a building
184
+ if class_id == -3 and building_id_grid is not None:
185
+ building_ids.extend([building_id, building_id])
186
+
187
+ if not vertices:
188
+ return None
189
+
190
+ vertices = np.array(vertices)
191
+ faces = np.array(faces)
192
+ face_normals_list = np.array(face_normals_list)
193
+
194
+ # Create mesh
195
+ mesh = trimesh.Trimesh(
196
+ vertices=vertices,
197
+ faces=faces,
198
+ face_normals=face_normals_list
199
+ )
200
+
201
+ # Merge vertices that are at the same position
202
+ mesh.merge_vertices()
203
+
204
+ # Ensure metadata dict exists
205
+ if not hasattr(mesh, 'metadata') or mesh.metadata is None:
206
+ mesh.metadata = {}
207
+
208
+ # Store intended per-triangle normals to avoid reliance on auto-computed normals
209
+ mesh.metadata['provided_face_normals'] = face_normals_list
210
+
211
+ # Add building IDs as metadata for buildings
212
+ if class_id == -3 and building_id_grid is not None and building_ids:
213
+ mesh.metadata['building_id'] = np.array(building_ids)
214
+
215
+ return mesh
216
+
217
+ def create_sim_surface_mesh(sim_grid, dem_grid,
218
+ meshsize=1.0, z_offset=1.5,
219
+ cmap_name='viridis',
220
+ vmin=None, vmax=None):
221
+ """
222
+ Create a colored planar surface mesh from simulation data, positioned above a DEM.
223
+
224
+ This function generates a 3D visualization mesh for 2D simulation results (like
225
+ Green View Index, solar radiation, etc.). The mesh is positioned above the Digital
226
+ Elevation Model (DEM) by a specified offset, and colored according to the simulation
227
+ values using a matplotlib colormap.
228
+
229
+ Parameters
230
+ ----------
231
+ sim_grid : 2D np.ndarray
232
+ 2D array of simulation values (e.g., Green View Index, solar radiation).
233
+ NaN values in this grid will be skipped in the output mesh.
234
+ The grid should be oriented with north at the top.
235
+
236
+ dem_grid : 2D np.ndarray
237
+ 2D array of ground elevations in meters. Must have the same shape as sim_grid.
238
+ Used to position the visualization mesh at the correct height above terrain.
239
+
240
+ meshsize : float, default=1.0
241
+ Size of each cell in meters. Applied uniformly to x and y dimensions.
242
+ Determines the resolution of the output mesh.
243
+
244
+ z_offset : float, default=1.5
245
+ Additional height offset in meters added to dem_grid elevations.
246
+ Used to position the visualization above ground level for better visibility.
247
+
248
+ cmap_name : str, default='viridis'
249
+ Matplotlib colormap name used for coloring the mesh based on sim_grid values.
250
+ Common options:
251
+ - 'viridis': Default, perceptually uniform, colorblind-friendly
252
+ - 'RdYlBu': Red-Yellow-Blue, good for diverging data
253
+ - 'jet': Rainbow colormap (not recommended for scientific visualization)
254
+
255
+ vmin : float, optional
256
+ Minimum value for color mapping. If None, uses min of sim_grid (excluding NaN).
257
+ Used to control the range of the colormap.
258
+
259
+ vmax : float, optional
260
+ Maximum value for color mapping. If None, uses max of sim_grid (excluding NaN).
261
+ Used to control the range of the colormap.
262
+
263
+ Returns
264
+ -------
265
+ mesh : trimesh.Trimesh or None
266
+ A single mesh containing one colored square face (two triangles) per non-NaN cell.
267
+ Returns None if there are no valid (non-NaN) cells in sim_grid.
268
+
269
+ The mesh includes:
270
+ - vertices: 3D coordinates of each vertex
271
+ - faces: triangles defined by vertex indices
272
+ - face_colors: RGBA colors for each face based on sim_grid values
273
+ - visual: trimesh.visual.ColorVisuals object storing the face colors
274
+
275
+ Examples
276
+ --------
277
+ Basic usage with Green View Index data:
278
+ >>> gvi = np.array([[0.5, 0.6], [0.4, 0.8]]) # GVI values
279
+ >>> dem = np.array([[10.0, 10.2], [9.8, 10.1]]) # Ground heights
280
+ >>> mesh = create_sim_surface_mesh(gvi, dem, meshsize=1.0, z_offset=1.5)
281
+
282
+ Custom color range and colormap:
283
+ >>> mesh = create_sim_surface_mesh(gvi, dem,
284
+ ... cmap_name='RdYlBu',
285
+ ... vmin=0.0, vmax=1.0)
286
+
287
+ Notes
288
+ -----
289
+ - The function automatically creates a matplotlib colorbar figure for visualization
290
+ - Both input grids are flipped vertically to match the voxel_array orientation
291
+ - Each grid cell is converted to two triangles for compatibility with 3D engines
292
+ - The mesh is positioned at dem_grid + z_offset to float above the terrain
293
+ - Face colors are interpolated from the colormap based on sim_grid values
294
+ """
295
+ # Flip arrays vertically using orientation helper
296
+ sim_grid_flipped = ensure_orientation(sim_grid, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP)
297
+ dem_grid_flipped = ensure_orientation(dem_grid, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP)
298
+
299
+ # Identify valid (non-NaN) values
300
+ valid_mask = ~np.isnan(sim_grid_flipped)
301
+ valid_values = sim_grid_flipped[valid_mask]
302
+ if valid_values.size == 0:
303
+ return None
304
+
305
+ # If vmin/vmax not provided, use actual min/max of the valid sim data
306
+ if vmin is None:
307
+ vmin = np.nanmin(valid_values)
308
+ if vmax is None:
309
+ vmax = np.nanmax(valid_values)
310
+
311
+ # Prepare the colormap and create colorbar
312
+ norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
313
+ scalar_map = cm.ScalarMappable(norm=norm, cmap=cmap_name)
314
+
315
+ # Create a figure just for the colorbar
316
+ fig, ax = plt.subplots(figsize=(6, 1))
317
+ plt.colorbar(scalar_map, cax=ax, orientation='horizontal')
318
+ plt.tight_layout()
319
+ plt.close()
320
+
321
+ vertices = []
322
+ faces = []
323
+ face_colors = []
324
+
325
+ vert_index = 0
326
+ nrows, ncols = sim_grid_flipped.shape
327
+
328
+ # Build a quad (two triangles) for each valid cell
329
+ for x in range(nrows):
330
+ for y in range(ncols):
331
+ val = sim_grid_flipped[x, y]
332
+ if np.isnan(val):
333
+ continue
334
+
335
+ # Match voxel ground rounding: int(dem/mesh + 0.5) + 1 == int(dem/mesh + 1.5)
336
+ # Then lower the plane by one mesh layer as requested
337
+ z_base = meshsize * int(dem_grid_flipped[x, y] / meshsize + 1.5) + z_offset - meshsize
338
+
339
+ # 4 corners in (x,y)*meshsize
340
+ v0 = [ x * meshsize, y * meshsize, z_base ]
341
+ v1 = [(x + 1) * meshsize, y * meshsize, z_base ]
342
+ v2 = [(x + 1) * meshsize, (y + 1) * meshsize, z_base ]
343
+ v3 = [ x * meshsize, (y + 1) * meshsize, z_base ]
344
+
345
+ vertices.extend([v0, v1, v2, v3])
346
+ faces.extend([
347
+ [vert_index, vert_index + 1, vert_index + 2],
348
+ [vert_index, vert_index + 2, vert_index + 3]
349
+ ])
350
+
351
+ # Get color from colormap
352
+ color_rgba = np.array(scalar_map.to_rgba(val)) # shape (4,)
353
+
354
+ # Each cell has 2 faces => add the color twice
355
+ face_colors.append(color_rgba)
356
+ face_colors.append(color_rgba)
357
+
358
+ vert_index += 4
359
+
360
+ if len(vertices) == 0:
361
+ return None
362
+
363
+ vertices = np.array(vertices, dtype=float)
364
+ faces = np.array(faces, dtype=int)
365
+ face_colors = np.array(face_colors, dtype=float)
366
+
367
+ mesh = trimesh.Trimesh(
368
+ vertices=vertices,
369
+ faces=faces,
370
+ face_colors=face_colors,
371
+ process=False # skip auto merge if you want to preserve quads
372
+ )
373
+
374
+ return mesh
375
+
376
+ def create_city_meshes(
377
+ voxel_array,
378
+ vox_dict,
379
+ meshsize=1.0,
380
+ include_classes=None,
381
+ exclude_classes=None,
382
+ ):
383
+ """
384
+ Create a collection of colored 3D meshes representing different city elements.
385
+
386
+ This function processes a voxelized city model and creates separate meshes for
387
+ different urban elements (buildings, trees, etc.), each with its own color.
388
+ The function preserves sharp edges and applies appropriate colors based on the
389
+ provided color dictionary.
390
+
391
+ Parameters
392
+ ----------
393
+ voxel_array : np.ndarray (3D)
394
+ 3D array representing the voxelized city model. Each voxel contains a class ID
395
+ that maps to an urban element type:
396
+ - 0: Void/air (automatically skipped)
397
+ - -2: Trees
398
+ - -3: Buildings
399
+ Other values can represent different urban elements as defined in vox_dict.
400
+
401
+ vox_dict : dict
402
+ Dictionary mapping class IDs to RGB colors. Each entry should be:
403
+ {class_id: [R, G, B]} where R, G, B are 0-255 integer values.
404
+ Example: {-3: [200, 200, 200], -2: [0, 255, 0]} for grey buildings and
405
+ green trees. The key 0 (air) is automatically excluded.
406
+
407
+ meshsize : float, default=1.0
408
+ Size of each voxel in meters, applied uniformly to x, y, and z dimensions.
409
+ Used to scale the output meshes to real-world coordinates.
410
+
411
+ Returns
412
+ -------
413
+ meshes : dict
414
+ Dictionary mapping class IDs to their corresponding trimesh.Trimesh objects.
415
+ Each mesh includes:
416
+ - vertices: 3D coordinates scaled by meshsize
417
+ - faces: triangulated faces preserving sharp edges
418
+ - face_colors: RGBA colors from vox_dict
419
+ - visual: trimesh.visual.ColorVisuals object storing the face colors
420
+
421
+ Classes with no voxels are automatically excluded from the output.
422
+
423
+ Examples
424
+ --------
425
+ Basic usage with buildings and trees:
426
+ >>> voxels = np.zeros((10, 10, 10))
427
+ >>> voxels[4:7, 4:7, 0:5] = -3 # Add a building
428
+ >>> voxels[2:4, 2:4, 0:3] = -2 # Add some trees
429
+ >>> colors = {
430
+ ... -3: [200, 200, 200], # Grey buildings
431
+ ... -2: [0, 255, 0] # Green trees
432
+ ... }
433
+ >>> meshes = create_city_meshes(voxels, colors, meshsize=1.0)
434
+
435
+ Notes
436
+ -----
437
+ - The function automatically skips class_id=0 (typically air/void)
438
+ - Each urban element type gets its own separate mesh for efficient rendering
439
+ - Colors are converted from RGB [0-255] to RGBA [0-1] format
440
+ - Sharp edges are preserved to maintain architectural features
441
+ - Empty classes (no voxels) are automatically excluded from the output
442
+ - Errors during mesh creation for a class are caught and reported
443
+ """
444
+ meshes = {}
445
+
446
+ # Convert RGB colors to hex for material properties
447
+ color_dict = {k: mcolors.rgb2hex([v[0]/255, v[1]/255, v[2]/255])
448
+ for k, v in vox_dict.items() if k != 0} # Exclude air
449
+
450
+ # Determine which classes to process
451
+ unique_classes = np.unique(voxel_array)
452
+
453
+ if include_classes is not None:
454
+ # Only keep classes explicitly requested (and present in the data)
455
+ class_iterable = [c for c in include_classes if c in unique_classes]
456
+ else:
457
+ class_iterable = list(unique_classes)
458
+
459
+ exclude_set = set(exclude_classes) if exclude_classes is not None else set()
460
+
461
+ # Create vertices and faces for each object class
462
+ for class_id in class_iterable:
463
+ if class_id == 0: # Skip air
464
+ continue
465
+
466
+ if class_id in exclude_set:
467
+ # Explicitly skipped (e.g., will be replaced with custom mesh)
468
+ continue
469
+
470
+ try:
471
+ mesh = create_voxel_mesh(voxel_array, class_id, meshsize=meshsize)
472
+
473
+ if mesh is None:
474
+ continue
475
+
476
+ # Convert hex color to RGBA
477
+ if class_id not in color_dict:
478
+ # Color not provided; skip silently for robustness
479
+ continue
480
+ rgb_color = np.array(mcolors.hex2color(color_dict[class_id]))
481
+ rgba_color = np.concatenate([rgb_color, [1.0]])
482
+
483
+ # Assign color to all faces
484
+ mesh.visual.face_colors = np.tile(rgba_color, (len(mesh.faces), 1))
485
+
486
+ meshes[class_id] = mesh
487
+
488
+ except ValueError as e:
489
+ print(f"Skipping class {class_id}: {e}")
490
+
491
+ return meshes
492
+
493
+ def export_meshes(meshes, output_directory, base_filename):
494
+ """
495
+ Export a collection of meshes to both OBJ (with MTL) and STL formats.
496
+
497
+ This function exports meshes in two ways:
498
+ 1. A single combined OBJ file with materials (and associated MTL file)
499
+ 2. Separate STL files for each mesh, named with their class IDs
500
+
501
+ Parameters
502
+ ----------
503
+ meshes : dict
504
+ Dictionary mapping class IDs to trimesh.Trimesh objects.
505
+ Each mesh should have:
506
+ - vertices: 3D coordinates
507
+ - faces: triangulated faces
508
+ - face_colors: RGBA colors (if using materials)
509
+
510
+ output_directory : str
511
+ Directory path where the output files will be saved.
512
+ Will be created if it doesn't exist.
513
+
514
+ base_filename : str
515
+ Base name for the output files (without extension).
516
+ Will be used to create:
517
+ - {base_filename}.obj : Combined mesh with materials
518
+ - {base_filename}.mtl : Material definitions for OBJ
519
+ - {base_filename}_{class_id}.stl : Individual STL files
520
+
521
+ Returns
522
+ -------
523
+ None
524
+ Files are written directly to the specified output directory.
525
+
526
+ Examples
527
+ --------
528
+ >>> meshes = {
529
+ ... -3: building_mesh, # Building mesh with grey color
530
+ ... -2: tree_mesh # Tree mesh with green color
531
+ ... }
532
+ >>> export_meshes(meshes, 'output/models', 'city_model')
533
+
534
+ This will create:
535
+ - output/models/city_model.obj
536
+ - output/models/city_model.mtl
537
+ - output/models/city_model_-3.stl
538
+ - output/models/city_model_-2.stl
539
+
540
+ Notes
541
+ -----
542
+ - OBJ/MTL format preserves colors and materials but is more complex
543
+ - STL format is simpler but doesn't support colors
544
+ - STL files are exported separately for each class for easier processing
545
+ - The OBJ file combines all meshes while preserving their materials
546
+ - File extensions are automatically added to the base filename
547
+ """
548
+ # Export combined mesh as OBJ with materials
549
+ combined_mesh = trimesh.util.concatenate(list(meshes.values()))
550
+ combined_mesh.export(f"{output_directory}/{base_filename}.obj")
551
+
552
+ # Export individual meshes as STL
553
+ for class_id, mesh in meshes.items():
554
+ # Convert class_id to a string for filename
555
+ mesh.export(f"{output_directory}/{base_filename}_{class_id}.stl")
556
+
557
+ def split_vertices_manual(mesh):
558
+ """
559
+ Split a mesh into independent faces by duplicating shared vertices.
560
+
561
+ This function imitates trimesh's split_vertices() functionality but ensures
562
+ complete face independence by giving each face its own copy of vertices.
563
+ This is particularly useful for rendering applications where smooth shading
564
+ between faces is undesirable, such as architectural visualization in Rhino.
565
+
566
+ Parameters
567
+ ----------
568
+ mesh : trimesh.Trimesh
569
+ Input mesh to split. Should have:
570
+ - vertices: array of vertex coordinates
571
+ - faces: array of vertex indices forming triangles
572
+ - visual: Optional ColorVisuals object with face colors
573
+
574
+ Returns
575
+ -------
576
+ out_mesh : trimesh.Trimesh
577
+ New mesh where each face is completely independent, with:
578
+ - Duplicated vertices for each face
579
+ - No vertex sharing between faces
580
+ - Preserved face colors if present in input
581
+ - Each face as a separate component
582
+
583
+ Examples
584
+ --------
585
+ Basic usage:
586
+ >>> vertices = np.array([[0,0,0], [1,0,0], [1,1,0], [0,1,0]])
587
+ >>> faces = np.array([[0,1,2], [0,2,3]]) # Two triangles sharing vertices
588
+ >>> mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
589
+ >>> split_mesh = split_vertices_manual(mesh)
590
+ >>> print(f"Original vertices: {len(mesh.vertices)}") # 4 vertices
591
+ >>> print(f"Split vertices: {len(split_mesh.vertices)}") # 6 vertices
592
+
593
+ With face colors:
594
+ >>> colors = np.array([[255,0,0,255], [0,255,0,255]]) # Red and green faces
595
+ >>> mesh.visual = trimesh.visual.ColorVisuals(mesh, face_colors=colors)
596
+ >>> split_mesh = split_vertices_manual(mesh) # Colors are preserved
597
+
598
+ Notes
599
+ -----
600
+ - Each output face has exactly 3 unique vertices
601
+ - Face colors are preserved in the output mesh
602
+ - Useful for:
603
+ - Preventing smooth shading artifacts
604
+ - Ensuring face color independence
605
+ - Preparing meshes for CAD software
606
+ - Creating sharp edges in architectural models
607
+ - Memory usage increases as vertices are duplicated
608
+ """
609
+ new_meshes = []
610
+
611
+ # For each face, build a small, one-face mesh
612
+ for face_idx, face in enumerate(mesh.faces):
613
+ face_coords = mesh.vertices[face]
614
+
615
+ # Create mini-mesh without colors first
616
+ mini_mesh = trimesh.Trimesh(
617
+ vertices=face_coords,
618
+ faces=[[0, 1, 2]],
619
+ process=False # skip merging/cleaning
620
+ )
621
+
622
+ # If the mesh has per-face colors, set the face color properly
623
+ if (mesh.visual.face_colors is not None
624
+ and len(mesh.visual.face_colors) == len(mesh.faces)):
625
+ # Create a visual object with the face color (for one face)
626
+ face_color = mesh.visual.face_colors[face_idx]
627
+ color_visual = trimesh.visual.ColorVisuals(
628
+ mesh=mini_mesh,
629
+ face_colors=np.array([face_color]), # One face, one color
630
+ vertex_colors=None
631
+ )
632
+ mini_mesh.visual = color_visual
633
+
634
+ new_meshes.append(mini_mesh)
635
+
636
+ # Concatenate all the single-face meshes
637
+ out_mesh = trimesh.util.concatenate(new_meshes)
638
+ return out_mesh
639
+
640
+ def save_obj_from_colored_mesh(meshes, output_path, base_filename, max_materials=None):
641
+ """
642
+ Memory-safe OBJ/MTL exporter.
643
+ - Streams vertices/faces to disk (no concatenate, no per-face mini-meshes).
644
+ - Uses face colors -> materials (no vertex splitting).
645
+ - Optional color quantization to reduce material count.
646
+ """
647
+ import os
648
+ import numpy as np
649
+
650
+ os.makedirs(output_path, exist_ok=True)
651
+ obj_path = os.path.join(output_path, f"{base_filename}.obj")
652
+ mtl_path = os.path.join(output_path, f"{base_filename}.mtl")
653
+
654
+ # --------------- helpers ---------------
655
+ def to_uint8_rgba(arr):
656
+ arr = np.asarray(arr)
657
+ if arr.dtype != np.uint8:
658
+ # Handle float [0..1] or int [0..255]
659
+ if arr.dtype.kind == 'f':
660
+ arr = np.clip(arr, 0.0, 1.0)
661
+ arr = (arr * 255.0 + 0.5).astype(np.uint8)
662
+ else:
663
+ arr = arr.astype(np.uint8)
664
+ if arr.shape[1] == 3:
665
+ alpha = np.full((arr.shape[0], 1), 255, dtype=np.uint8)
666
+ arr = np.concatenate([arr, alpha], axis=1)
667
+ return arr
668
+
669
+ # First pass: build material palette
670
+ # We avoid collecting all colors at once—scan per mesh and update a dict.
671
+ color_to_id = {}
672
+ ordered_colors = [] # list of RGBA uint8 tuples in material order
673
+
674
+ # Optional quantizer (lazy-init)
675
+ quantizer = None
676
+ if max_materials is not None:
677
+ try:
678
+ from sklearn.cluster import MiniBatchKMeans
679
+ quantizer = MiniBatchKMeans(n_clusters=max_materials, random_state=42, batch_size=8192)
680
+ # Partial-fit streaming pass over colors
681
+ for m in meshes.values():
682
+ fc = getattr(m.visual, "face_colors", None)
683
+ if fc is None:
684
+ continue
685
+ fc = to_uint8_rgba(fc)
686
+ if fc.size == 0:
687
+ continue
688
+ # Use only RGB for clustering
689
+ quantizer.partial_fit(fc[:, :3].astype(np.float32))
690
+ except ImportError:
691
+ raise ImportError("scikit-learn is required for color quantization. Install it with: pip install scikit-learn")
692
+
693
+ # Assign material ids during a second scan, but still streaming to avoid big unions
694
+ def get_material_id(rgba):
695
+ key = (int(rgba[0]), int(rgba[1]), int(rgba[2]), int(rgba[3]))
696
+ mid = color_to_id.get(key)
697
+ if mid is None:
698
+ mid = len(ordered_colors)
699
+ color_to_id[key] = mid
700
+ ordered_colors.append(key)
701
+ return mid
702
+
703
+ # 2nd pass if quantizing: we need color centroids
704
+ centers_u8 = None
705
+ if quantizer is not None:
706
+ centers = quantizer.cluster_centers_.astype(np.float32) # RGB float
707
+ centers = np.clip(centers, 0.0, 255.0).astype(np.uint8)
708
+ # Build a quick LUT fun
709
+ def quantize_rgb(rgb_u8):
710
+ # rgb_u8: (N,3) uint8 -> labels -> centers
711
+ labels = quantizer.predict(rgb_u8.astype(np.float32))
712
+ return centers[labels]
713
+ # We'll convert each mesh's face colors to quantized RGB on the fly
714
+ centers_u8 = centers
715
+
716
+ # Build materials palette by scanning once (still O(total faces) but tiny memory)
717
+ for m in meshes.values():
718
+ fc = getattr(m.visual, "face_colors", None)
719
+ if fc is None:
720
+ # No colors: assign default grey
721
+ rgba = np.array([[200,200,200,255]], dtype=np.uint8)
722
+ get_material_id(rgba[0])
723
+ continue
724
+ fc = to_uint8_rgba(fc)
725
+ if quantizer is not None:
726
+ q_rgb = quantize_rgb(fc[:, :3])
727
+ fc = np.concatenate([q_rgb, fc[:, 3:4]], axis=1)
728
+ # Iterate unique colors in this mesh to limit get_material_id calls
729
+ # but don't materialize huge sets; unique per mesh is fine.
730
+ uniq = np.unique(fc, axis=0)
731
+ for rgba in uniq:
732
+ get_material_id(rgba)
733
+
734
+ # Write MTL
735
+ with open(mtl_path, "w") as mtl:
736
+ for i, (r, g, b, a) in enumerate(ordered_colors):
737
+ mtl.write(f"newmtl material_{i}\n")
738
+ # Match viewport look: diffuse only, no specular. Many viewers assume sRGB.
739
+ kd_r, kd_g, kd_b = r/255.0, g/255.0, b/255.0
740
+ mtl.write(f"Kd {kd_r:.6f} {kd_g:.6f} {kd_b:.6f}\n")
741
+ # Ambient same as diffuse to avoid darkening in some viewers
742
+ mtl.write(f"Ka {kd_r:.6f} {kd_g:.6f} {kd_b:.6f}\n")
743
+ # No specular highlight
744
+ mtl.write("Ks 0.000000 0.000000 0.000000\n")
745
+ # Disable lighting model with specular; keep simple shading
746
+ mtl.write("illum 1\n")
747
+ # Alpha
748
+ mtl.write(f"d {a/255.0:.6f}\n\n")
749
+
750
+ # Stream OBJ
751
+ with open(obj_path, "w") as obj:
752
+ obj.write(f"mtllib {os.path.basename(mtl_path)}\n")
753
+
754
+ v_offset = 0 # running vertex index offset
755
+ # Reusable cache so we don't keep writing 'usemtl' for the same block unnecessarily
756
+ current_material = None
757
+
758
+ for class_id, m in meshes.items():
759
+ verts = np.asarray(m.vertices, dtype=np.float64)
760
+ faces = np.asarray(m.faces, dtype=np.int64)
761
+ if verts.size == 0 or faces.size == 0:
762
+ continue
763
+
764
+ # Write vertices
765
+ # (We do a single pass; writing text is the bottleneck, but memory-safe.)
766
+ for v in verts:
767
+ obj.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
768
+
769
+ # Prepare face colors (face-level)
770
+ fc = getattr(m.visual, "face_colors", None)
771
+ if fc is None or len(fc) != len(faces):
772
+ # default grey if missing or mismatched
773
+ fc = np.tile(np.array([200,200,200,255], dtype=np.uint8), (len(faces), 1))
774
+ else:
775
+ fc = to_uint8_rgba(fc)
776
+
777
+ if quantizer is not None:
778
+ q_rgb = quantize_rgb(fc[:, :3])
779
+ fc = np.concatenate([q_rgb, fc[:, 3:4]], axis=1)
780
+
781
+ # Group faces by material id and stream in order
782
+ # Build material id per face quickly
783
+ # Convert face colors to material ids
784
+ # (Avoid Python loops over faces more than once)
785
+ # Map unique colors in this mesh to material ids first:
786
+ uniq_colors, inv_idx = np.unique(fc, axis=0, return_inverse=True)
787
+ color_to_mid_local = {tuple(c.tolist()): get_material_id(c) for c in uniq_colors}
788
+ mids = np.fromiter(
789
+ (color_to_mid_local[tuple(c.tolist())] for c in uniq_colors[inv_idx]),
790
+ dtype=np.int64,
791
+ count=len(inv_idx)
792
+ )
793
+
794
+ # Write faces grouped by material, but preserve simple ordering
795
+ # Cheap approach: emit runs; switching material only when necessary
796
+ current_material = None
797
+ for i_face, face in enumerate(faces):
798
+ mid = int(mids[i_face])
799
+ if current_material != mid:
800
+ obj.write(f"usemtl material_{mid}\n")
801
+ current_material = mid
802
+ a, b, c = face + 1 + v_offset # OBJ is 1-based
803
+ obj.write(f"f {a} {b} {c}\n")
804
+
805
+ v_offset += len(verts)
806
+
807
807
  return obj_path, mtl_path