voxcity 0.7.0__py3-none-any.whl → 1.0.2__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.
- voxcity/__init__.py +14 -14
- voxcity/exporter/__init__.py +12 -12
- voxcity/exporter/cityles.py +633 -633
- voxcity/exporter/envimet.py +733 -728
- voxcity/exporter/magicavoxel.py +333 -333
- voxcity/exporter/netcdf.py +238 -238
- voxcity/exporter/obj.py +1480 -1480
- voxcity/generator/__init__.py +47 -44
- voxcity/generator/api.py +721 -675
- voxcity/generator/grids.py +381 -379
- voxcity/generator/io.py +94 -94
- voxcity/generator/pipeline.py +282 -282
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +18 -6
- voxcity/geoprocessor/__init__.py +75 -75
- voxcity/geoprocessor/draw.py +1488 -1219
- voxcity/geoprocessor/merge_utils.py +91 -91
- voxcity/geoprocessor/mesh.py +806 -806
- voxcity/geoprocessor/network.py +708 -708
- voxcity/geoprocessor/raster/buildings.py +435 -428
- voxcity/geoprocessor/raster/export.py +93 -93
- voxcity/geoprocessor/raster/landcover.py +5 -2
- voxcity/geoprocessor/utils.py +824 -824
- voxcity/models.py +113 -113
- voxcity/simulator/solar/__init__.py +66 -43
- voxcity/simulator/solar/integration.py +336 -336
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -434
- voxcity/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/shape.py +230 -0
- voxcity/visualizer/__init__.py +24 -24
- voxcity/visualizer/builder.py +43 -43
- voxcity/visualizer/grids.py +141 -141
- voxcity/visualizer/maps.py +187 -187
- voxcity/visualizer/renderer.py +1145 -928
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/METADATA +90 -49
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/RECORD +42 -38
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
voxcity/geoprocessor/mesh.py
CHANGED
|
@@ -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
|