voxcity 0.6.20__py3-none-any.whl → 0.6.21__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.
Potentially problematic release.
This version of voxcity might be problematic. Click here for more details.
- voxcity/exporter/obj.py +1405 -1405
- voxcity/generator.py +1301 -1305
- voxcity/utils/visualization.py +2734 -2691
- {voxcity-0.6.20.dist-info → voxcity-0.6.21.dist-info}/METADATA +2 -2
- {voxcity-0.6.20.dist-info → voxcity-0.6.21.dist-info}/RECORD +8 -8
- {voxcity-0.6.20.dist-info → voxcity-0.6.21.dist-info}/WHEEL +0 -0
- {voxcity-0.6.20.dist-info → voxcity-0.6.21.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.6.20.dist-info → voxcity-0.6.21.dist-info}/licenses/LICENSE +0 -0
voxcity/exporter/obj.py
CHANGED
|
@@ -1,1406 +1,1406 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Module for exporting voxel data to OBJ format.
|
|
3
|
-
|
|
4
|
-
This module provides functionality for converting voxel arrays and grid data to OBJ files,
|
|
5
|
-
including color mapping, material generation, and mesh optimization.
|
|
6
|
-
|
|
7
|
-
Key Features:
|
|
8
|
-
- Exports voxel data to industry-standard OBJ format with MTL materials
|
|
9
|
-
- Supports color mapping for visualization
|
|
10
|
-
- Performs greedy meshing for optimized face generation
|
|
11
|
-
- Handles proper face orientation and winding order
|
|
12
|
-
- Supports both regular voxel grids and terrain/elevation data
|
|
13
|
-
- Generates complete OBJ files with materials and textures
|
|
14
|
-
|
|
15
|
-
Main Functions:
|
|
16
|
-
- convert_colormap_indices: Converts arbitrary color indices to sequential ones
|
|
17
|
-
- create_face_vertices: Creates properly oriented face vertices
|
|
18
|
-
- mesh_faces: Performs greedy meshing on voxel layers
|
|
19
|
-
- export_obj: Main function to export voxel data to OBJ
|
|
20
|
-
- grid_to_obj: Converts 2D grid data to OBJ with elevation
|
|
21
|
-
|
|
22
|
-
Dependencies:
|
|
23
|
-
- numpy: For array operations
|
|
24
|
-
- matplotlib: For colormap handling
|
|
25
|
-
- trimesh: For mesh operations
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
import numpy as np
|
|
29
|
-
import os
|
|
30
|
-
from numba import njit, prange
|
|
31
|
-
import matplotlib.pyplot as plt
|
|
32
|
-
import trimesh
|
|
33
|
-
import numpy as np
|
|
34
|
-
from ..utils.visualization import get_voxel_color_map
|
|
35
|
-
|
|
36
|
-
def convert_colormap_indices(original_map):
|
|
37
|
-
"""
|
|
38
|
-
Convert a color map with arbitrary indices to sequential indices starting from 0.
|
|
39
|
-
|
|
40
|
-
This function takes a color map with arbitrary integer keys and creates a new map
|
|
41
|
-
with sequential indices starting from 0, maintaining the original color values.
|
|
42
|
-
This is useful for ensuring consistent material indexing in OBJ files.
|
|
43
|
-
|
|
44
|
-
Args:
|
|
45
|
-
original_map (dict): Dictionary with integer keys and RGB color value lists.
|
|
46
|
-
Each value should be a list of 3 integers (0-255) representing RGB colors.
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
dict: New color map with sequential indices starting from 0.
|
|
50
|
-
The values maintain their original RGB color assignments.
|
|
51
|
-
|
|
52
|
-
Example:
|
|
53
|
-
>>> original = {5: [255, 0, 0], 10: [0, 255, 0], 15: [0, 0, 255]}
|
|
54
|
-
>>> new_map = convert_colormap_indices(original)
|
|
55
|
-
>>> print(new_map)
|
|
56
|
-
{0: [255, 0, 0], 1: [0, 255, 0], 2: [0, 0, 255]}
|
|
57
|
-
"""
|
|
58
|
-
# Sort the original keys to maintain consistent ordering
|
|
59
|
-
keys = sorted(original_map.keys())
|
|
60
|
-
new_map = {}
|
|
61
|
-
|
|
62
|
-
# Create new map with sequential indices
|
|
63
|
-
for new_idx, old_idx in enumerate(keys):
|
|
64
|
-
new_map[new_idx] = original_map[old_idx]
|
|
65
|
-
|
|
66
|
-
# Print the new colormap for debugging/reference
|
|
67
|
-
print("new_colormap = {")
|
|
68
|
-
for key, value in new_map.items():
|
|
69
|
-
original_key = keys[key]
|
|
70
|
-
original_line = str(original_map[original_key])
|
|
71
|
-
comment = ""
|
|
72
|
-
if "#" in original_line:
|
|
73
|
-
comment = "#" + original_line.split("#")[1].strip()
|
|
74
|
-
print(f" {key}: {value}, {comment}")
|
|
75
|
-
print("}")
|
|
76
|
-
|
|
77
|
-
return new_map
|
|
78
|
-
|
|
79
|
-
def create_face_vertices(coords, positive_direction, axis):
|
|
80
|
-
"""
|
|
81
|
-
Helper function to create properly oriented face vertices for OBJ export.
|
|
82
|
-
|
|
83
|
-
This function handles the creation of face vertices with correct winding order
|
|
84
|
-
based on the face direction and axis. It accounts for OpenGL coordinate system
|
|
85
|
-
conventions and ensures proper face orientation for rendering.
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
coords (list): List of 4 vertex coordinates defining the face corners.
|
|
89
|
-
Each coordinate should be a tuple of (x, y, z) values.
|
|
90
|
-
positive_direction (bool): Whether face points in positive axis direction.
|
|
91
|
-
True = face normal points in positive direction along the axis
|
|
92
|
-
False = face normal points in negative direction along the axis
|
|
93
|
-
axis (str): Axis the face is perpendicular to ('x', 'y', or 'z').
|
|
94
|
-
This determines how vertices are ordered for proper face orientation.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
list: Ordered vertex coordinates for the face, arranged to create proper
|
|
98
|
-
face orientation and winding order for rendering.
|
|
99
|
-
|
|
100
|
-
Notes:
|
|
101
|
-
- Y-axis faces need special handling due to OpenGL coordinate system
|
|
102
|
-
- Winding order determines which side of the face is visible
|
|
103
|
-
- Consistent winding order is maintained for X and Z faces
|
|
104
|
-
"""
|
|
105
|
-
# Y-axis faces need special handling due to OpenGL coordinate system
|
|
106
|
-
if axis == 'y':
|
|
107
|
-
if positive_direction: # +Y face
|
|
108
|
-
return [coords[3], coords[2], coords[1], coords[0]] # Reverse order for +Y
|
|
109
|
-
else: # -Y face
|
|
110
|
-
return [coords[0], coords[1], coords[2], coords[3]] # Standard order for -Y
|
|
111
|
-
else:
|
|
112
|
-
# For X and Z faces, use consistent winding order
|
|
113
|
-
if positive_direction:
|
|
114
|
-
return [coords[0], coords[3], coords[2], coords[1]]
|
|
115
|
-
else:
|
|
116
|
-
return [coords[0], coords[1], coords[2], coords[3]]
|
|
117
|
-
|
|
118
|
-
def mesh_faces(mask, layer_index, axis, positive_direction, normal_idx, voxel_size_m,
|
|
119
|
-
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material):
|
|
120
|
-
"""
|
|
121
|
-
Performs greedy meshing on a 2D mask layer and adds optimized faces to the mesh.
|
|
122
|
-
|
|
123
|
-
This function implements a greedy meshing algorithm to combine adjacent voxels
|
|
124
|
-
into larger faces, reducing the total number of faces in the final mesh while
|
|
125
|
-
maintaining visual accuracy. It processes each layer of voxels and generates
|
|
126
|
-
optimized faces with proper materials and orientations.
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
mask (ndarray): 2D boolean array indicating voxel presence.
|
|
130
|
-
Non-zero values indicate voxel presence, zero indicates empty space.
|
|
131
|
-
layer_index (int): Index of current layer being processed.
|
|
132
|
-
Used to position faces in 3D space.
|
|
133
|
-
axis (str): Axis perpendicular to faces being generated ('x', 'y', or 'z').
|
|
134
|
-
Determines how coordinates are generated for the faces.
|
|
135
|
-
positive_direction (bool): Whether faces point in positive axis direction.
|
|
136
|
-
Affects face normal orientation.
|
|
137
|
-
normal_idx (int): Index of normal vector to use for faces.
|
|
138
|
-
References pre-defined normal vectors in the OBJ file.
|
|
139
|
-
voxel_size_m (float): Size of each voxel in meters.
|
|
140
|
-
Used to scale coordinates to real-world units.
|
|
141
|
-
vertex_dict (dict): Dictionary mapping vertex coordinates to indices.
|
|
142
|
-
Used to avoid duplicate vertices in the mesh.
|
|
143
|
-
vertex_list (list): List of unique vertex coordinates.
|
|
144
|
-
Stores all vertices used in the mesh.
|
|
145
|
-
faces_per_material (dict): Dictionary collecting faces by material.
|
|
146
|
-
Keys are material names, values are lists of face definitions.
|
|
147
|
-
voxel_value_to_material (dict): Mapping from voxel values to material names.
|
|
148
|
-
Used to assign materials to faces based on voxel values.
|
|
149
|
-
|
|
150
|
-
Notes:
|
|
151
|
-
- Uses greedy meshing to combine adjacent same-value voxels
|
|
152
|
-
- Handles coordinate system conversion for proper orientation
|
|
153
|
-
- Maintains consistent face winding order for rendering
|
|
154
|
-
- Optimizes mesh by reusing vertices and combining faces
|
|
155
|
-
- Supports different coordinate systems for each axis
|
|
156
|
-
"""
|
|
157
|
-
|
|
158
|
-
voxel_size = voxel_size_m
|
|
159
|
-
|
|
160
|
-
# Create copy to avoid modifying original mask
|
|
161
|
-
mask = mask.copy()
|
|
162
|
-
h, w = mask.shape
|
|
163
|
-
|
|
164
|
-
# Track which voxels have been processed
|
|
165
|
-
visited = np.zeros_like(mask, dtype=bool)
|
|
166
|
-
|
|
167
|
-
# Iterate through each position in the mask
|
|
168
|
-
for u in range(h):
|
|
169
|
-
v = 0
|
|
170
|
-
while v < w:
|
|
171
|
-
# Skip if already visited or empty voxel
|
|
172
|
-
if visited[u, v] or mask[u, v] == 0:
|
|
173
|
-
v += 1
|
|
174
|
-
continue
|
|
175
|
-
|
|
176
|
-
voxel_value = mask[u, v]
|
|
177
|
-
material_name = voxel_value_to_material[voxel_value]
|
|
178
|
-
|
|
179
|
-
# Greedy meshing: Find maximum width of consecutive same-value voxels
|
|
180
|
-
width = 1
|
|
181
|
-
while v + width < w and mask[u, v + width] == voxel_value and not visited[u, v + width]:
|
|
182
|
-
width += 1
|
|
183
|
-
|
|
184
|
-
# Find maximum height of same-value voxels
|
|
185
|
-
height = 1
|
|
186
|
-
done = False
|
|
187
|
-
while u + height < h and not done:
|
|
188
|
-
for k in range(width):
|
|
189
|
-
if mask[u + height, v + k] != voxel_value or visited[u + height, v + k]:
|
|
190
|
-
done = True
|
|
191
|
-
break
|
|
192
|
-
if not done:
|
|
193
|
-
height += 1
|
|
194
|
-
|
|
195
|
-
# Mark processed voxels as visited
|
|
196
|
-
visited[u:u + height, v:v + width] = True
|
|
197
|
-
|
|
198
|
-
# Generate vertex coordinates based on axis orientation
|
|
199
|
-
if axis == 'x':
|
|
200
|
-
i = float(layer_index) * voxel_size
|
|
201
|
-
y0 = float(u) * voxel_size
|
|
202
|
-
y1 = float(u + height) * voxel_size
|
|
203
|
-
z0 = float(v) * voxel_size
|
|
204
|
-
z1 = float(v + width) * voxel_size
|
|
205
|
-
coords = [
|
|
206
|
-
(i, y0, z0),
|
|
207
|
-
(i, y1, z0),
|
|
208
|
-
(i, y1, z1),
|
|
209
|
-
(i, y0, z1),
|
|
210
|
-
]
|
|
211
|
-
elif axis == 'y':
|
|
212
|
-
i = float(layer_index) * voxel_size
|
|
213
|
-
x0 = float(u) * voxel_size
|
|
214
|
-
x1 = float(u + height) * voxel_size
|
|
215
|
-
z0 = float(v) * voxel_size
|
|
216
|
-
z1 = float(v + width) * voxel_size
|
|
217
|
-
coords = [
|
|
218
|
-
(x0, i, z0),
|
|
219
|
-
(x1, i, z0),
|
|
220
|
-
(x1, i, z1),
|
|
221
|
-
(x0, i, z1),
|
|
222
|
-
]
|
|
223
|
-
elif axis == 'z':
|
|
224
|
-
i = float(layer_index) * voxel_size
|
|
225
|
-
x0 = float(u) * voxel_size
|
|
226
|
-
x1 = float(u + height) * voxel_size
|
|
227
|
-
y0 = float(v) * voxel_size
|
|
228
|
-
y1 = float(v + width) * voxel_size
|
|
229
|
-
coords = [
|
|
230
|
-
(x0, y0, i),
|
|
231
|
-
(x1, y0, i),
|
|
232
|
-
(x1, y1, i),
|
|
233
|
-
(x0, y1, i),
|
|
234
|
-
]
|
|
235
|
-
else:
|
|
236
|
-
continue
|
|
237
|
-
|
|
238
|
-
# Convert to right-handed coordinate system
|
|
239
|
-
coords = [(c[2], c[1], c[0]) for c in coords]
|
|
240
|
-
face_vertices = create_face_vertices(coords, positive_direction, axis)
|
|
241
|
-
|
|
242
|
-
# Convert vertices to indices, adding new vertices as needed
|
|
243
|
-
indices = []
|
|
244
|
-
for coord in face_vertices:
|
|
245
|
-
if coord not in vertex_dict:
|
|
246
|
-
vertex_list.append(coord)
|
|
247
|
-
vertex_dict[coord] = len(vertex_list)
|
|
248
|
-
indices.append(vertex_dict[coord])
|
|
249
|
-
|
|
250
|
-
# Create triangulated faces with proper winding order
|
|
251
|
-
if axis == 'y':
|
|
252
|
-
faces = [
|
|
253
|
-
{'vertices': [indices[2], indices[1], indices[0]], 'normal_idx': normal_idx},
|
|
254
|
-
{'vertices': [indices[3], indices[2], indices[0]], 'normal_idx': normal_idx}
|
|
255
|
-
]
|
|
256
|
-
else:
|
|
257
|
-
faces = [
|
|
258
|
-
{'vertices': [indices[0], indices[1], indices[2]], 'normal_idx': normal_idx},
|
|
259
|
-
{'vertices': [indices[0], indices[2], indices[3]], 'normal_idx': normal_idx}
|
|
260
|
-
]
|
|
261
|
-
|
|
262
|
-
# Store faces by material
|
|
263
|
-
if material_name not in faces_per_material:
|
|
264
|
-
faces_per_material[material_name] = []
|
|
265
|
-
faces_per_material[material_name].extend(faces)
|
|
266
|
-
|
|
267
|
-
v += width
|
|
268
|
-
|
|
269
|
-
def export_obj(array, output_dir, file_name, voxel_size, voxel_color_map=None):
|
|
270
|
-
"""
|
|
271
|
-
Export a voxel array to OBJ format with materials and proper face orientations.
|
|
272
|
-
|
|
273
|
-
This function converts a 3D voxel array into a complete OBJ file with materials,
|
|
274
|
-
performing mesh optimization and ensuring proper face orientations. It generates
|
|
275
|
-
both OBJ and MTL files with all necessary components for rendering.
|
|
276
|
-
|
|
277
|
-
Args:
|
|
278
|
-
array (ndarray): 3D numpy array containing voxel values.
|
|
279
|
-
Non-zero values indicate voxel presence and material type.
|
|
280
|
-
output_dir (str): Directory to save the OBJ and MTL files.
|
|
281
|
-
Will be created if it doesn't exist.
|
|
282
|
-
file_name (str): Base name for the output files.
|
|
283
|
-
Will be used for both .obj and .mtl files.
|
|
284
|
-
voxel_size (float): Size of each voxel in meters.
|
|
285
|
-
Used to scale the model to real-world units.
|
|
286
|
-
voxel_color_map (dict, optional): Dictionary mapping voxel values to RGB colors.
|
|
287
|
-
If None, uses default color map. Colors should be RGB lists (0-255).
|
|
288
|
-
|
|
289
|
-
Notes:
|
|
290
|
-
- Generates optimized mesh using greedy meshing
|
|
291
|
-
- Creates complete OBJ file with vertices, normals, and faces
|
|
292
|
-
- Generates MTL file with material definitions
|
|
293
|
-
- Handles proper face orientation and winding order
|
|
294
|
-
- Supports color mapping for visualization
|
|
295
|
-
- Uses consistent coordinate system throughout
|
|
296
|
-
|
|
297
|
-
File Format Details:
|
|
298
|
-
OBJ file contains:
|
|
299
|
-
- Vertex coordinates (v)
|
|
300
|
-
- Normal vectors (vn)
|
|
301
|
-
- Material references (usemtl)
|
|
302
|
-
- Face definitions (f)
|
|
303
|
-
|
|
304
|
-
MTL file contains:
|
|
305
|
-
- Material names and colors
|
|
306
|
-
- Ambient, diffuse, and specular properties
|
|
307
|
-
- Transparency settings
|
|
308
|
-
- Illumination model definitions
|
|
309
|
-
"""
|
|
310
|
-
if voxel_color_map is None:
|
|
311
|
-
voxel_color_map = get_voxel_color_map()
|
|
312
|
-
|
|
313
|
-
# Extract unique voxel values (excluding zero)
|
|
314
|
-
unique_voxel_values = np.unique(array)
|
|
315
|
-
unique_voxel_values = unique_voxel_values[unique_voxel_values != 0]
|
|
316
|
-
|
|
317
|
-
# Map voxel values to material names
|
|
318
|
-
voxel_value_to_material = {val: f'material_{val}' for val in unique_voxel_values}
|
|
319
|
-
|
|
320
|
-
# Define normal vectors for each face direction
|
|
321
|
-
normals = [
|
|
322
|
-
(1.0, 0.0, 0.0), # 1: +X Right face
|
|
323
|
-
(-1.0, 0.0, 0.0), # 2: -X Left face
|
|
324
|
-
(0.0, 1.0, 0.0), # 3: +Y Top face
|
|
325
|
-
(0.0, -1.0, 0.0), # 4: -Y Bottom face
|
|
326
|
-
(0.0, 0.0, 1.0), # 5: +Z Front face
|
|
327
|
-
(0.0, 0.0, -1.0), # 6: -Z Back face
|
|
328
|
-
]
|
|
329
|
-
|
|
330
|
-
# Map direction names to normal indices
|
|
331
|
-
normal_indices = {
|
|
332
|
-
'nx': 2,
|
|
333
|
-
'px': 1,
|
|
334
|
-
'ny': 4,
|
|
335
|
-
'py': 3,
|
|
336
|
-
'nz': 6,
|
|
337
|
-
'pz': 5,
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
# Initialize data structures
|
|
341
|
-
vertex_list = []
|
|
342
|
-
vertex_dict = {}
|
|
343
|
-
faces_per_material = {}
|
|
344
|
-
|
|
345
|
-
# Transpose array for correct orientation in output
|
|
346
|
-
array = array.transpose(2, 1, 0) # Now array[x, y, z]
|
|
347
|
-
size_x, size_y, size_z = array.shape
|
|
348
|
-
|
|
349
|
-
# Define processing directions and their normals
|
|
350
|
-
directions = [
|
|
351
|
-
('nx', (-1, 0, 0)),
|
|
352
|
-
('px', (1, 0, 0)),
|
|
353
|
-
('ny', (0, -1, 0)),
|
|
354
|
-
('py', (0, 1, 0)),
|
|
355
|
-
('nz', (0, 0, -1)),
|
|
356
|
-
('pz', (0, 0, 1)),
|
|
357
|
-
]
|
|
358
|
-
|
|
359
|
-
# Process each face direction
|
|
360
|
-
for direction, normal in directions:
|
|
361
|
-
normal_idx = normal_indices[direction]
|
|
362
|
-
|
|
363
|
-
# Process X-axis aligned faces
|
|
364
|
-
if direction in ('nx', 'px'):
|
|
365
|
-
for x in range(size_x):
|
|
366
|
-
voxel_slice = array[x, :, :]
|
|
367
|
-
if direction == 'nx':
|
|
368
|
-
neighbor_slice = array[x - 1, :, :] if x > 0 else np.zeros_like(voxel_slice)
|
|
369
|
-
layer = x
|
|
370
|
-
else:
|
|
371
|
-
neighbor_slice = array[x + 1, :, :] if x + 1 < size_x else np.zeros_like(voxel_slice)
|
|
372
|
-
layer = x + 1
|
|
373
|
-
|
|
374
|
-
# Create mask for faces that need to be generated
|
|
375
|
-
mask = np.where((voxel_slice != neighbor_slice) & (voxel_slice != 0), voxel_slice, 0)
|
|
376
|
-
mesh_faces(mask, layer, 'x', direction == 'px', normal_idx, voxel_size,
|
|
377
|
-
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material)
|
|
378
|
-
|
|
379
|
-
# Process Y-axis aligned faces
|
|
380
|
-
elif direction in ('ny', 'py'):
|
|
381
|
-
for y in range(size_y):
|
|
382
|
-
voxel_slice = array[:, y, :]
|
|
383
|
-
if direction == 'ny':
|
|
384
|
-
neighbor_slice = array[:, y - 1, :] if y > 0 else np.zeros_like(voxel_slice)
|
|
385
|
-
layer = y
|
|
386
|
-
else:
|
|
387
|
-
neighbor_slice = array[:, y + 1, :] if y + 1 < size_y else np.zeros_like(voxel_slice)
|
|
388
|
-
layer = y + 1
|
|
389
|
-
|
|
390
|
-
mask = np.where((voxel_slice != neighbor_slice) & (voxel_slice != 0), voxel_slice, 0)
|
|
391
|
-
mesh_faces(mask, layer, 'y', direction == 'py', normal_idx, voxel_size,
|
|
392
|
-
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material)
|
|
393
|
-
|
|
394
|
-
# Process Z-axis aligned faces
|
|
395
|
-
elif direction in ('nz', 'pz'):
|
|
396
|
-
for z in range(size_z):
|
|
397
|
-
voxel_slice = array[:, :, z]
|
|
398
|
-
if direction == 'nz':
|
|
399
|
-
neighbor_slice = array[:, :, z - 1] if z > 0 else np.zeros_like(voxel_slice)
|
|
400
|
-
layer = z
|
|
401
|
-
else:
|
|
402
|
-
neighbor_slice = array[:, :, z + 1] if z + 1 < size_z else np.zeros_like(voxel_slice)
|
|
403
|
-
layer = z + 1
|
|
404
|
-
|
|
405
|
-
mask = np.where((voxel_slice != neighbor_slice) & (voxel_slice != 0), voxel_slice, 0)
|
|
406
|
-
mesh_faces(mask, layer, 'z', direction == 'pz', normal_idx, voxel_size,
|
|
407
|
-
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material)
|
|
408
|
-
|
|
409
|
-
# Create output directory if it doesn't exist
|
|
410
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
411
|
-
|
|
412
|
-
# Define output file paths
|
|
413
|
-
obj_file_path = os.path.join(output_dir, f'{file_name}.obj')
|
|
414
|
-
mtl_file_path = os.path.join(output_dir, f'{file_name}.mtl')
|
|
415
|
-
|
|
416
|
-
# Write OBJ file
|
|
417
|
-
with open(obj_file_path, 'w') as f:
|
|
418
|
-
f.write('# Generated OBJ file\n\n')
|
|
419
|
-
f.write('# group\no \n\n')
|
|
420
|
-
f.write(f'# material\nmtllib {file_name}.mtl\n\n')
|
|
421
|
-
|
|
422
|
-
# Write normal vectors
|
|
423
|
-
f.write('# normals\n')
|
|
424
|
-
for nx, ny, nz in normals:
|
|
425
|
-
f.write(f'vn {nx:.6f} {ny:.6f} {nz:.6f}\n')
|
|
426
|
-
f.write('\n')
|
|
427
|
-
|
|
428
|
-
# Write vertex coordinates
|
|
429
|
-
f.write('# verts\n')
|
|
430
|
-
for vx, vy, vz in vertex_list:
|
|
431
|
-
f.write(f'v {vx:.6f} {vy:.6f} {vz:.6f}\n')
|
|
432
|
-
f.write('\n')
|
|
433
|
-
|
|
434
|
-
# Write faces grouped by material
|
|
435
|
-
f.write('# faces\n')
|
|
436
|
-
for material_name, faces in faces_per_material.items():
|
|
437
|
-
f.write(f'usemtl {material_name}\n')
|
|
438
|
-
for face in faces:
|
|
439
|
-
v_indices = [str(vi) for vi in face['vertices']]
|
|
440
|
-
normal_idx = face['normal_idx']
|
|
441
|
-
face_str = ' '.join([f'{vi}//{normal_idx}' for vi in face['vertices']])
|
|
442
|
-
f.write(f'f {face_str}\n')
|
|
443
|
-
f.write('\n')
|
|
444
|
-
|
|
445
|
-
# Write MTL file with material definitions
|
|
446
|
-
with open(mtl_file_path, 'w') as f:
|
|
447
|
-
f.write('# Material file\n\n')
|
|
448
|
-
for voxel_value in unique_voxel_values:
|
|
449
|
-
material_name = voxel_value_to_material[voxel_value]
|
|
450
|
-
color = voxel_color_map.get(voxel_value, [0, 0, 0])
|
|
451
|
-
r, g, b = [c / 255.0 for c in color]
|
|
452
|
-
f.write(f'newmtl {material_name}\n')
|
|
453
|
-
f.write(f'Ka {r:.6f} {g:.6f} {b:.6f}\n') # Ambient color
|
|
454
|
-
f.write(f'Kd {r:.6f} {g:.6f} {b:.6f}\n') # Diffuse color
|
|
455
|
-
f.write(f'Ke {r:.6f} {g:.6f} {b:.6f}\n') # Emissive color
|
|
456
|
-
f.write('Ks 0.500000 0.500000 0.500000\n') # Specular reflection
|
|
457
|
-
f.write('Ns 50.000000\n') # Specular exponent
|
|
458
|
-
f.write('illum 2\n\n') # Illumination model
|
|
459
|
-
|
|
460
|
-
print(f'OBJ and MTL files have been generated in {output_dir} with the base name "{file_name}".')
|
|
461
|
-
|
|
462
|
-
def grid_to_obj(value_array_ori, dem_array_ori, output_dir, file_name, cell_size, offset,
|
|
463
|
-
colormap_name='viridis', num_colors=256, alpha=1.0, vmin=None, vmax=None):
|
|
464
|
-
"""
|
|
465
|
-
Converts a 2D array of values and a corresponding DEM array to an OBJ file
|
|
466
|
-
with specified colormap, transparency, and value range.
|
|
467
|
-
|
|
468
|
-
This function creates a 3D visualization of 2D grid data by using elevation
|
|
469
|
-
data and color mapping. It's particularly useful for visualizing terrain data,
|
|
470
|
-
analysis results, or any 2D data that should be displayed with elevation.
|
|
471
|
-
|
|
472
|
-
Args:
|
|
473
|
-
value_array_ori (ndarray): 2D array of values to visualize.
|
|
474
|
-
These values will be mapped to colors using the specified colormap.
|
|
475
|
-
dem_array_ori (ndarray): 2D array of DEM values corresponding to value_array.
|
|
476
|
-
Provides elevation data for the 3D visualization.
|
|
477
|
-
output_dir (str): Directory to save the OBJ and MTL files.
|
|
478
|
-
Will be created if it doesn't exist.
|
|
479
|
-
file_name (str): Base name for the output files.
|
|
480
|
-
Used for both .obj and .mtl files.
|
|
481
|
-
cell_size (float): Size of each cell in the grid (e.g., in meters).
|
|
482
|
-
Used to scale the model to real-world units.
|
|
483
|
-
offset (float): Elevation offset added after quantization.
|
|
484
|
-
Useful for adjusting the base height of the model.
|
|
485
|
-
colormap_name (str, optional): Name of the Matplotlib colormap to use.
|
|
486
|
-
Defaults to 'viridis'. Must be a valid Matplotlib colormap name.
|
|
487
|
-
num_colors (int, optional): Number of discrete colors to use from the colormap.
|
|
488
|
-
Defaults to 256. Higher values give smoother color transitions.
|
|
489
|
-
alpha (float, optional): Transparency value between 0.0 (transparent) and 1.0 (opaque).
|
|
490
|
-
Defaults to 1.0 (fully opaque).
|
|
491
|
-
vmin (float, optional): Minimum value for colormap normalization.
|
|
492
|
-
If None, uses data minimum. Used to control color mapping range.
|
|
493
|
-
vmax (float, optional): Maximum value for colormap normalization.
|
|
494
|
-
If None, uses data maximum. Used to control color mapping range.
|
|
495
|
-
|
|
496
|
-
Notes:
|
|
497
|
-
- Automatically handles NaN values in input arrays
|
|
498
|
-
- Creates triangulated mesh for proper rendering
|
|
499
|
-
- Supports transparency and color mapping
|
|
500
|
-
- Generates complete OBJ and MTL files
|
|
501
|
-
- Maintains consistent coordinate system
|
|
502
|
-
- Optimizes mesh generation for large grids
|
|
503
|
-
|
|
504
|
-
Raises:
|
|
505
|
-
ValueError: If vmin equals vmax or if colormap_name is invalid
|
|
506
|
-
"""
|
|
507
|
-
# Validate input arrays
|
|
508
|
-
if value_array_ori.shape != dem_array_ori.shape:
|
|
509
|
-
raise ValueError("The value array and DEM array must have the same shape.")
|
|
510
|
-
|
|
511
|
-
# Get the dimensions
|
|
512
|
-
rows, cols = value_array_ori.shape
|
|
513
|
-
|
|
514
|
-
# Flip arrays vertically and normalize DEM values
|
|
515
|
-
value_array = np.flipud(value_array_ori.copy())
|
|
516
|
-
dem_array = np.flipud(dem_array_ori.copy()) - np.min(dem_array_ori)
|
|
517
|
-
|
|
518
|
-
# Get valid indices (non-NaN)
|
|
519
|
-
valid_indices = np.argwhere(~np.isnan(value_array))
|
|
520
|
-
|
|
521
|
-
# Set vmin and vmax if not provided
|
|
522
|
-
if vmin is None:
|
|
523
|
-
vmin = np.nanmin(value_array)
|
|
524
|
-
if vmax is None:
|
|
525
|
-
vmax = np.nanmax(value_array)
|
|
526
|
-
|
|
527
|
-
# Handle case where vmin equals vmax
|
|
528
|
-
if vmin == vmax:
|
|
529
|
-
raise ValueError("vmin and vmax cannot be the same value.")
|
|
530
|
-
|
|
531
|
-
# Normalize values to [0, 1] based on vmin and vmax
|
|
532
|
-
normalized_values = (value_array - vmin) / (vmax - vmin)
|
|
533
|
-
# Clip normalized values to [0, 1]
|
|
534
|
-
normalized_values = np.clip(normalized_values, 0.0, 1.0)
|
|
535
|
-
|
|
536
|
-
# Prepare the colormap
|
|
537
|
-
if colormap_name not in plt.colormaps():
|
|
538
|
-
raise ValueError(f"Colormap '{colormap_name}' is not recognized. Please choose a valid Matplotlib colormap.")
|
|
539
|
-
colormap = plt.get_cmap(colormap_name, num_colors) # Discrete colors
|
|
540
|
-
|
|
541
|
-
# Create a mapping from quantized colors to material names
|
|
542
|
-
color_to_material = {}
|
|
543
|
-
materials = []
|
|
544
|
-
material_index = 1 # Start indexing materials from 1
|
|
545
|
-
|
|
546
|
-
# Initialize vertex tracking
|
|
547
|
-
vertex_list = []
|
|
548
|
-
vertex_dict = {} # To avoid duplicate vertices
|
|
549
|
-
vertex_index = 1 # OBJ indices start at 1
|
|
550
|
-
|
|
551
|
-
faces_per_material = {}
|
|
552
|
-
|
|
553
|
-
# Process each valid cell in the grid
|
|
554
|
-
for idx in valid_indices:
|
|
555
|
-
i, j = idx # i is the row index, j is the column index
|
|
556
|
-
value = value_array[i, j]
|
|
557
|
-
normalized_value = normalized_values[i, j]
|
|
558
|
-
|
|
559
|
-
# Get the color from the colormap
|
|
560
|
-
rgba = colormap(normalized_value)
|
|
561
|
-
rgb = rgba[:3] # Ignore alpha channel
|
|
562
|
-
r, g, b = [int(c * 255) for c in rgb]
|
|
563
|
-
|
|
564
|
-
# Create unique material name for this color
|
|
565
|
-
color_key = (r, g, b)
|
|
566
|
-
material_name = f'material_{r}_{g}_{b}'
|
|
567
|
-
|
|
568
|
-
# Add new material if not seen before
|
|
569
|
-
if material_name not in color_to_material:
|
|
570
|
-
color_to_material[material_name] = {
|
|
571
|
-
'r': r / 255.0,
|
|
572
|
-
'g': g / 255.0,
|
|
573
|
-
'b': b / 255.0,
|
|
574
|
-
'alpha': alpha
|
|
575
|
-
}
|
|
576
|
-
materials.append(material_name)
|
|
577
|
-
|
|
578
|
-
# Calculate cell vertices
|
|
579
|
-
x0 = i * cell_size
|
|
580
|
-
x1 = (i + 1) * cell_size
|
|
581
|
-
y0 = j * cell_size
|
|
582
|
-
y1 = (j + 1) * cell_size
|
|
583
|
-
|
|
584
|
-
# Calculate elevation with quantization and offset
|
|
585
|
-
z = cell_size * int(dem_array[i, j] / cell_size + 1.5) + offset
|
|
586
|
-
|
|
587
|
-
# Define quad vertices
|
|
588
|
-
vertices = [
|
|
589
|
-
(x0, y0, z),
|
|
590
|
-
(x1, y0, z),
|
|
591
|
-
(x1, y1, z),
|
|
592
|
-
(x0, y1, z),
|
|
593
|
-
]
|
|
594
|
-
|
|
595
|
-
# Convert vertices to indices
|
|
596
|
-
indices = []
|
|
597
|
-
for v in vertices:
|
|
598
|
-
if v not in vertex_dict:
|
|
599
|
-
vertex_list.append(v)
|
|
600
|
-
vertex_dict[v] = vertex_index
|
|
601
|
-
vertex_index += 1
|
|
602
|
-
indices.append(vertex_dict[v])
|
|
603
|
-
|
|
604
|
-
# Create triangulated faces
|
|
605
|
-
faces = [
|
|
606
|
-
{'vertices': [indices[0], indices[1], indices[2]]},
|
|
607
|
-
{'vertices': [indices[0], indices[2], indices[3]]},
|
|
608
|
-
]
|
|
609
|
-
|
|
610
|
-
# Store faces by material
|
|
611
|
-
if material_name not in faces_per_material:
|
|
612
|
-
faces_per_material[material_name] = []
|
|
613
|
-
faces_per_material[material_name].extend(faces)
|
|
614
|
-
|
|
615
|
-
# Create output directory if needed
|
|
616
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
617
|
-
|
|
618
|
-
# Define output file paths
|
|
619
|
-
obj_file_path = os.path.join(output_dir, f'{file_name}.obj')
|
|
620
|
-
mtl_file_path = os.path.join(output_dir, f'{file_name}.mtl')
|
|
621
|
-
|
|
622
|
-
# Write OBJ file
|
|
623
|
-
with open(obj_file_path, 'w') as f:
|
|
624
|
-
f.write('# Generated OBJ file\n\n')
|
|
625
|
-
f.write(f'mtllib {file_name}.mtl\n\n')
|
|
626
|
-
# Write vertices
|
|
627
|
-
for vx, vy, vz in vertex_list:
|
|
628
|
-
f.write(f'v {vx:.6f} {vy:.6f} {vz:.6f}\n')
|
|
629
|
-
f.write('\n')
|
|
630
|
-
# Write faces grouped by material
|
|
631
|
-
for material_name in materials:
|
|
632
|
-
f.write(f'usemtl {material_name}\n')
|
|
633
|
-
faces = faces_per_material[material_name]
|
|
634
|
-
for face in faces:
|
|
635
|
-
v_indices = face['vertices']
|
|
636
|
-
face_str = ' '.join([f'{vi}' for vi in v_indices])
|
|
637
|
-
f.write(f'f {face_str}\n')
|
|
638
|
-
f.write('\n')
|
|
639
|
-
|
|
640
|
-
# Write MTL file with material properties
|
|
641
|
-
with open(mtl_file_path, 'w') as f:
|
|
642
|
-
for material_name in materials:
|
|
643
|
-
color = color_to_material[material_name]
|
|
644
|
-
r, g, b = color['r'], color['g'], color['b']
|
|
645
|
-
a = color['alpha']
|
|
646
|
-
f.write(f'newmtl {material_name}\n')
|
|
647
|
-
f.write(f'Ka {r:.6f} {g:.6f} {b:.6f}\n') # Ambient color
|
|
648
|
-
f.write(f'Kd {r:.6f} {g:.6f} {b:.6f}\n') # Diffuse color
|
|
649
|
-
f.write(f'Ks 0.000000 0.000000 0.000000\n') # Specular reflection
|
|
650
|
-
f.write('Ns 10.000000\n') # Specular exponent
|
|
651
|
-
f.write('illum 1\n') # Illumination model
|
|
652
|
-
f.write(f'd {a:.6f}\n') # Transparency (alpha)
|
|
653
|
-
f.write('\n')
|
|
654
|
-
|
|
655
|
-
print(f'OBJ and MTL files have been generated in {output_dir} with the base name "{file_name}".')
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def export_netcdf_to_obj(
|
|
659
|
-
voxcity_nc,
|
|
660
|
-
scalar_nc,
|
|
661
|
-
lonlat_txt,
|
|
662
|
-
output_dir,
|
|
663
|
-
vox_base_filename="voxcity_objects",
|
|
664
|
-
tm_base_filename="tm_isosurfaces",
|
|
665
|
-
scalar_var="tm",
|
|
666
|
-
scalar_building_value=-999.99,
|
|
667
|
-
scalar_building_tol=1e-4,
|
|
668
|
-
stride_vox=(1, 1, 1),
|
|
669
|
-
stride_scalar=(1, 1, 1),
|
|
670
|
-
contour_levels=24,
|
|
671
|
-
cmap_name="magma",
|
|
672
|
-
vmin=None,
|
|
673
|
-
vmax=None,
|
|
674
|
-
iso_vmin=None,
|
|
675
|
-
iso_vmax=None,
|
|
676
|
-
greedy_vox=True,
|
|
677
|
-
vox_voxel_size=None,
|
|
678
|
-
scalar_spacing=None,
|
|
679
|
-
opacity_points=None,
|
|
680
|
-
max_opacity=0.10,
|
|
681
|
-
classes_to_show=None,
|
|
682
|
-
voxel_color_scheme="default",
|
|
683
|
-
max_faces_warn=1_000_000,
|
|
684
|
-
):
|
|
685
|
-
"""
|
|
686
|
-
Export two OBJ/MTL files using the same local meter frame:
|
|
687
|
-
- VoxCity voxels: opaque, per-class color, fixed face winding and normals
|
|
688
|
-
- Scalar iso-surfaces: colormap colors with variable transparency
|
|
689
|
-
|
|
690
|
-
The two outputs share the same XY origin and axes (X east, Y north, Z up),
|
|
691
|
-
anchored at the minimum lon/lat of the VoxCity bounding rectangle.
|
|
692
|
-
|
|
693
|
-
Args:
|
|
694
|
-
voxcity_nc (str): Path to VoxCity NetCDF (must include variable 'voxels' and coords 'x','y','z').
|
|
695
|
-
scalar_nc (str): Path to scalar NetCDF containing variable specified by scalar_var.
|
|
696
|
-
lonlat_txt (str): Text file with columns: i j lon lat (1-based indices) describing the scalar grid georef.
|
|
697
|
-
output_dir (str): Directory to write results.
|
|
698
|
-
vox_base_filename (str): Base filename for VoxCity OBJ/MTL.
|
|
699
|
-
tm_base_filename (str): Base filename for scalar iso-surfaces OBJ/MTL.
|
|
700
|
-
scalar_var (str): Name of scalar variable in scalar_nc.
|
|
701
|
-
scalar_building_value (float): Value used in scalar field to mark buildings (to be masked).
|
|
702
|
-
scalar_building_tol (float): Tolerance for building masking (isclose).
|
|
703
|
-
stride_vox (tuple[int,int,int]): Downsampling strides for VoxCity (z,y,x) in voxels.
|
|
704
|
-
stride_scalar (tuple[int,int,int]): Downsampling strides for scalar (k,j,i).
|
|
705
|
-
contour_levels (int): Number of iso-surface levels between vmin and vmax.
|
|
706
|
-
cmap_name (str): Matplotlib colormap name for iso-surfaces.
|
|
707
|
-
vmin (float|None): Minimum scalar value for color mapping and iso range. If None, inferred.
|
|
708
|
-
vmax (float|None): Maximum scalar value for color mapping and iso range. If None, inferred.
|
|
709
|
-
iso_vmin (float|None): Minimum scalar value to generate iso-surface levels. If None, uses vmin.
|
|
710
|
-
iso_vmax (float|None): Maximum scalar value to generate iso-surface levels. If None, uses vmax.
|
|
711
|
-
greedy_vox (bool): If True, use greedy meshing for VoxCity faces to reduce triangles.
|
|
712
|
-
vox_voxel_size (float|tuple[float,float,float]|None): If provided, overrides VoxCity voxel spacing
|
|
713
|
-
for X,Y,Z respectively in meters. A single float applies to all axes.
|
|
714
|
-
scalar_spacing (tuple[float,float,float]|None): If provided, overrides scalar grid spacing (dx,dy,dz)
|
|
715
|
-
used for iso-surface generation. Values are in meters.
|
|
716
|
-
opacity_points (list[tuple[float,float]]|None): Transfer function control points (value, alpha in [0..1]).
|
|
717
|
-
max_opacity (float): Global max opacity multiplier for iso-surfaces (0..1).
|
|
718
|
-
classes_to_show (set[int]|None): Optional subset of voxel classes to export; None -> all present (except 0).
|
|
719
|
-
voxel_color_scheme (str): Color scheme name passed to get_voxel_color_map.
|
|
720
|
-
max_faces_warn (int): Warn if a single class exceeds this many faces.
|
|
721
|
-
|
|
722
|
-
Returns:
|
|
723
|
-
dict: Paths of written files: keys 'vox_obj','vox_mtl','tm_obj','tm_mtl' (values may be None).
|
|
724
|
-
"""
|
|
725
|
-
import json
|
|
726
|
-
import numpy as np
|
|
727
|
-
import os
|
|
728
|
-
import xarray as xr
|
|
729
|
-
import trimesh
|
|
730
|
-
|
|
731
|
-
try:
|
|
732
|
-
from skimage import measure as skim
|
|
733
|
-
except Exception as e: # pragma: no cover - optional dependency
|
|
734
|
-
raise ImportError(
|
|
735
|
-
"scikit-image is required for iso-surface generation. Install 'scikit-image'."
|
|
736
|
-
) from e
|
|
737
|
-
|
|
738
|
-
from matplotlib import cm
|
|
739
|
-
|
|
740
|
-
if opacity_points is None:
|
|
741
|
-
opacity_points = [(-0.2, 0.00), (2.0, 1.00)]
|
|
742
|
-
|
|
743
|
-
def find_dims(ds):
|
|
744
|
-
lvl = ["k", "level", "lev", "z", "height", "alt", "plev"]
|
|
745
|
-
yy = ["j", "y", "south_north", "lat", "latitude"]
|
|
746
|
-
xx = ["i", "x", "west_east", "lon", "longitude"]
|
|
747
|
-
tt = ["time", "Times"]
|
|
748
|
-
|
|
749
|
-
def pick(cands):
|
|
750
|
-
for c in cands:
|
|
751
|
-
if c in ds.dims:
|
|
752
|
-
return c
|
|
753
|
-
return None
|
|
754
|
-
|
|
755
|
-
t = pick(tt)
|
|
756
|
-
k = pick(lvl)
|
|
757
|
-
j = pick(yy)
|
|
758
|
-
i = pick(xx)
|
|
759
|
-
if (k is None or j is None or i is None) and len(ds.dims) >= 3:
|
|
760
|
-
dims = list(ds.dims)
|
|
761
|
-
k = k or dims[0]
|
|
762
|
-
j = j or dims[-2]
|
|
763
|
-
i = i or dims[-1]
|
|
764
|
-
return t, k, j, i
|
|
765
|
-
|
|
766
|
-
def squeeze_to_kji(da, tname, kname, jname, iname, time_index=0):
|
|
767
|
-
if tname and tname in da.dims:
|
|
768
|
-
da = da.isel({tname: time_index})
|
|
769
|
-
for d in list(da.dims):
|
|
770
|
-
if d not in (kname, jname, iname):
|
|
771
|
-
da = da.isel({d: 0})
|
|
772
|
-
return da.transpose(*(d for d in (kname, jname, iname) if d in da.dims))
|
|
773
|
-
|
|
774
|
-
def downsample3(a, sk, sj, si):
|
|
775
|
-
return a[:: max(1, sk), :: max(1, sj), :: max(1, si)]
|
|
776
|
-
|
|
777
|
-
def clip_minmax(arr, frac):
|
|
778
|
-
v = np.asarray(arr)
|
|
779
|
-
v = v[np.isfinite(v)]
|
|
780
|
-
if v.size == 0:
|
|
781
|
-
return 0.0, 1.0
|
|
782
|
-
if frac <= 0:
|
|
783
|
-
return float(np.nanmin(v)), float(np.nanmax(v))
|
|
784
|
-
vmin_ = float(np.nanpercentile(v, 100 * frac))
|
|
785
|
-
vmax_ = float(np.nanpercentile(v, 100 * (1 - frac)))
|
|
786
|
-
if vmin_ >= vmax_:
|
|
787
|
-
vmin_, vmax_ = float(np.nanmin(v)), float(np.nanmax(v))
|
|
788
|
-
return vmin_, vmax_
|
|
789
|
-
|
|
790
|
-
def meters_per_degree(lat_rad):
|
|
791
|
-
m_per_deg_lat = 111132.92 - 559.82 * np.cos(2 * lat_rad) + 1.175 * np.cos(4 * lat_rad) - 0.0023 * np.cos(6 * lat_rad)
|
|
792
|
-
m_per_deg_lon = 111412.84 * np.cos(lat_rad) - 93.5 * np.cos(3 * lat_rad) + 0.118 * np.cos(5 * lat_rad)
|
|
793
|
-
return m_per_deg_lat, m_per_deg_lon
|
|
794
|
-
|
|
795
|
-
def opacity_at(v, points):
|
|
796
|
-
if not points:
|
|
797
|
-
return 0.0 if np.isscalar(v) else np.zeros_like(v)
|
|
798
|
-
pts = sorted((float(x), float(a)) for x, a in points)
|
|
799
|
-
xs = np.array([p[0] for p in pts], dtype=float)
|
|
800
|
-
as_ = np.array([p[1] for p in pts], dtype=float)
|
|
801
|
-
v_arr = np.asarray(v, dtype=float)
|
|
802
|
-
out = np.empty_like(v_arr, dtype=float)
|
|
803
|
-
out[v_arr <= xs[0]] = as_[0]
|
|
804
|
-
out[v_arr >= xs[-1]] = as_[-1]
|
|
805
|
-
idx = np.searchsorted(xs, v_arr, side="right") - 1
|
|
806
|
-
idx = np.clip(idx, 0, len(xs) - 2)
|
|
807
|
-
x0, x1 = xs[idx], xs[idx + 1]
|
|
808
|
-
a0, a1 = as_[idx], as_[idx + 1]
|
|
809
|
-
t = np.where(x1 > x0, (v_arr - x0) / (x1 - x0), 0.0)
|
|
810
|
-
mid = (v_arr > xs[0]) & (v_arr < xs[-1])
|
|
811
|
-
out[mid] = a0[mid] + t[mid] * (a1[mid] - a0[mid])
|
|
812
|
-
return out.item() if np.isscalar(v) else out
|
|
813
|
-
|
|
814
|
-
def _exposed_face_masks(occ):
|
|
815
|
-
K, J, I = occ.shape
|
|
816
|
-
p = np.pad(occ, ((0, 0), (0, 0), (0, 1)), constant_values=False)
|
|
817
|
-
posx = occ & (~p[..., 1:])
|
|
818
|
-
p = np.pad(occ, ((0, 0), (0, 0), (1, 0)), constant_values=False)
|
|
819
|
-
negx = occ & (~p[..., :-1])
|
|
820
|
-
p = np.pad(occ, ((0, 0), (0, 1), (0, 0)), constant_values=False)
|
|
821
|
-
posy = occ & (~p[:, 1:, :])
|
|
822
|
-
p = np.pad(occ, ((0, 0), (1, 0), (0, 0)), constant_values=False)
|
|
823
|
-
negy = occ & (~p[:, :-1, :])
|
|
824
|
-
p = np.pad(occ, ((0, 1), (0, 0), (0, 0)), constant_values=False)
|
|
825
|
-
posz = occ & (~p[1:, :, :])
|
|
826
|
-
p = np.pad(occ, ((1, 0), (0, 0), (0, 0)), constant_values=False)
|
|
827
|
-
negz = occ & (~p[:-1, :, :])
|
|
828
|
-
return posx, negx, posy, negy, posz, negz
|
|
829
|
-
|
|
830
|
-
def _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx):
|
|
831
|
-
N = k.size
|
|
832
|
-
if N == 0:
|
|
833
|
-
return np.empty((0, 3)), np.empty((0, 3), dtype=np.int64), start_idx
|
|
834
|
-
|
|
835
|
-
dx = (X[1] - X[0]) if len(X) > 1 else 1.0
|
|
836
|
-
dy = (Y[1] - Y[0]) if len(Y) > 1 else 1.0
|
|
837
|
-
dz = (Z[1] - Z[0]) if len(Z) > 1 else 1.0
|
|
838
|
-
|
|
839
|
-
x = X[i].astype(np.float64)
|
|
840
|
-
y = Y[j].astype(np.float64)
|
|
841
|
-
z = Z[k].astype(np.float64)
|
|
842
|
-
hx, hy, hz = dx / 2.0, dy / 2.0, dz / 2.0
|
|
843
|
-
|
|
844
|
-
if plane == "+x":
|
|
845
|
-
P = np.column_stack([x + hx, y - hy, z - hz])
|
|
846
|
-
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
847
|
-
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
848
|
-
S = np.column_stack([x + hx, y - hy, z + hz])
|
|
849
|
-
order = "default"
|
|
850
|
-
elif plane == "-x":
|
|
851
|
-
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
852
|
-
Q = np.column_stack([x - hx, y + hy, z + hz])
|
|
853
|
-
R = np.column_stack([x - hx, y + hy, z - hz])
|
|
854
|
-
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
855
|
-
order = "default"
|
|
856
|
-
elif plane == "+y":
|
|
857
|
-
P = np.column_stack([x - hx, y + hy, z - hz])
|
|
858
|
-
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
859
|
-
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
860
|
-
S = np.column_stack([x - hx, y + hy, z + hz])
|
|
861
|
-
order = "flip" # enforce outward normals
|
|
862
|
-
elif plane == "-y":
|
|
863
|
-
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
864
|
-
Q = np.column_stack([x + hx, y - hy, z + hz])
|
|
865
|
-
R = np.column_stack([x + hx, y - hy, z - hz])
|
|
866
|
-
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
867
|
-
order = "flip"
|
|
868
|
-
elif plane == "+z":
|
|
869
|
-
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
870
|
-
Q = np.column_stack([x + hx, y - hy, z + hz])
|
|
871
|
-
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
872
|
-
S = np.column_stack([x - hx, y + hy, z + hz])
|
|
873
|
-
order = "default"
|
|
874
|
-
else: # "-z"
|
|
875
|
-
P = np.column_stack([x - hx, y + hy, z - hz])
|
|
876
|
-
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
877
|
-
R = np.column_stack([x + hx, y - hy, z - hz])
|
|
878
|
-
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
879
|
-
order = "default"
|
|
880
|
-
|
|
881
|
-
verts = np.vstack([P, Q, R, S])
|
|
882
|
-
a = np.arange(N, dtype=np.int64) + start_idx
|
|
883
|
-
b = a + N
|
|
884
|
-
c = a + 2 * N
|
|
885
|
-
d = a + 3 * N
|
|
886
|
-
|
|
887
|
-
if order == "default":
|
|
888
|
-
tris = np.vstack([np.column_stack([a, b, c]), np.column_stack([a, c, d])])
|
|
889
|
-
else:
|
|
890
|
-
tris = np.vstack([np.column_stack([a, c, b]), np.column_stack([a, d, c])])
|
|
891
|
-
|
|
892
|
-
return verts, tris, start_idx + 4 * N
|
|
893
|
-
|
|
894
|
-
def make_voxel_mesh_uniform_color(occ_mask, X, Y, Z, rgb, name="class"):
|
|
895
|
-
posx, negx, posy, negy, posz, negz = _exposed_face_masks(occ_mask.astype(bool))
|
|
896
|
-
total_faces = int(posx.sum() + negx.sum() + posy.sum() + negy.sum() + posz.sum() + negz.sum())
|
|
897
|
-
if total_faces == 0:
|
|
898
|
-
return None, 0
|
|
899
|
-
if total_faces > max_faces_warn:
|
|
900
|
-
print(f" Warning: {name} faces={total_faces:,} (> {max_faces_warn:,}). Consider increasing stride.")
|
|
901
|
-
|
|
902
|
-
verts_all, tris_all, start_idx = [], [], 0
|
|
903
|
-
for plane, mask in (("+x", posx), ("-x", negx), ("+y", posy), ("-y", negy), ("+z", posz), ("-z", negz)):
|
|
904
|
-
idx = np.argwhere(mask)
|
|
905
|
-
if idx.size == 0:
|
|
906
|
-
continue
|
|
907
|
-
k, j, i = idx[:, 0], idx[:, 1], idx[:, 2]
|
|
908
|
-
Vp, Tp, start_idx = _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx)
|
|
909
|
-
verts_all.append(Vp)
|
|
910
|
-
tris_all.append(Tp)
|
|
911
|
-
|
|
912
|
-
V = np.vstack(verts_all)
|
|
913
|
-
F = np.vstack(tris_all)
|
|
914
|
-
mesh = trimesh.Trimesh(vertices=V, faces=F, process=False)
|
|
915
|
-
rgba = np.array([int(rgb[0]), int(rgb[1]), int(rgb[2]), 255], dtype=np.uint8)
|
|
916
|
-
mesh.visual.face_colors = np.tile(rgba, (len(F), 1))
|
|
917
|
-
return mesh, len(F)
|
|
918
|
-
|
|
919
|
-
def _greedy_rectangles(mask2d):
|
|
920
|
-
h, w = mask2d.shape
|
|
921
|
-
visited = np.zeros_like(mask2d, dtype=bool)
|
|
922
|
-
rects = []
|
|
923
|
-
for u in range(h):
|
|
924
|
-
v = 0
|
|
925
|
-
while v < w:
|
|
926
|
-
if visited[u, v] or not mask2d[u, v]:
|
|
927
|
-
v += 1
|
|
928
|
-
continue
|
|
929
|
-
# width
|
|
930
|
-
width = 1
|
|
931
|
-
while v + width < w and mask2d[u, v + width] and not visited[u, v + width]:
|
|
932
|
-
width += 1
|
|
933
|
-
# height
|
|
934
|
-
height = 1
|
|
935
|
-
done = False
|
|
936
|
-
while u + height < h and not done:
|
|
937
|
-
for k_ in range(width):
|
|
938
|
-
if (not mask2d[u + height, v + k_]) or visited[u + height, v + k_]:
|
|
939
|
-
done = True
|
|
940
|
-
break
|
|
941
|
-
if not done:
|
|
942
|
-
height += 1
|
|
943
|
-
visited[u:u + height, v:v + width] = True
|
|
944
|
-
rects.append((u, v, height, width))
|
|
945
|
-
v += width
|
|
946
|
-
return rects
|
|
947
|
-
|
|
948
|
-
def make_voxel_mesh_uniform_color_greedy(occ_mask, X, Y, Z, rgb, name="class"):
|
|
949
|
-
posx, negx, posy, negy, posz, negz = _exposed_face_masks(occ_mask.astype(bool))
|
|
950
|
-
total_faces_naive = int(posx.sum() + negx.sum() + posy.sum() + negy.sum() + posz.sum() + negz.sum())
|
|
951
|
-
if total_faces_naive == 0:
|
|
952
|
-
return None, 0
|
|
953
|
-
|
|
954
|
-
dx = (X[1] - X[0]) if len(X) > 1 else 1.0
|
|
955
|
-
dy = (Y[1] - Y[0]) if len(Y) > 1 else 1.0
|
|
956
|
-
dz = (Z[1] - Z[0]) if len(Z) > 1 else 1.0
|
|
957
|
-
hx, hy, hz = dx / 2.0, dy / 2.0, dz / 2.0
|
|
958
|
-
|
|
959
|
-
V_list = []
|
|
960
|
-
F_list = []
|
|
961
|
-
start_idx = 0
|
|
962
|
-
|
|
963
|
-
def add_quad(P, Q, R, S, order):
|
|
964
|
-
nonlocal start_idx
|
|
965
|
-
V_list.extend([P, Q, R, S])
|
|
966
|
-
a = start_idx
|
|
967
|
-
b = start_idx + 1
|
|
968
|
-
c = start_idx + 2
|
|
969
|
-
d = start_idx + 3
|
|
970
|
-
start_idx += 4
|
|
971
|
-
if order == "default":
|
|
972
|
-
F_list.append([a, b, c])
|
|
973
|
-
F_list.append([a, c, d])
|
|
974
|
-
else: # flip
|
|
975
|
-
F_list.append([a, c, b])
|
|
976
|
-
F_list.append([a, d, c])
|
|
977
|
-
|
|
978
|
-
K, J, I = occ_mask.shape
|
|
979
|
-
|
|
980
|
-
# +x and -x: iterate i, mask over (k,j)
|
|
981
|
-
for plane, mask3 in (("+x", posx), ("-x", negx)):
|
|
982
|
-
order = "default"
|
|
983
|
-
for i in range(I):
|
|
984
|
-
m2 = mask3[:, :, i]
|
|
985
|
-
if not np.any(m2):
|
|
986
|
-
continue
|
|
987
|
-
for u, v, h, w in _greedy_rectangles(m2):
|
|
988
|
-
k0, j0 = u, v
|
|
989
|
-
k1, j1 = u + h, v + w
|
|
990
|
-
z0 = Z[k0] - hz
|
|
991
|
-
z1 = Z[k1 - 1] + hz
|
|
992
|
-
y0 = Y[j0] - hy
|
|
993
|
-
y1 = Y[j1 - 1] + hy
|
|
994
|
-
x_center = X[i]
|
|
995
|
-
if plane == "+x":
|
|
996
|
-
x = x_center + hx
|
|
997
|
-
P = (x, y0, z0)
|
|
998
|
-
Q = (x, y1, z0)
|
|
999
|
-
R = (x, y1, z1)
|
|
1000
|
-
S = (x, y0, z1)
|
|
1001
|
-
else: # -x
|
|
1002
|
-
x = x_center - hx
|
|
1003
|
-
P = (x, y0, z1)
|
|
1004
|
-
Q = (x, y1, z1)
|
|
1005
|
-
R = (x, y1, z0)
|
|
1006
|
-
S = (x, y0, z0)
|
|
1007
|
-
add_quad(P, Q, R, S, order)
|
|
1008
|
-
|
|
1009
|
-
# +y and -y: iterate j, mask over (k,i)
|
|
1010
|
-
for plane, mask3 in (("+y", posy), ("-y", negy)):
|
|
1011
|
-
order = "flip" # enforce outward normals like original
|
|
1012
|
-
for j in range(J):
|
|
1013
|
-
m2 = mask3[:, j, :]
|
|
1014
|
-
if not np.any(m2):
|
|
1015
|
-
continue
|
|
1016
|
-
for u, v, h, w in _greedy_rectangles(m2):
|
|
1017
|
-
k0, i0 = u, v
|
|
1018
|
-
k1, i1 = u + h, v + w
|
|
1019
|
-
z0 = Z[k0] - hz
|
|
1020
|
-
z1 = Z[k1 - 1] + hz
|
|
1021
|
-
x0 = X[i0] - hx
|
|
1022
|
-
x1 = X[i1 - 1] + hx
|
|
1023
|
-
y_center = Y[j]
|
|
1024
|
-
if plane == "+y":
|
|
1025
|
-
y = y_center + hy
|
|
1026
|
-
P = (x0, y, z0)
|
|
1027
|
-
Q = (x1, y, z0)
|
|
1028
|
-
R = (x1, y, z1)
|
|
1029
|
-
S = (x0, y, z1)
|
|
1030
|
-
else: # -y
|
|
1031
|
-
y = y_center - hy
|
|
1032
|
-
P = (x0, y, z1)
|
|
1033
|
-
Q = (x1, y, z1)
|
|
1034
|
-
R = (x1, y, z0)
|
|
1035
|
-
S = (x0, y, z0)
|
|
1036
|
-
add_quad(P, Q, R, S, order)
|
|
1037
|
-
|
|
1038
|
-
# +z and -z: iterate k, mask over (j,i)
|
|
1039
|
-
for plane, mask3 in (("+z", posz), ("-z", negz)):
|
|
1040
|
-
order = "default"
|
|
1041
|
-
for k in range(K):
|
|
1042
|
-
m2 = mask3[k, :, :]
|
|
1043
|
-
if not np.any(m2):
|
|
1044
|
-
continue
|
|
1045
|
-
for u, v, h, w in _greedy_rectangles(m2):
|
|
1046
|
-
j0, i0 = u, v
|
|
1047
|
-
j1, i1 = u + h, v + w
|
|
1048
|
-
y0 = Y[j0] - hy
|
|
1049
|
-
y1 = Y[j1 - 1] + hy
|
|
1050
|
-
x0 = X[i0] - hx
|
|
1051
|
-
x1 = X[i1 - 1] + hx
|
|
1052
|
-
z_center = Z[k]
|
|
1053
|
-
if plane == "+z":
|
|
1054
|
-
z = z_center + hz
|
|
1055
|
-
P = (x0, y0, z)
|
|
1056
|
-
Q = (x1, y0, z)
|
|
1057
|
-
R = (x1, y1, z)
|
|
1058
|
-
S = (x0, y1, z)
|
|
1059
|
-
else: # -z
|
|
1060
|
-
z = z_center - hz
|
|
1061
|
-
P = (x0, y1, z)
|
|
1062
|
-
Q = (x1, y1, z)
|
|
1063
|
-
R = (x1, y0, z)
|
|
1064
|
-
S = (x0, y0, z)
|
|
1065
|
-
add_quad(P, Q, R, S, order)
|
|
1066
|
-
|
|
1067
|
-
if not V_list or not F_list:
|
|
1068
|
-
return None, 0
|
|
1069
|
-
V = np.asarray(V_list, dtype=np.float64)
|
|
1070
|
-
F = np.asarray(F_list, dtype=np.int64)
|
|
1071
|
-
mesh = trimesh.Trimesh(vertices=V, faces=F, process=False)
|
|
1072
|
-
rgba = np.array([int(rgb[0]), int(rgb[1]), int(rgb[2]), 255], dtype=np.uint8)
|
|
1073
|
-
mesh.visual.face_colors = np.tile(rgba, (len(F), 1))
|
|
1074
|
-
return mesh, len(F)
|
|
1075
|
-
|
|
1076
|
-
def build_tm_isosurfaces_regular_grid(A_scalar, vmin, vmax, levels, dx, dy, dz, origin_xyz, cmap_name, opacity_points, max_opacity, iso_vmin=None, iso_vmax=None):
|
|
1077
|
-
cmap = cm.get_cmap(cmap_name)
|
|
1078
|
-
meshes = []
|
|
1079
|
-
if levels <= 0:
|
|
1080
|
-
return meshes
|
|
1081
|
-
ivmin = vmin if (iso_vmin is None) else float(iso_vmin)
|
|
1082
|
-
ivmax = vmax if (iso_vmax is None) else float(iso_vmax)
|
|
1083
|
-
if not (ivmin < ivmax):
|
|
1084
|
-
return meshes
|
|
1085
|
-
iso_vals = np.linspace(ivmin, ivmax, int(levels))
|
|
1086
|
-
for iso in iso_vals:
|
|
1087
|
-
a_base = float(opacity_at(iso, opacity_points or []))
|
|
1088
|
-
a_base = min(max(a_base, 0.0), 1.0)
|
|
1089
|
-
alpha = a_base * max_opacity
|
|
1090
|
-
if alpha <= 0.0:
|
|
1091
|
-
continue
|
|
1092
|
-
try:
|
|
1093
|
-
verts, faces, _, _ = skim.marching_cubes(A_scalar, level=iso, spacing=(dz, dy, dx))
|
|
1094
|
-
except Exception:
|
|
1095
|
-
continue
|
|
1096
|
-
if len(verts) == 0 or len(faces) == 0:
|
|
1097
|
-
continue
|
|
1098
|
-
V = verts[:, [2, 1, 0]].astype(np.float64)
|
|
1099
|
-
V += np.array(origin_xyz, dtype=np.float64)[None, :]
|
|
1100
|
-
m = trimesh.Trimesh(vertices=V, faces=faces.astype(np.int64), process=False)
|
|
1101
|
-
t = 0.0 if vmax <= vmin else (iso - vmin) / (vmax - vmin)
|
|
1102
|
-
r, g, b, _ = cmap(np.clip(t, 0.0, 1.0))
|
|
1103
|
-
rgba = (
|
|
1104
|
-
int(round(255 * r)),
|
|
1105
|
-
int(round(255 * g)),
|
|
1106
|
-
int(round(255 * b)),
|
|
1107
|
-
int(round(255 * alpha)),
|
|
1108
|
-
)
|
|
1109
|
-
m.visual.face_colors = np.tile(np.array(rgba, dtype=np.uint8), (len(m.faces), 1))
|
|
1110
|
-
meshes.append((iso, m, rgba))
|
|
1111
|
-
print(f"Iso {iso:.4f}: faces={len(m.faces):,}, alpha={alpha:.4f}")
|
|
1112
|
-
return meshes
|
|
1113
|
-
|
|
1114
|
-
def save_obj_with_mtl_and_normals(meshes_dict, output_path, base_filename):
|
|
1115
|
-
os.makedirs(output_path, exist_ok=True)
|
|
1116
|
-
obj_path = os.path.join(output_path, f"{base_filename}.obj")
|
|
1117
|
-
mtl_path = os.path.join(output_path, f"{base_filename}.mtl")
|
|
1118
|
-
|
|
1119
|
-
def to_uint8_rgba(arr):
|
|
1120
|
-
arr = np.asarray(arr)
|
|
1121
|
-
if arr.dtype != np.uint8:
|
|
1122
|
-
if arr.dtype.kind == "f":
|
|
1123
|
-
arr = np.clip(arr, 0.0, 1.0)
|
|
1124
|
-
arr = (arr * 255.0 + 0.5).astype(np.uint8)
|
|
1125
|
-
else:
|
|
1126
|
-
arr = arr.astype(np.uint8)
|
|
1127
|
-
if arr.shape[1] == 3:
|
|
1128
|
-
arr = np.concatenate([arr, np.full((arr.shape[0], 1), 255, np.uint8)], axis=1)
|
|
1129
|
-
return arr
|
|
1130
|
-
|
|
1131
|
-
color_to_id, ordered = {}, []
|
|
1132
|
-
|
|
1133
|
-
def mid_of(rgba):
|
|
1134
|
-
if rgba not in color_to_id:
|
|
1135
|
-
color_to_id[rgba] = len(ordered)
|
|
1136
|
-
ordered.append(rgba)
|
|
1137
|
-
return color_to_id[rgba]
|
|
1138
|
-
|
|
1139
|
-
for m in meshes_dict.values():
|
|
1140
|
-
fc = getattr(m.visual, "face_colors", None)
|
|
1141
|
-
if fc is None or len(fc) == 0:
|
|
1142
|
-
mid_of((200, 200, 200, 255))
|
|
1143
|
-
continue
|
|
1144
|
-
for rgba in np.unique(to_uint8_rgba(fc), axis=0):
|
|
1145
|
-
mid_of(tuple(int(x) for x in rgba.tolist()))
|
|
1146
|
-
|
|
1147
|
-
with open(mtl_path, "w") as mtl:
|
|
1148
|
-
for i, (r, g, b, a) in enumerate(ordered):
|
|
1149
|
-
kd = (r / 255.0, g / 255.0, b / 255.0)
|
|
1150
|
-
ka = kd
|
|
1151
|
-
dval = a / 255.0
|
|
1152
|
-
tr = max(0.0, min(1.0, 1.0 - dval))
|
|
1153
|
-
mtl.write(f"newmtl material_{i}\n")
|
|
1154
|
-
mtl.write(f"Kd {kd[0]:.6f} {kd[1]:.6f} {kd[2]:.6f}\n")
|
|
1155
|
-
mtl.write(f"Ka {ka[0]:.6f} {ka[1]:.6f} {ka[2]:.6f}\n")
|
|
1156
|
-
mtl.write("Ks 0.000000 0.000000 0.000000\n")
|
|
1157
|
-
mtl.write("Ns 0.000000\n")
|
|
1158
|
-
mtl.write("illum 1\n")
|
|
1159
|
-
mtl.write(f"d {dval:.6f}\n")
|
|
1160
|
-
mtl.write(f"Tr {tr:.6f}\n\n")
|
|
1161
|
-
|
|
1162
|
-
def face_normals(V, F):
|
|
1163
|
-
v0, v1, v2 = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
|
|
1164
|
-
n = np.cross(v1 - v0, v2 - v0)
|
|
1165
|
-
L = np.linalg.norm(n, axis=1)
|
|
1166
|
-
mask = L > 0
|
|
1167
|
-
n[mask] /= L[mask][:, None]
|
|
1168
|
-
if (~mask).any():
|
|
1169
|
-
n[~mask] = np.array([0.0, 0.0, 1.0])
|
|
1170
|
-
return n
|
|
1171
|
-
|
|
1172
|
-
with open(obj_path, "w") as obj:
|
|
1173
|
-
obj.write(f"mtllib {os.path.basename(mtl_path)}\n")
|
|
1174
|
-
v_offset = 0
|
|
1175
|
-
n_offset = 0
|
|
1176
|
-
for name, m in meshes_dict.items():
|
|
1177
|
-
V = np.asarray(m.vertices, dtype=np.float64)
|
|
1178
|
-
F = np.asarray(m.faces, dtype=np.int64)
|
|
1179
|
-
if len(V) == 0 or len(F) == 0:
|
|
1180
|
-
continue
|
|
1181
|
-
obj.write(f"o {name}\n")
|
|
1182
|
-
obj.write("s off\n")
|
|
1183
|
-
for vx, vy, vz in V:
|
|
1184
|
-
obj.write(f"v {vx:.6f} {vy:.6f} {vz:.6f}\n")
|
|
1185
|
-
|
|
1186
|
-
fc = getattr(m.visual, "face_colors", None)
|
|
1187
|
-
if fc is None or len(fc) != len(F):
|
|
1188
|
-
fc = np.tile(np.array([200, 200, 200, 255], dtype=np.uint8), (len(F), 1))
|
|
1189
|
-
else:
|
|
1190
|
-
fc = to_uint8_rgba(fc)
|
|
1191
|
-
uniq, inv = np.unique(fc, axis=0, return_inverse=True)
|
|
1192
|
-
color2mid = {tuple(int(x) for x in c.tolist()): mid_of(tuple(int(x) for x in c.tolist())) for c in uniq}
|
|
1193
|
-
|
|
1194
|
-
FN = face_normals(V, F)
|
|
1195
|
-
for nx, ny, nz in FN:
|
|
1196
|
-
obj.write(f"vn {float(nx):.6f} {float(ny):.6f} {float(nz):.6f}\n")
|
|
1197
|
-
|
|
1198
|
-
current_mid = None
|
|
1199
|
-
for i_face, face in enumerate(F):
|
|
1200
|
-
key = tuple(int(x) for x in uniq[inv[i_face]].tolist())
|
|
1201
|
-
mid = color2mid[key]
|
|
1202
|
-
if current_mid != mid:
|
|
1203
|
-
obj.write(f"usemtl material_{mid}\n")
|
|
1204
|
-
current_mid = mid
|
|
1205
|
-
a, b, c = face + 1 + v_offset
|
|
1206
|
-
ni = n_offset + i_face + 1
|
|
1207
|
-
obj.write(f"f {a}//{ni} {b}//{ni} {c}//{ni}\n")
|
|
1208
|
-
|
|
1209
|
-
v_offset += len(V)
|
|
1210
|
-
n_offset += len(F)
|
|
1211
|
-
|
|
1212
|
-
return obj_path, mtl_path
|
|
1213
|
-
|
|
1214
|
-
# Load VoxCity
|
|
1215
|
-
dsv = xr.open_dataset(voxcity_nc)
|
|
1216
|
-
if "voxels" not in dsv:
|
|
1217
|
-
raise KeyError("'voxels' not found in VoxCity dataset.")
|
|
1218
|
-
dav = dsv["voxels"]
|
|
1219
|
-
if tuple(dav.dims) != ("y", "x", "z") and all(d in dav.dims for d in ("y", "x", "z")):
|
|
1220
|
-
dav = dav.transpose("y", "x", "z")
|
|
1221
|
-
|
|
1222
|
-
Yv = dsv["y"].values.astype(float)
|
|
1223
|
-
Xv = dsv["x"].values.astype(float)
|
|
1224
|
-
Zv = dsv["z"].values.astype(float)
|
|
1225
|
-
|
|
1226
|
-
Av = dav.values # (y,x,z)
|
|
1227
|
-
Av_kji = np.transpose(Av, (2, 0, 1)) # (K=z, J=y, I=x)
|
|
1228
|
-
svz, svy, svx = stride_vox
|
|
1229
|
-
Av_kji = downsample3(Av_kji, svz, svy, svx)
|
|
1230
|
-
# Y flip (north-up)
|
|
1231
|
-
Av_kji = Av_kji[:, ::-1, :]
|
|
1232
|
-
|
|
1233
|
-
# VoxCity coordinate spacing (optionally override by vox_voxel_size)
|
|
1234
|
-
Ks, Js, Is = Av_kji.shape
|
|
1235
|
-
if vox_voxel_size is None:
|
|
1236
|
-
Zv_s = Zv[:: max(1, svz)].astype(float)
|
|
1237
|
-
Yv_s = (Yv[:: max(1, svy)] - Yv.min()).astype(float)
|
|
1238
|
-
Xv_s = (Xv[:: max(1, svx)] - Xv.min()).astype(float)
|
|
1239
|
-
else:
|
|
1240
|
-
if isinstance(vox_voxel_size, (int, float)):
|
|
1241
|
-
vx = vy = vz = float(vox_voxel_size)
|
|
1242
|
-
else:
|
|
1243
|
-
try:
|
|
1244
|
-
vx, vy, vz = (float(vox_voxel_size[0]), float(vox_voxel_size[1]), float(vox_voxel_size[2]))
|
|
1245
|
-
except Exception as e:
|
|
1246
|
-
raise ValueError("vox_voxel_size must be a float or a length-3 iterable of floats (vx,vy,vz)") from e
|
|
1247
|
-
Xv_s = (np.arange(Is, dtype=float) * vx)
|
|
1248
|
-
Yv_s = (np.arange(Js, dtype=float) * vy)
|
|
1249
|
-
Zv_s = (np.arange(Ks, dtype=float) * vz)
|
|
1250
|
-
|
|
1251
|
-
# Load scalar and georeference using lon/lat table
|
|
1252
|
-
dss = xr.open_dataset(scalar_nc, decode_coords="all", decode_times=True)
|
|
1253
|
-
tname, kname, jname, iname = find_dims(dss)
|
|
1254
|
-
if scalar_var not in dss:
|
|
1255
|
-
raise KeyError(f"{scalar_var} not found in scalar dataset")
|
|
1256
|
-
|
|
1257
|
-
A = squeeze_to_kji(dss[scalar_var], tname, kname, jname, iname).values # (K,J,I)
|
|
1258
|
-
K0, J0, I0 = map(int, A.shape)
|
|
1259
|
-
|
|
1260
|
-
ll = np.loadtxt(lonlat_txt, comments="#")
|
|
1261
|
-
ii = ll[:, 0].astype(int) - 1
|
|
1262
|
-
jj = ll[:, 1].astype(int) - 1
|
|
1263
|
-
lon = ll[:, 2].astype(float)
|
|
1264
|
-
lat = ll[:, 3].astype(float)
|
|
1265
|
-
I_ll = int(ii.max() + 1)
|
|
1266
|
-
J_ll = int(jj.max() + 1)
|
|
1267
|
-
lon_grid = np.full((J_ll, I_ll), np.nan, float)
|
|
1268
|
-
lat_grid = np.full((J_ll, I_ll), np.nan, float)
|
|
1269
|
-
lon_grid[jj, ii] = lon
|
|
1270
|
-
lat_grid[jj, ii] = lat
|
|
1271
|
-
|
|
1272
|
-
Jc = min(J0, J_ll)
|
|
1273
|
-
Ic = min(I0, I_ll)
|
|
1274
|
-
if (Jc != J0) or (Ic != I0):
|
|
1275
|
-
print(
|
|
1276
|
-
f"Warning: scalar (J,I)=({J0},{I0}) vs lonlat ({J_ll},{I_ll}); using common ({Jc},{Ic})."
|
|
1277
|
-
)
|
|
1278
|
-
A = A[:, :Jc, :Ic]
|
|
1279
|
-
lon_grid = lon_grid[:Jc, :Ic]
|
|
1280
|
-
lat_grid = lat_grid[:Jc, :Ic]
|
|
1281
|
-
|
|
1282
|
-
ssk, ssj, ssi = stride_scalar
|
|
1283
|
-
A_s = downsample3(A, ssk, ssj, ssi)
|
|
1284
|
-
lon_s = lon_grid[:: max(1, ssj), :: max(1, ssi)]
|
|
1285
|
-
lat_s = lat_grid[:: max(1, ssj), :: max(1, ssi)]
|
|
1286
|
-
Ks, Js, Is = A_s.shape
|
|
1287
|
-
|
|
1288
|
-
rect = np.array(json.loads(dsv.attrs.get("rectangle_vertices_lonlat_json", "[]")), float)
|
|
1289
|
-
if rect.size == 0:
|
|
1290
|
-
raise RuntimeError("VoxCity attribute 'rectangle_vertices_lonlat_json' missing.")
|
|
1291
|
-
lon0 = float(np.min(rect[:, 0]))
|
|
1292
|
-
lat0 = float(np.min(rect[:, 1]))
|
|
1293
|
-
lat_c = float(np.mean(rect[:, 1]))
|
|
1294
|
-
m_per_deg_lat, m_per_deg_lon = meters_per_degree(np.deg2rad(lat_c))
|
|
1295
|
-
Xs_m = (lon_s - lon0) * m_per_deg_lon
|
|
1296
|
-
Ys_m = (lat_s - lat0) * m_per_deg_lat
|
|
1297
|
-
|
|
1298
|
-
if (kname is not None) and (kname in dss.coords):
|
|
1299
|
-
zc = dss.coords[kname].values
|
|
1300
|
-
if np.issubdtype(zc.dtype, np.number) and zc.ndim == 1 and len(zc) >= Ks:
|
|
1301
|
-
Zk = zc.astype(float)[:: max(1, ssk)][:Ks]
|
|
1302
|
-
else:
|
|
1303
|
-
Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
|
|
1304
|
-
else:
|
|
1305
|
-
Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
|
|
1306
|
-
|
|
1307
|
-
# Mask scalar buildings
|
|
1308
|
-
bmask_scalar = downsample3(
|
|
1309
|
-
np.isclose(A, scalar_building_value, atol=scalar_building_tol), ssk, ssj, ssi
|
|
1310
|
-
)
|
|
1311
|
-
A_s = A_s.astype(float)
|
|
1312
|
-
A_s[bmask_scalar] = np.nan
|
|
1313
|
-
|
|
1314
|
-
finite_vals = A_s[np.isfinite(A_s)]
|
|
1315
|
-
if finite_vals.size == 0:
|
|
1316
|
-
raise RuntimeError("No finite scalar values after masking.")
|
|
1317
|
-
if (vmin is None) or (vmax is None):
|
|
1318
|
-
auto_vmin, auto_vmax = clip_minmax(finite_vals, 0.0)
|
|
1319
|
-
if vmin is None:
|
|
1320
|
-
vmin = auto_vmin
|
|
1321
|
-
if vmax is None:
|
|
1322
|
-
vmax = auto_vmax
|
|
1323
|
-
if not (vmin < vmax):
|
|
1324
|
-
raise ValueError("vmin must be less than vmax.")
|
|
1325
|
-
A_s[np.isnan(A_s)] = vmin - 1e6
|
|
1326
|
-
|
|
1327
|
-
# Determine iso-surface generation range (defaults to color mapping range)
|
|
1328
|
-
iso_vmin_eff = vmin if (iso_vmin is None) else float(iso_vmin)
|
|
1329
|
-
iso_vmax_eff = vmax if (iso_vmax is None) else float(iso_vmax)
|
|
1330
|
-
if not (iso_vmin_eff < iso_vmax_eff):
|
|
1331
|
-
raise ValueError("iso_vmin must be less than iso_vmax.")
|
|
1332
|
-
|
|
1333
|
-
Xmin, Xmax = np.nanmin(Xs_m), np.nanmax(Xs_m)
|
|
1334
|
-
Ymin, Ymax = np.nanmin(Ys_m), np.nanmax(Ys_m)
|
|
1335
|
-
dx_s = (Xmax - Xmin) / max(1, Is - 1)
|
|
1336
|
-
dy_s = (Ymax - Ymin) / max(1, Js - 1)
|
|
1337
|
-
dz_s = (Zk[-1] - Zk[0]) / max(1, Ks - 1) if Ks > 1 else 1.0
|
|
1338
|
-
if scalar_spacing is not None:
|
|
1339
|
-
try:
|
|
1340
|
-
dx_s, dy_s, dz_s = (float(scalar_spacing[0]), float(scalar_spacing[1]), float(scalar_spacing[2]))
|
|
1341
|
-
except Exception as e:
|
|
1342
|
-
raise ValueError("scalar_spacing must be a length-3 iterable of floats (dx,dy,dz)") from e
|
|
1343
|
-
origin_xyz = (float(Xmin), float(Ymin), float(Zk[0]))
|
|
1344
|
-
|
|
1345
|
-
vox_meshes = {}
|
|
1346
|
-
tm_meshes = {}
|
|
1347
|
-
|
|
1348
|
-
present = set(np.unique(Av_kji))
|
|
1349
|
-
present.discard(0)
|
|
1350
|
-
if classes_to_show is not None:
|
|
1351
|
-
present &= set(classes_to_show)
|
|
1352
|
-
present = sorted(present)
|
|
1353
|
-
|
|
1354
|
-
faces_total = 0
|
|
1355
|
-
voxel_color_map = get_voxel_color_map(color_scheme=voxel_color_scheme)
|
|
1356
|
-
for cls in present:
|
|
1357
|
-
mask = Av_kji == cls
|
|
1358
|
-
if not np.any(mask):
|
|
1359
|
-
continue
|
|
1360
|
-
rgb = voxel_color_map.get(int(cls), [200, 200, 200])
|
|
1361
|
-
if greedy_vox:
|
|
1362
|
-
m_cls, faces = make_voxel_mesh_uniform_color_greedy(mask, Xv_s, Yv_s, Zv_s, rgb=rgb, name=f"class_{int(cls)}")
|
|
1363
|
-
else:
|
|
1364
|
-
m_cls, faces = make_voxel_mesh_uniform_color(mask, Xv_s, Yv_s, Zv_s, rgb=rgb, name=f"class_{int(cls)}")
|
|
1365
|
-
if m_cls is not None:
|
|
1366
|
-
vox_meshes[f"voxclass_{int(cls)}"] = m_cls
|
|
1367
|
-
faces_total += faces
|
|
1368
|
-
print(f"[VoxCity] total voxel faces: {faces_total:,}")
|
|
1369
|
-
|
|
1370
|
-
iso_meshes = build_tm_isosurfaces_regular_grid(
|
|
1371
|
-
A_scalar=A_s,
|
|
1372
|
-
vmin=vmin,
|
|
1373
|
-
vmax=vmax,
|
|
1374
|
-
levels=contour_levels,
|
|
1375
|
-
dx=dx_s,
|
|
1376
|
-
dy=dy_s,
|
|
1377
|
-
dz=dz_s,
|
|
1378
|
-
origin_xyz=origin_xyz,
|
|
1379
|
-
cmap_name=cmap_name,
|
|
1380
|
-
opacity_points=opacity_points,
|
|
1381
|
-
max_opacity=max_opacity,
|
|
1382
|
-
iso_vmin=iso_vmin_eff,
|
|
1383
|
-
iso_vmax=iso_vmax_eff,
|
|
1384
|
-
)
|
|
1385
|
-
for iso, m, rgba in iso_meshes:
|
|
1386
|
-
tm_meshes[f"iso_{iso:.6f}"] = m
|
|
1387
|
-
|
|
1388
|
-
if not vox_meshes and not tm_meshes:
|
|
1389
|
-
raise RuntimeError("Nothing to export.")
|
|
1390
|
-
|
|
1391
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
1392
|
-
obj_vox = mtl_vox = obj_tm = mtl_tm = None
|
|
1393
|
-
if vox_meshes:
|
|
1394
|
-
obj_vox, mtl_vox = save_obj_with_mtl_and_normals(vox_meshes, output_dir, vox_base_filename)
|
|
1395
|
-
if tm_meshes:
|
|
1396
|
-
obj_tm, mtl_tm = save_obj_with_mtl_and_normals(tm_meshes, output_dir, tm_base_filename)
|
|
1397
|
-
|
|
1398
|
-
print("Export finished.")
|
|
1399
|
-
if obj_vox:
|
|
1400
|
-
print(f"VoxCity OBJ: {obj_vox}")
|
|
1401
|
-
print(f"VoxCity MTL: {mtl_vox}")
|
|
1402
|
-
if obj_tm:
|
|
1403
|
-
print(f"Scalar Iso OBJ: {obj_tm}")
|
|
1404
|
-
print(f"Scalar Iso MTL: {mtl_tm}")
|
|
1405
|
-
|
|
1
|
+
"""
|
|
2
|
+
Module for exporting voxel data to OBJ format.
|
|
3
|
+
|
|
4
|
+
This module provides functionality for converting voxel arrays and grid data to OBJ files,
|
|
5
|
+
including color mapping, material generation, and mesh optimization.
|
|
6
|
+
|
|
7
|
+
Key Features:
|
|
8
|
+
- Exports voxel data to industry-standard OBJ format with MTL materials
|
|
9
|
+
- Supports color mapping for visualization
|
|
10
|
+
- Performs greedy meshing for optimized face generation
|
|
11
|
+
- Handles proper face orientation and winding order
|
|
12
|
+
- Supports both regular voxel grids and terrain/elevation data
|
|
13
|
+
- Generates complete OBJ files with materials and textures
|
|
14
|
+
|
|
15
|
+
Main Functions:
|
|
16
|
+
- convert_colormap_indices: Converts arbitrary color indices to sequential ones
|
|
17
|
+
- create_face_vertices: Creates properly oriented face vertices
|
|
18
|
+
- mesh_faces: Performs greedy meshing on voxel layers
|
|
19
|
+
- export_obj: Main function to export voxel data to OBJ
|
|
20
|
+
- grid_to_obj: Converts 2D grid data to OBJ with elevation
|
|
21
|
+
|
|
22
|
+
Dependencies:
|
|
23
|
+
- numpy: For array operations
|
|
24
|
+
- matplotlib: For colormap handling
|
|
25
|
+
- trimesh: For mesh operations
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import os
|
|
30
|
+
from numba import njit, prange
|
|
31
|
+
import matplotlib.pyplot as plt
|
|
32
|
+
import trimesh
|
|
33
|
+
import numpy as np
|
|
34
|
+
from ..utils.visualization import get_voxel_color_map
|
|
35
|
+
|
|
36
|
+
def convert_colormap_indices(original_map):
|
|
37
|
+
"""
|
|
38
|
+
Convert a color map with arbitrary indices to sequential indices starting from 0.
|
|
39
|
+
|
|
40
|
+
This function takes a color map with arbitrary integer keys and creates a new map
|
|
41
|
+
with sequential indices starting from 0, maintaining the original color values.
|
|
42
|
+
This is useful for ensuring consistent material indexing in OBJ files.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
original_map (dict): Dictionary with integer keys and RGB color value lists.
|
|
46
|
+
Each value should be a list of 3 integers (0-255) representing RGB colors.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
dict: New color map with sequential indices starting from 0.
|
|
50
|
+
The values maintain their original RGB color assignments.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> original = {5: [255, 0, 0], 10: [0, 255, 0], 15: [0, 0, 255]}
|
|
54
|
+
>>> new_map = convert_colormap_indices(original)
|
|
55
|
+
>>> print(new_map)
|
|
56
|
+
{0: [255, 0, 0], 1: [0, 255, 0], 2: [0, 0, 255]}
|
|
57
|
+
"""
|
|
58
|
+
# Sort the original keys to maintain consistent ordering
|
|
59
|
+
keys = sorted(original_map.keys())
|
|
60
|
+
new_map = {}
|
|
61
|
+
|
|
62
|
+
# Create new map with sequential indices
|
|
63
|
+
for new_idx, old_idx in enumerate(keys):
|
|
64
|
+
new_map[new_idx] = original_map[old_idx]
|
|
65
|
+
|
|
66
|
+
# Print the new colormap for debugging/reference
|
|
67
|
+
print("new_colormap = {")
|
|
68
|
+
for key, value in new_map.items():
|
|
69
|
+
original_key = keys[key]
|
|
70
|
+
original_line = str(original_map[original_key])
|
|
71
|
+
comment = ""
|
|
72
|
+
if "#" in original_line:
|
|
73
|
+
comment = "#" + original_line.split("#")[1].strip()
|
|
74
|
+
print(f" {key}: {value}, {comment}")
|
|
75
|
+
print("}")
|
|
76
|
+
|
|
77
|
+
return new_map
|
|
78
|
+
|
|
79
|
+
def create_face_vertices(coords, positive_direction, axis):
|
|
80
|
+
"""
|
|
81
|
+
Helper function to create properly oriented face vertices for OBJ export.
|
|
82
|
+
|
|
83
|
+
This function handles the creation of face vertices with correct winding order
|
|
84
|
+
based on the face direction and axis. It accounts for OpenGL coordinate system
|
|
85
|
+
conventions and ensures proper face orientation for rendering.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
coords (list): List of 4 vertex coordinates defining the face corners.
|
|
89
|
+
Each coordinate should be a tuple of (x, y, z) values.
|
|
90
|
+
positive_direction (bool): Whether face points in positive axis direction.
|
|
91
|
+
True = face normal points in positive direction along the axis
|
|
92
|
+
False = face normal points in negative direction along the axis
|
|
93
|
+
axis (str): Axis the face is perpendicular to ('x', 'y', or 'z').
|
|
94
|
+
This determines how vertices are ordered for proper face orientation.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
list: Ordered vertex coordinates for the face, arranged to create proper
|
|
98
|
+
face orientation and winding order for rendering.
|
|
99
|
+
|
|
100
|
+
Notes:
|
|
101
|
+
- Y-axis faces need special handling due to OpenGL coordinate system
|
|
102
|
+
- Winding order determines which side of the face is visible
|
|
103
|
+
- Consistent winding order is maintained for X and Z faces
|
|
104
|
+
"""
|
|
105
|
+
# Y-axis faces need special handling due to OpenGL coordinate system
|
|
106
|
+
if axis == 'y':
|
|
107
|
+
if positive_direction: # +Y face
|
|
108
|
+
return [coords[3], coords[2], coords[1], coords[0]] # Reverse order for +Y
|
|
109
|
+
else: # -Y face
|
|
110
|
+
return [coords[0], coords[1], coords[2], coords[3]] # Standard order for -Y
|
|
111
|
+
else:
|
|
112
|
+
# For X and Z faces, use consistent winding order
|
|
113
|
+
if positive_direction:
|
|
114
|
+
return [coords[0], coords[3], coords[2], coords[1]]
|
|
115
|
+
else:
|
|
116
|
+
return [coords[0], coords[1], coords[2], coords[3]]
|
|
117
|
+
|
|
118
|
+
def mesh_faces(mask, layer_index, axis, positive_direction, normal_idx, voxel_size_m,
|
|
119
|
+
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material):
|
|
120
|
+
"""
|
|
121
|
+
Performs greedy meshing on a 2D mask layer and adds optimized faces to the mesh.
|
|
122
|
+
|
|
123
|
+
This function implements a greedy meshing algorithm to combine adjacent voxels
|
|
124
|
+
into larger faces, reducing the total number of faces in the final mesh while
|
|
125
|
+
maintaining visual accuracy. It processes each layer of voxels and generates
|
|
126
|
+
optimized faces with proper materials and orientations.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
mask (ndarray): 2D boolean array indicating voxel presence.
|
|
130
|
+
Non-zero values indicate voxel presence, zero indicates empty space.
|
|
131
|
+
layer_index (int): Index of current layer being processed.
|
|
132
|
+
Used to position faces in 3D space.
|
|
133
|
+
axis (str): Axis perpendicular to faces being generated ('x', 'y', or 'z').
|
|
134
|
+
Determines how coordinates are generated for the faces.
|
|
135
|
+
positive_direction (bool): Whether faces point in positive axis direction.
|
|
136
|
+
Affects face normal orientation.
|
|
137
|
+
normal_idx (int): Index of normal vector to use for faces.
|
|
138
|
+
References pre-defined normal vectors in the OBJ file.
|
|
139
|
+
voxel_size_m (float): Size of each voxel in meters.
|
|
140
|
+
Used to scale coordinates to real-world units.
|
|
141
|
+
vertex_dict (dict): Dictionary mapping vertex coordinates to indices.
|
|
142
|
+
Used to avoid duplicate vertices in the mesh.
|
|
143
|
+
vertex_list (list): List of unique vertex coordinates.
|
|
144
|
+
Stores all vertices used in the mesh.
|
|
145
|
+
faces_per_material (dict): Dictionary collecting faces by material.
|
|
146
|
+
Keys are material names, values are lists of face definitions.
|
|
147
|
+
voxel_value_to_material (dict): Mapping from voxel values to material names.
|
|
148
|
+
Used to assign materials to faces based on voxel values.
|
|
149
|
+
|
|
150
|
+
Notes:
|
|
151
|
+
- Uses greedy meshing to combine adjacent same-value voxels
|
|
152
|
+
- Handles coordinate system conversion for proper orientation
|
|
153
|
+
- Maintains consistent face winding order for rendering
|
|
154
|
+
- Optimizes mesh by reusing vertices and combining faces
|
|
155
|
+
- Supports different coordinate systems for each axis
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
voxel_size = voxel_size_m
|
|
159
|
+
|
|
160
|
+
# Create copy to avoid modifying original mask
|
|
161
|
+
mask = mask.copy()
|
|
162
|
+
h, w = mask.shape
|
|
163
|
+
|
|
164
|
+
# Track which voxels have been processed
|
|
165
|
+
visited = np.zeros_like(mask, dtype=bool)
|
|
166
|
+
|
|
167
|
+
# Iterate through each position in the mask
|
|
168
|
+
for u in range(h):
|
|
169
|
+
v = 0
|
|
170
|
+
while v < w:
|
|
171
|
+
# Skip if already visited or empty voxel
|
|
172
|
+
if visited[u, v] or mask[u, v] == 0:
|
|
173
|
+
v += 1
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
voxel_value = mask[u, v]
|
|
177
|
+
material_name = voxel_value_to_material[voxel_value]
|
|
178
|
+
|
|
179
|
+
# Greedy meshing: Find maximum width of consecutive same-value voxels
|
|
180
|
+
width = 1
|
|
181
|
+
while v + width < w and mask[u, v + width] == voxel_value and not visited[u, v + width]:
|
|
182
|
+
width += 1
|
|
183
|
+
|
|
184
|
+
# Find maximum height of same-value voxels
|
|
185
|
+
height = 1
|
|
186
|
+
done = False
|
|
187
|
+
while u + height < h and not done:
|
|
188
|
+
for k in range(width):
|
|
189
|
+
if mask[u + height, v + k] != voxel_value or visited[u + height, v + k]:
|
|
190
|
+
done = True
|
|
191
|
+
break
|
|
192
|
+
if not done:
|
|
193
|
+
height += 1
|
|
194
|
+
|
|
195
|
+
# Mark processed voxels as visited
|
|
196
|
+
visited[u:u + height, v:v + width] = True
|
|
197
|
+
|
|
198
|
+
# Generate vertex coordinates based on axis orientation
|
|
199
|
+
if axis == 'x':
|
|
200
|
+
i = float(layer_index) * voxel_size
|
|
201
|
+
y0 = float(u) * voxel_size
|
|
202
|
+
y1 = float(u + height) * voxel_size
|
|
203
|
+
z0 = float(v) * voxel_size
|
|
204
|
+
z1 = float(v + width) * voxel_size
|
|
205
|
+
coords = [
|
|
206
|
+
(i, y0, z0),
|
|
207
|
+
(i, y1, z0),
|
|
208
|
+
(i, y1, z1),
|
|
209
|
+
(i, y0, z1),
|
|
210
|
+
]
|
|
211
|
+
elif axis == 'y':
|
|
212
|
+
i = float(layer_index) * voxel_size
|
|
213
|
+
x0 = float(u) * voxel_size
|
|
214
|
+
x1 = float(u + height) * voxel_size
|
|
215
|
+
z0 = float(v) * voxel_size
|
|
216
|
+
z1 = float(v + width) * voxel_size
|
|
217
|
+
coords = [
|
|
218
|
+
(x0, i, z0),
|
|
219
|
+
(x1, i, z0),
|
|
220
|
+
(x1, i, z1),
|
|
221
|
+
(x0, i, z1),
|
|
222
|
+
]
|
|
223
|
+
elif axis == 'z':
|
|
224
|
+
i = float(layer_index) * voxel_size
|
|
225
|
+
x0 = float(u) * voxel_size
|
|
226
|
+
x1 = float(u + height) * voxel_size
|
|
227
|
+
y0 = float(v) * voxel_size
|
|
228
|
+
y1 = float(v + width) * voxel_size
|
|
229
|
+
coords = [
|
|
230
|
+
(x0, y0, i),
|
|
231
|
+
(x1, y0, i),
|
|
232
|
+
(x1, y1, i),
|
|
233
|
+
(x0, y1, i),
|
|
234
|
+
]
|
|
235
|
+
else:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Convert to right-handed coordinate system
|
|
239
|
+
coords = [(c[2], c[1], c[0]) for c in coords]
|
|
240
|
+
face_vertices = create_face_vertices(coords, positive_direction, axis)
|
|
241
|
+
|
|
242
|
+
# Convert vertices to indices, adding new vertices as needed
|
|
243
|
+
indices = []
|
|
244
|
+
for coord in face_vertices:
|
|
245
|
+
if coord not in vertex_dict:
|
|
246
|
+
vertex_list.append(coord)
|
|
247
|
+
vertex_dict[coord] = len(vertex_list)
|
|
248
|
+
indices.append(vertex_dict[coord])
|
|
249
|
+
|
|
250
|
+
# Create triangulated faces with proper winding order
|
|
251
|
+
if axis == 'y':
|
|
252
|
+
faces = [
|
|
253
|
+
{'vertices': [indices[2], indices[1], indices[0]], 'normal_idx': normal_idx},
|
|
254
|
+
{'vertices': [indices[3], indices[2], indices[0]], 'normal_idx': normal_idx}
|
|
255
|
+
]
|
|
256
|
+
else:
|
|
257
|
+
faces = [
|
|
258
|
+
{'vertices': [indices[0], indices[1], indices[2]], 'normal_idx': normal_idx},
|
|
259
|
+
{'vertices': [indices[0], indices[2], indices[3]], 'normal_idx': normal_idx}
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
# Store faces by material
|
|
263
|
+
if material_name not in faces_per_material:
|
|
264
|
+
faces_per_material[material_name] = []
|
|
265
|
+
faces_per_material[material_name].extend(faces)
|
|
266
|
+
|
|
267
|
+
v += width
|
|
268
|
+
|
|
269
|
+
def export_obj(array, output_dir, file_name, voxel_size, voxel_color_map=None):
|
|
270
|
+
"""
|
|
271
|
+
Export a voxel array to OBJ format with materials and proper face orientations.
|
|
272
|
+
|
|
273
|
+
This function converts a 3D voxel array into a complete OBJ file with materials,
|
|
274
|
+
performing mesh optimization and ensuring proper face orientations. It generates
|
|
275
|
+
both OBJ and MTL files with all necessary components for rendering.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
array (ndarray): 3D numpy array containing voxel values.
|
|
279
|
+
Non-zero values indicate voxel presence and material type.
|
|
280
|
+
output_dir (str): Directory to save the OBJ and MTL files.
|
|
281
|
+
Will be created if it doesn't exist.
|
|
282
|
+
file_name (str): Base name for the output files.
|
|
283
|
+
Will be used for both .obj and .mtl files.
|
|
284
|
+
voxel_size (float): Size of each voxel in meters.
|
|
285
|
+
Used to scale the model to real-world units.
|
|
286
|
+
voxel_color_map (dict, optional): Dictionary mapping voxel values to RGB colors.
|
|
287
|
+
If None, uses default color map. Colors should be RGB lists (0-255).
|
|
288
|
+
|
|
289
|
+
Notes:
|
|
290
|
+
- Generates optimized mesh using greedy meshing
|
|
291
|
+
- Creates complete OBJ file with vertices, normals, and faces
|
|
292
|
+
- Generates MTL file with material definitions
|
|
293
|
+
- Handles proper face orientation and winding order
|
|
294
|
+
- Supports color mapping for visualization
|
|
295
|
+
- Uses consistent coordinate system throughout
|
|
296
|
+
|
|
297
|
+
File Format Details:
|
|
298
|
+
OBJ file contains:
|
|
299
|
+
- Vertex coordinates (v)
|
|
300
|
+
- Normal vectors (vn)
|
|
301
|
+
- Material references (usemtl)
|
|
302
|
+
- Face definitions (f)
|
|
303
|
+
|
|
304
|
+
MTL file contains:
|
|
305
|
+
- Material names and colors
|
|
306
|
+
- Ambient, diffuse, and specular properties
|
|
307
|
+
- Transparency settings
|
|
308
|
+
- Illumination model definitions
|
|
309
|
+
"""
|
|
310
|
+
if voxel_color_map is None:
|
|
311
|
+
voxel_color_map = get_voxel_color_map()
|
|
312
|
+
|
|
313
|
+
# Extract unique voxel values (excluding zero)
|
|
314
|
+
unique_voxel_values = np.unique(array)
|
|
315
|
+
unique_voxel_values = unique_voxel_values[unique_voxel_values != 0]
|
|
316
|
+
|
|
317
|
+
# Map voxel values to material names
|
|
318
|
+
voxel_value_to_material = {val: f'material_{val}' for val in unique_voxel_values}
|
|
319
|
+
|
|
320
|
+
# Define normal vectors for each face direction
|
|
321
|
+
normals = [
|
|
322
|
+
(1.0, 0.0, 0.0), # 1: +X Right face
|
|
323
|
+
(-1.0, 0.0, 0.0), # 2: -X Left face
|
|
324
|
+
(0.0, 1.0, 0.0), # 3: +Y Top face
|
|
325
|
+
(0.0, -1.0, 0.0), # 4: -Y Bottom face
|
|
326
|
+
(0.0, 0.0, 1.0), # 5: +Z Front face
|
|
327
|
+
(0.0, 0.0, -1.0), # 6: -Z Back face
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
# Map direction names to normal indices
|
|
331
|
+
normal_indices = {
|
|
332
|
+
'nx': 2,
|
|
333
|
+
'px': 1,
|
|
334
|
+
'ny': 4,
|
|
335
|
+
'py': 3,
|
|
336
|
+
'nz': 6,
|
|
337
|
+
'pz': 5,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# Initialize data structures
|
|
341
|
+
vertex_list = []
|
|
342
|
+
vertex_dict = {}
|
|
343
|
+
faces_per_material = {}
|
|
344
|
+
|
|
345
|
+
# Transpose array for correct orientation in output
|
|
346
|
+
array = array.transpose(2, 1, 0) # Now array[x, y, z]
|
|
347
|
+
size_x, size_y, size_z = array.shape
|
|
348
|
+
|
|
349
|
+
# Define processing directions and their normals
|
|
350
|
+
directions = [
|
|
351
|
+
('nx', (-1, 0, 0)),
|
|
352
|
+
('px', (1, 0, 0)),
|
|
353
|
+
('ny', (0, -1, 0)),
|
|
354
|
+
('py', (0, 1, 0)),
|
|
355
|
+
('nz', (0, 0, -1)),
|
|
356
|
+
('pz', (0, 0, 1)),
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
# Process each face direction
|
|
360
|
+
for direction, normal in directions:
|
|
361
|
+
normal_idx = normal_indices[direction]
|
|
362
|
+
|
|
363
|
+
# Process X-axis aligned faces
|
|
364
|
+
if direction in ('nx', 'px'):
|
|
365
|
+
for x in range(size_x):
|
|
366
|
+
voxel_slice = array[x, :, :]
|
|
367
|
+
if direction == 'nx':
|
|
368
|
+
neighbor_slice = array[x - 1, :, :] if x > 0 else np.zeros_like(voxel_slice)
|
|
369
|
+
layer = x
|
|
370
|
+
else:
|
|
371
|
+
neighbor_slice = array[x + 1, :, :] if x + 1 < size_x else np.zeros_like(voxel_slice)
|
|
372
|
+
layer = x + 1
|
|
373
|
+
|
|
374
|
+
# Create mask for faces that need to be generated
|
|
375
|
+
mask = np.where((voxel_slice != neighbor_slice) & (voxel_slice != 0), voxel_slice, 0)
|
|
376
|
+
mesh_faces(mask, layer, 'x', direction == 'px', normal_idx, voxel_size,
|
|
377
|
+
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material)
|
|
378
|
+
|
|
379
|
+
# Process Y-axis aligned faces
|
|
380
|
+
elif direction in ('ny', 'py'):
|
|
381
|
+
for y in range(size_y):
|
|
382
|
+
voxel_slice = array[:, y, :]
|
|
383
|
+
if direction == 'ny':
|
|
384
|
+
neighbor_slice = array[:, y - 1, :] if y > 0 else np.zeros_like(voxel_slice)
|
|
385
|
+
layer = y
|
|
386
|
+
else:
|
|
387
|
+
neighbor_slice = array[:, y + 1, :] if y + 1 < size_y else np.zeros_like(voxel_slice)
|
|
388
|
+
layer = y + 1
|
|
389
|
+
|
|
390
|
+
mask = np.where((voxel_slice != neighbor_slice) & (voxel_slice != 0), voxel_slice, 0)
|
|
391
|
+
mesh_faces(mask, layer, 'y', direction == 'py', normal_idx, voxel_size,
|
|
392
|
+
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material)
|
|
393
|
+
|
|
394
|
+
# Process Z-axis aligned faces
|
|
395
|
+
elif direction in ('nz', 'pz'):
|
|
396
|
+
for z in range(size_z):
|
|
397
|
+
voxel_slice = array[:, :, z]
|
|
398
|
+
if direction == 'nz':
|
|
399
|
+
neighbor_slice = array[:, :, z - 1] if z > 0 else np.zeros_like(voxel_slice)
|
|
400
|
+
layer = z
|
|
401
|
+
else:
|
|
402
|
+
neighbor_slice = array[:, :, z + 1] if z + 1 < size_z else np.zeros_like(voxel_slice)
|
|
403
|
+
layer = z + 1
|
|
404
|
+
|
|
405
|
+
mask = np.where((voxel_slice != neighbor_slice) & (voxel_slice != 0), voxel_slice, 0)
|
|
406
|
+
mesh_faces(mask, layer, 'z', direction == 'pz', normal_idx, voxel_size,
|
|
407
|
+
vertex_dict, vertex_list, faces_per_material, voxel_value_to_material)
|
|
408
|
+
|
|
409
|
+
# Create output directory if it doesn't exist
|
|
410
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
411
|
+
|
|
412
|
+
# Define output file paths
|
|
413
|
+
obj_file_path = os.path.join(output_dir, f'{file_name}.obj')
|
|
414
|
+
mtl_file_path = os.path.join(output_dir, f'{file_name}.mtl')
|
|
415
|
+
|
|
416
|
+
# Write OBJ file
|
|
417
|
+
with open(obj_file_path, 'w') as f:
|
|
418
|
+
f.write('# Generated OBJ file\n\n')
|
|
419
|
+
f.write('# group\no \n\n')
|
|
420
|
+
f.write(f'# material\nmtllib {file_name}.mtl\n\n')
|
|
421
|
+
|
|
422
|
+
# Write normal vectors
|
|
423
|
+
f.write('# normals\n')
|
|
424
|
+
for nx, ny, nz in normals:
|
|
425
|
+
f.write(f'vn {nx:.6f} {ny:.6f} {nz:.6f}\n')
|
|
426
|
+
f.write('\n')
|
|
427
|
+
|
|
428
|
+
# Write vertex coordinates
|
|
429
|
+
f.write('# verts\n')
|
|
430
|
+
for vx, vy, vz in vertex_list:
|
|
431
|
+
f.write(f'v {vx:.6f} {vy:.6f} {vz:.6f}\n')
|
|
432
|
+
f.write('\n')
|
|
433
|
+
|
|
434
|
+
# Write faces grouped by material
|
|
435
|
+
f.write('# faces\n')
|
|
436
|
+
for material_name, faces in faces_per_material.items():
|
|
437
|
+
f.write(f'usemtl {material_name}\n')
|
|
438
|
+
for face in faces:
|
|
439
|
+
v_indices = [str(vi) for vi in face['vertices']]
|
|
440
|
+
normal_idx = face['normal_idx']
|
|
441
|
+
face_str = ' '.join([f'{vi}//{normal_idx}' for vi in face['vertices']])
|
|
442
|
+
f.write(f'f {face_str}\n')
|
|
443
|
+
f.write('\n')
|
|
444
|
+
|
|
445
|
+
# Write MTL file with material definitions
|
|
446
|
+
with open(mtl_file_path, 'w') as f:
|
|
447
|
+
f.write('# Material file\n\n')
|
|
448
|
+
for voxel_value in unique_voxel_values:
|
|
449
|
+
material_name = voxel_value_to_material[voxel_value]
|
|
450
|
+
color = voxel_color_map.get(voxel_value, [0, 0, 0])
|
|
451
|
+
r, g, b = [c / 255.0 for c in color]
|
|
452
|
+
f.write(f'newmtl {material_name}\n')
|
|
453
|
+
f.write(f'Ka {r:.6f} {g:.6f} {b:.6f}\n') # Ambient color
|
|
454
|
+
f.write(f'Kd {r:.6f} {g:.6f} {b:.6f}\n') # Diffuse color
|
|
455
|
+
f.write(f'Ke {r:.6f} {g:.6f} {b:.6f}\n') # Emissive color
|
|
456
|
+
f.write('Ks 0.500000 0.500000 0.500000\n') # Specular reflection
|
|
457
|
+
f.write('Ns 50.000000\n') # Specular exponent
|
|
458
|
+
f.write('illum 2\n\n') # Illumination model
|
|
459
|
+
|
|
460
|
+
print(f'OBJ and MTL files have been generated in {output_dir} with the base name "{file_name}".')
|
|
461
|
+
|
|
462
|
+
def grid_to_obj(value_array_ori, dem_array_ori, output_dir, file_name, cell_size, offset,
|
|
463
|
+
colormap_name='viridis', num_colors=256, alpha=1.0, vmin=None, vmax=None):
|
|
464
|
+
"""
|
|
465
|
+
Converts a 2D array of values and a corresponding DEM array to an OBJ file
|
|
466
|
+
with specified colormap, transparency, and value range.
|
|
467
|
+
|
|
468
|
+
This function creates a 3D visualization of 2D grid data by using elevation
|
|
469
|
+
data and color mapping. It's particularly useful for visualizing terrain data,
|
|
470
|
+
analysis results, or any 2D data that should be displayed with elevation.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
value_array_ori (ndarray): 2D array of values to visualize.
|
|
474
|
+
These values will be mapped to colors using the specified colormap.
|
|
475
|
+
dem_array_ori (ndarray): 2D array of DEM values corresponding to value_array.
|
|
476
|
+
Provides elevation data for the 3D visualization.
|
|
477
|
+
output_dir (str): Directory to save the OBJ and MTL files.
|
|
478
|
+
Will be created if it doesn't exist.
|
|
479
|
+
file_name (str): Base name for the output files.
|
|
480
|
+
Used for both .obj and .mtl files.
|
|
481
|
+
cell_size (float): Size of each cell in the grid (e.g., in meters).
|
|
482
|
+
Used to scale the model to real-world units.
|
|
483
|
+
offset (float): Elevation offset added after quantization.
|
|
484
|
+
Useful for adjusting the base height of the model.
|
|
485
|
+
colormap_name (str, optional): Name of the Matplotlib colormap to use.
|
|
486
|
+
Defaults to 'viridis'. Must be a valid Matplotlib colormap name.
|
|
487
|
+
num_colors (int, optional): Number of discrete colors to use from the colormap.
|
|
488
|
+
Defaults to 256. Higher values give smoother color transitions.
|
|
489
|
+
alpha (float, optional): Transparency value between 0.0 (transparent) and 1.0 (opaque).
|
|
490
|
+
Defaults to 1.0 (fully opaque).
|
|
491
|
+
vmin (float, optional): Minimum value for colormap normalization.
|
|
492
|
+
If None, uses data minimum. Used to control color mapping range.
|
|
493
|
+
vmax (float, optional): Maximum value for colormap normalization.
|
|
494
|
+
If None, uses data maximum. Used to control color mapping range.
|
|
495
|
+
|
|
496
|
+
Notes:
|
|
497
|
+
- Automatically handles NaN values in input arrays
|
|
498
|
+
- Creates triangulated mesh for proper rendering
|
|
499
|
+
- Supports transparency and color mapping
|
|
500
|
+
- Generates complete OBJ and MTL files
|
|
501
|
+
- Maintains consistent coordinate system
|
|
502
|
+
- Optimizes mesh generation for large grids
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
ValueError: If vmin equals vmax or if colormap_name is invalid
|
|
506
|
+
"""
|
|
507
|
+
# Validate input arrays
|
|
508
|
+
if value_array_ori.shape != dem_array_ori.shape:
|
|
509
|
+
raise ValueError("The value array and DEM array must have the same shape.")
|
|
510
|
+
|
|
511
|
+
# Get the dimensions
|
|
512
|
+
rows, cols = value_array_ori.shape
|
|
513
|
+
|
|
514
|
+
# Flip arrays vertically and normalize DEM values
|
|
515
|
+
value_array = np.flipud(value_array_ori.copy())
|
|
516
|
+
dem_array = np.flipud(dem_array_ori.copy()) - np.min(dem_array_ori)
|
|
517
|
+
|
|
518
|
+
# Get valid indices (non-NaN)
|
|
519
|
+
valid_indices = np.argwhere(~np.isnan(value_array))
|
|
520
|
+
|
|
521
|
+
# Set vmin and vmax if not provided
|
|
522
|
+
if vmin is None:
|
|
523
|
+
vmin = np.nanmin(value_array)
|
|
524
|
+
if vmax is None:
|
|
525
|
+
vmax = np.nanmax(value_array)
|
|
526
|
+
|
|
527
|
+
# Handle case where vmin equals vmax
|
|
528
|
+
if vmin == vmax:
|
|
529
|
+
raise ValueError("vmin and vmax cannot be the same value.")
|
|
530
|
+
|
|
531
|
+
# Normalize values to [0, 1] based on vmin and vmax
|
|
532
|
+
normalized_values = (value_array - vmin) / (vmax - vmin)
|
|
533
|
+
# Clip normalized values to [0, 1]
|
|
534
|
+
normalized_values = np.clip(normalized_values, 0.0, 1.0)
|
|
535
|
+
|
|
536
|
+
# Prepare the colormap
|
|
537
|
+
if colormap_name not in plt.colormaps():
|
|
538
|
+
raise ValueError(f"Colormap '{colormap_name}' is not recognized. Please choose a valid Matplotlib colormap.")
|
|
539
|
+
colormap = plt.get_cmap(colormap_name, num_colors) # Discrete colors
|
|
540
|
+
|
|
541
|
+
# Create a mapping from quantized colors to material names
|
|
542
|
+
color_to_material = {}
|
|
543
|
+
materials = []
|
|
544
|
+
material_index = 1 # Start indexing materials from 1
|
|
545
|
+
|
|
546
|
+
# Initialize vertex tracking
|
|
547
|
+
vertex_list = []
|
|
548
|
+
vertex_dict = {} # To avoid duplicate vertices
|
|
549
|
+
vertex_index = 1 # OBJ indices start at 1
|
|
550
|
+
|
|
551
|
+
faces_per_material = {}
|
|
552
|
+
|
|
553
|
+
# Process each valid cell in the grid
|
|
554
|
+
for idx in valid_indices:
|
|
555
|
+
i, j = idx # i is the row index, j is the column index
|
|
556
|
+
value = value_array[i, j]
|
|
557
|
+
normalized_value = normalized_values[i, j]
|
|
558
|
+
|
|
559
|
+
# Get the color from the colormap
|
|
560
|
+
rgba = colormap(normalized_value)
|
|
561
|
+
rgb = rgba[:3] # Ignore alpha channel
|
|
562
|
+
r, g, b = [int(c * 255) for c in rgb]
|
|
563
|
+
|
|
564
|
+
# Create unique material name for this color
|
|
565
|
+
color_key = (r, g, b)
|
|
566
|
+
material_name = f'material_{r}_{g}_{b}'
|
|
567
|
+
|
|
568
|
+
# Add new material if not seen before
|
|
569
|
+
if material_name not in color_to_material:
|
|
570
|
+
color_to_material[material_name] = {
|
|
571
|
+
'r': r / 255.0,
|
|
572
|
+
'g': g / 255.0,
|
|
573
|
+
'b': b / 255.0,
|
|
574
|
+
'alpha': alpha
|
|
575
|
+
}
|
|
576
|
+
materials.append(material_name)
|
|
577
|
+
|
|
578
|
+
# Calculate cell vertices
|
|
579
|
+
x0 = i * cell_size
|
|
580
|
+
x1 = (i + 1) * cell_size
|
|
581
|
+
y0 = j * cell_size
|
|
582
|
+
y1 = (j + 1) * cell_size
|
|
583
|
+
|
|
584
|
+
# Calculate elevation with quantization and offset
|
|
585
|
+
z = cell_size * int(dem_array[i, j] / cell_size + 1.5) + offset
|
|
586
|
+
|
|
587
|
+
# Define quad vertices
|
|
588
|
+
vertices = [
|
|
589
|
+
(x0, y0, z),
|
|
590
|
+
(x1, y0, z),
|
|
591
|
+
(x1, y1, z),
|
|
592
|
+
(x0, y1, z),
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
# Convert vertices to indices
|
|
596
|
+
indices = []
|
|
597
|
+
for v in vertices:
|
|
598
|
+
if v not in vertex_dict:
|
|
599
|
+
vertex_list.append(v)
|
|
600
|
+
vertex_dict[v] = vertex_index
|
|
601
|
+
vertex_index += 1
|
|
602
|
+
indices.append(vertex_dict[v])
|
|
603
|
+
|
|
604
|
+
# Create triangulated faces
|
|
605
|
+
faces = [
|
|
606
|
+
{'vertices': [indices[0], indices[1], indices[2]]},
|
|
607
|
+
{'vertices': [indices[0], indices[2], indices[3]]},
|
|
608
|
+
]
|
|
609
|
+
|
|
610
|
+
# Store faces by material
|
|
611
|
+
if material_name not in faces_per_material:
|
|
612
|
+
faces_per_material[material_name] = []
|
|
613
|
+
faces_per_material[material_name].extend(faces)
|
|
614
|
+
|
|
615
|
+
# Create output directory if needed
|
|
616
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
617
|
+
|
|
618
|
+
# Define output file paths
|
|
619
|
+
obj_file_path = os.path.join(output_dir, f'{file_name}.obj')
|
|
620
|
+
mtl_file_path = os.path.join(output_dir, f'{file_name}.mtl')
|
|
621
|
+
|
|
622
|
+
# Write OBJ file
|
|
623
|
+
with open(obj_file_path, 'w') as f:
|
|
624
|
+
f.write('# Generated OBJ file\n\n')
|
|
625
|
+
f.write(f'mtllib {file_name}.mtl\n\n')
|
|
626
|
+
# Write vertices
|
|
627
|
+
for vx, vy, vz in vertex_list:
|
|
628
|
+
f.write(f'v {vx:.6f} {vy:.6f} {vz:.6f}\n')
|
|
629
|
+
f.write('\n')
|
|
630
|
+
# Write faces grouped by material
|
|
631
|
+
for material_name in materials:
|
|
632
|
+
f.write(f'usemtl {material_name}\n')
|
|
633
|
+
faces = faces_per_material[material_name]
|
|
634
|
+
for face in faces:
|
|
635
|
+
v_indices = face['vertices']
|
|
636
|
+
face_str = ' '.join([f'{vi}' for vi in v_indices])
|
|
637
|
+
f.write(f'f {face_str}\n')
|
|
638
|
+
f.write('\n')
|
|
639
|
+
|
|
640
|
+
# Write MTL file with material properties
|
|
641
|
+
with open(mtl_file_path, 'w') as f:
|
|
642
|
+
for material_name in materials:
|
|
643
|
+
color = color_to_material[material_name]
|
|
644
|
+
r, g, b = color['r'], color['g'], color['b']
|
|
645
|
+
a = color['alpha']
|
|
646
|
+
f.write(f'newmtl {material_name}\n')
|
|
647
|
+
f.write(f'Ka {r:.6f} {g:.6f} {b:.6f}\n') # Ambient color
|
|
648
|
+
f.write(f'Kd {r:.6f} {g:.6f} {b:.6f}\n') # Diffuse color
|
|
649
|
+
f.write(f'Ks 0.000000 0.000000 0.000000\n') # Specular reflection
|
|
650
|
+
f.write('Ns 10.000000\n') # Specular exponent
|
|
651
|
+
f.write('illum 1\n') # Illumination model
|
|
652
|
+
f.write(f'd {a:.6f}\n') # Transparency (alpha)
|
|
653
|
+
f.write('\n')
|
|
654
|
+
|
|
655
|
+
print(f'OBJ and MTL files have been generated in {output_dir} with the base name "{file_name}".')
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def export_netcdf_to_obj(
|
|
659
|
+
voxcity_nc,
|
|
660
|
+
scalar_nc,
|
|
661
|
+
lonlat_txt,
|
|
662
|
+
output_dir,
|
|
663
|
+
vox_base_filename="voxcity_objects",
|
|
664
|
+
tm_base_filename="tm_isosurfaces",
|
|
665
|
+
scalar_var="tm",
|
|
666
|
+
scalar_building_value=-999.99,
|
|
667
|
+
scalar_building_tol=1e-4,
|
|
668
|
+
stride_vox=(1, 1, 1),
|
|
669
|
+
stride_scalar=(1, 1, 1),
|
|
670
|
+
contour_levels=24,
|
|
671
|
+
cmap_name="magma",
|
|
672
|
+
vmin=None,
|
|
673
|
+
vmax=None,
|
|
674
|
+
iso_vmin=None,
|
|
675
|
+
iso_vmax=None,
|
|
676
|
+
greedy_vox=True,
|
|
677
|
+
vox_voxel_size=None,
|
|
678
|
+
scalar_spacing=None,
|
|
679
|
+
opacity_points=None,
|
|
680
|
+
max_opacity=0.10,
|
|
681
|
+
classes_to_show=None,
|
|
682
|
+
voxel_color_scheme="default",
|
|
683
|
+
max_faces_warn=1_000_000,
|
|
684
|
+
):
|
|
685
|
+
"""
|
|
686
|
+
Export two OBJ/MTL files using the same local meter frame:
|
|
687
|
+
- VoxCity voxels: opaque, per-class color, fixed face winding and normals
|
|
688
|
+
- Scalar iso-surfaces: colormap colors with variable transparency
|
|
689
|
+
|
|
690
|
+
The two outputs share the same XY origin and axes (X east, Y north, Z up),
|
|
691
|
+
anchored at the minimum lon/lat of the VoxCity bounding rectangle.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
voxcity_nc (str): Path to VoxCity NetCDF (must include variable 'voxels' and coords 'x','y','z').
|
|
695
|
+
scalar_nc (str): Path to scalar NetCDF containing variable specified by scalar_var.
|
|
696
|
+
lonlat_txt (str): Text file with columns: i j lon lat (1-based indices) describing the scalar grid georef.
|
|
697
|
+
output_dir (str): Directory to write results.
|
|
698
|
+
vox_base_filename (str): Base filename for VoxCity OBJ/MTL.
|
|
699
|
+
tm_base_filename (str): Base filename for scalar iso-surfaces OBJ/MTL.
|
|
700
|
+
scalar_var (str): Name of scalar variable in scalar_nc.
|
|
701
|
+
scalar_building_value (float): Value used in scalar field to mark buildings (to be masked).
|
|
702
|
+
scalar_building_tol (float): Tolerance for building masking (isclose).
|
|
703
|
+
stride_vox (tuple[int,int,int]): Downsampling strides for VoxCity (z,y,x) in voxels.
|
|
704
|
+
stride_scalar (tuple[int,int,int]): Downsampling strides for scalar (k,j,i).
|
|
705
|
+
contour_levels (int): Number of iso-surface levels between vmin and vmax.
|
|
706
|
+
cmap_name (str): Matplotlib colormap name for iso-surfaces.
|
|
707
|
+
vmin (float|None): Minimum scalar value for color mapping and iso range. If None, inferred.
|
|
708
|
+
vmax (float|None): Maximum scalar value for color mapping and iso range. If None, inferred.
|
|
709
|
+
iso_vmin (float|None): Minimum scalar value to generate iso-surface levels. If None, uses vmin.
|
|
710
|
+
iso_vmax (float|None): Maximum scalar value to generate iso-surface levels. If None, uses vmax.
|
|
711
|
+
greedy_vox (bool): If True, use greedy meshing for VoxCity faces to reduce triangles.
|
|
712
|
+
vox_voxel_size (float|tuple[float,float,float]|None): If provided, overrides VoxCity voxel spacing
|
|
713
|
+
for X,Y,Z respectively in meters. A single float applies to all axes.
|
|
714
|
+
scalar_spacing (tuple[float,float,float]|None): If provided, overrides scalar grid spacing (dx,dy,dz)
|
|
715
|
+
used for iso-surface generation. Values are in meters.
|
|
716
|
+
opacity_points (list[tuple[float,float]]|None): Transfer function control points (value, alpha in [0..1]).
|
|
717
|
+
max_opacity (float): Global max opacity multiplier for iso-surfaces (0..1).
|
|
718
|
+
classes_to_show (set[int]|None): Optional subset of voxel classes to export; None -> all present (except 0).
|
|
719
|
+
voxel_color_scheme (str): Color scheme name passed to get_voxel_color_map.
|
|
720
|
+
max_faces_warn (int): Warn if a single class exceeds this many faces.
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
dict: Paths of written files: keys 'vox_obj','vox_mtl','tm_obj','tm_mtl' (values may be None).
|
|
724
|
+
"""
|
|
725
|
+
import json
|
|
726
|
+
import numpy as np
|
|
727
|
+
import os
|
|
728
|
+
import xarray as xr
|
|
729
|
+
import trimesh
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
from skimage import measure as skim
|
|
733
|
+
except Exception as e: # pragma: no cover - optional dependency
|
|
734
|
+
raise ImportError(
|
|
735
|
+
"scikit-image is required for iso-surface generation. Install 'scikit-image'."
|
|
736
|
+
) from e
|
|
737
|
+
|
|
738
|
+
from matplotlib import cm
|
|
739
|
+
|
|
740
|
+
if opacity_points is None:
|
|
741
|
+
opacity_points = [(-0.2, 0.00), (2.0, 1.00)]
|
|
742
|
+
|
|
743
|
+
def find_dims(ds):
|
|
744
|
+
lvl = ["k", "level", "lev", "z", "height", "alt", "plev"]
|
|
745
|
+
yy = ["j", "y", "south_north", "lat", "latitude"]
|
|
746
|
+
xx = ["i", "x", "west_east", "lon", "longitude"]
|
|
747
|
+
tt = ["time", "Times"]
|
|
748
|
+
|
|
749
|
+
def pick(cands):
|
|
750
|
+
for c in cands:
|
|
751
|
+
if c in ds.dims:
|
|
752
|
+
return c
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
t = pick(tt)
|
|
756
|
+
k = pick(lvl)
|
|
757
|
+
j = pick(yy)
|
|
758
|
+
i = pick(xx)
|
|
759
|
+
if (k is None or j is None or i is None) and len(ds.dims) >= 3:
|
|
760
|
+
dims = list(ds.dims)
|
|
761
|
+
k = k or dims[0]
|
|
762
|
+
j = j or dims[-2]
|
|
763
|
+
i = i or dims[-1]
|
|
764
|
+
return t, k, j, i
|
|
765
|
+
|
|
766
|
+
def squeeze_to_kji(da, tname, kname, jname, iname, time_index=0):
|
|
767
|
+
if tname and tname in da.dims:
|
|
768
|
+
da = da.isel({tname: time_index})
|
|
769
|
+
for d in list(da.dims):
|
|
770
|
+
if d not in (kname, jname, iname):
|
|
771
|
+
da = da.isel({d: 0})
|
|
772
|
+
return da.transpose(*(d for d in (kname, jname, iname) if d in da.dims))
|
|
773
|
+
|
|
774
|
+
def downsample3(a, sk, sj, si):
|
|
775
|
+
return a[:: max(1, sk), :: max(1, sj), :: max(1, si)]
|
|
776
|
+
|
|
777
|
+
def clip_minmax(arr, frac):
|
|
778
|
+
v = np.asarray(arr)
|
|
779
|
+
v = v[np.isfinite(v)]
|
|
780
|
+
if v.size == 0:
|
|
781
|
+
return 0.0, 1.0
|
|
782
|
+
if frac <= 0:
|
|
783
|
+
return float(np.nanmin(v)), float(np.nanmax(v))
|
|
784
|
+
vmin_ = float(np.nanpercentile(v, 100 * frac))
|
|
785
|
+
vmax_ = float(np.nanpercentile(v, 100 * (1 - frac)))
|
|
786
|
+
if vmin_ >= vmax_:
|
|
787
|
+
vmin_, vmax_ = float(np.nanmin(v)), float(np.nanmax(v))
|
|
788
|
+
return vmin_, vmax_
|
|
789
|
+
|
|
790
|
+
def meters_per_degree(lat_rad):
|
|
791
|
+
m_per_deg_lat = 111132.92 - 559.82 * np.cos(2 * lat_rad) + 1.175 * np.cos(4 * lat_rad) - 0.0023 * np.cos(6 * lat_rad)
|
|
792
|
+
m_per_deg_lon = 111412.84 * np.cos(lat_rad) - 93.5 * np.cos(3 * lat_rad) + 0.118 * np.cos(5 * lat_rad)
|
|
793
|
+
return m_per_deg_lat, m_per_deg_lon
|
|
794
|
+
|
|
795
|
+
def opacity_at(v, points):
|
|
796
|
+
if not points:
|
|
797
|
+
return 0.0 if np.isscalar(v) else np.zeros_like(v)
|
|
798
|
+
pts = sorted((float(x), float(a)) for x, a in points)
|
|
799
|
+
xs = np.array([p[0] for p in pts], dtype=float)
|
|
800
|
+
as_ = np.array([p[1] for p in pts], dtype=float)
|
|
801
|
+
v_arr = np.asarray(v, dtype=float)
|
|
802
|
+
out = np.empty_like(v_arr, dtype=float)
|
|
803
|
+
out[v_arr <= xs[0]] = as_[0]
|
|
804
|
+
out[v_arr >= xs[-1]] = as_[-1]
|
|
805
|
+
idx = np.searchsorted(xs, v_arr, side="right") - 1
|
|
806
|
+
idx = np.clip(idx, 0, len(xs) - 2)
|
|
807
|
+
x0, x1 = xs[idx], xs[idx + 1]
|
|
808
|
+
a0, a1 = as_[idx], as_[idx + 1]
|
|
809
|
+
t = np.where(x1 > x0, (v_arr - x0) / (x1 - x0), 0.0)
|
|
810
|
+
mid = (v_arr > xs[0]) & (v_arr < xs[-1])
|
|
811
|
+
out[mid] = a0[mid] + t[mid] * (a1[mid] - a0[mid])
|
|
812
|
+
return out.item() if np.isscalar(v) else out
|
|
813
|
+
|
|
814
|
+
def _exposed_face_masks(occ):
|
|
815
|
+
K, J, I = occ.shape
|
|
816
|
+
p = np.pad(occ, ((0, 0), (0, 0), (0, 1)), constant_values=False)
|
|
817
|
+
posx = occ & (~p[..., 1:])
|
|
818
|
+
p = np.pad(occ, ((0, 0), (0, 0), (1, 0)), constant_values=False)
|
|
819
|
+
negx = occ & (~p[..., :-1])
|
|
820
|
+
p = np.pad(occ, ((0, 0), (0, 1), (0, 0)), constant_values=False)
|
|
821
|
+
posy = occ & (~p[:, 1:, :])
|
|
822
|
+
p = np.pad(occ, ((0, 0), (1, 0), (0, 0)), constant_values=False)
|
|
823
|
+
negy = occ & (~p[:, :-1, :])
|
|
824
|
+
p = np.pad(occ, ((0, 1), (0, 0), (0, 0)), constant_values=False)
|
|
825
|
+
posz = occ & (~p[1:, :, :])
|
|
826
|
+
p = np.pad(occ, ((1, 0), (0, 0), (0, 0)), constant_values=False)
|
|
827
|
+
negz = occ & (~p[:-1, :, :])
|
|
828
|
+
return posx, negx, posy, negy, posz, negz
|
|
829
|
+
|
|
830
|
+
def _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx):
|
|
831
|
+
N = k.size
|
|
832
|
+
if N == 0:
|
|
833
|
+
return np.empty((0, 3)), np.empty((0, 3), dtype=np.int64), start_idx
|
|
834
|
+
|
|
835
|
+
dx = (X[1] - X[0]) if len(X) > 1 else 1.0
|
|
836
|
+
dy = (Y[1] - Y[0]) if len(Y) > 1 else 1.0
|
|
837
|
+
dz = (Z[1] - Z[0]) if len(Z) > 1 else 1.0
|
|
838
|
+
|
|
839
|
+
x = X[i].astype(np.float64)
|
|
840
|
+
y = Y[j].astype(np.float64)
|
|
841
|
+
z = Z[k].astype(np.float64)
|
|
842
|
+
hx, hy, hz = dx / 2.0, dy / 2.0, dz / 2.0
|
|
843
|
+
|
|
844
|
+
if plane == "+x":
|
|
845
|
+
P = np.column_stack([x + hx, y - hy, z - hz])
|
|
846
|
+
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
847
|
+
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
848
|
+
S = np.column_stack([x + hx, y - hy, z + hz])
|
|
849
|
+
order = "default"
|
|
850
|
+
elif plane == "-x":
|
|
851
|
+
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
852
|
+
Q = np.column_stack([x - hx, y + hy, z + hz])
|
|
853
|
+
R = np.column_stack([x - hx, y + hy, z - hz])
|
|
854
|
+
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
855
|
+
order = "default"
|
|
856
|
+
elif plane == "+y":
|
|
857
|
+
P = np.column_stack([x - hx, y + hy, z - hz])
|
|
858
|
+
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
859
|
+
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
860
|
+
S = np.column_stack([x - hx, y + hy, z + hz])
|
|
861
|
+
order = "flip" # enforce outward normals
|
|
862
|
+
elif plane == "-y":
|
|
863
|
+
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
864
|
+
Q = np.column_stack([x + hx, y - hy, z + hz])
|
|
865
|
+
R = np.column_stack([x + hx, y - hy, z - hz])
|
|
866
|
+
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
867
|
+
order = "flip"
|
|
868
|
+
elif plane == "+z":
|
|
869
|
+
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
870
|
+
Q = np.column_stack([x + hx, y - hy, z + hz])
|
|
871
|
+
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
872
|
+
S = np.column_stack([x - hx, y + hy, z + hz])
|
|
873
|
+
order = "default"
|
|
874
|
+
else: # "-z"
|
|
875
|
+
P = np.column_stack([x - hx, y + hy, z - hz])
|
|
876
|
+
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
877
|
+
R = np.column_stack([x + hx, y - hy, z - hz])
|
|
878
|
+
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
879
|
+
order = "default"
|
|
880
|
+
|
|
881
|
+
verts = np.vstack([P, Q, R, S])
|
|
882
|
+
a = np.arange(N, dtype=np.int64) + start_idx
|
|
883
|
+
b = a + N
|
|
884
|
+
c = a + 2 * N
|
|
885
|
+
d = a + 3 * N
|
|
886
|
+
|
|
887
|
+
if order == "default":
|
|
888
|
+
tris = np.vstack([np.column_stack([a, b, c]), np.column_stack([a, c, d])])
|
|
889
|
+
else:
|
|
890
|
+
tris = np.vstack([np.column_stack([a, c, b]), np.column_stack([a, d, c])])
|
|
891
|
+
|
|
892
|
+
return verts, tris, start_idx + 4 * N
|
|
893
|
+
|
|
894
|
+
def make_voxel_mesh_uniform_color(occ_mask, X, Y, Z, rgb, name="class"):
|
|
895
|
+
posx, negx, posy, negy, posz, negz = _exposed_face_masks(occ_mask.astype(bool))
|
|
896
|
+
total_faces = int(posx.sum() + negx.sum() + posy.sum() + negy.sum() + posz.sum() + negz.sum())
|
|
897
|
+
if total_faces == 0:
|
|
898
|
+
return None, 0
|
|
899
|
+
if total_faces > max_faces_warn:
|
|
900
|
+
print(f" Warning: {name} faces={total_faces:,} (> {max_faces_warn:,}). Consider increasing stride.")
|
|
901
|
+
|
|
902
|
+
verts_all, tris_all, start_idx = [], [], 0
|
|
903
|
+
for plane, mask in (("+x", posx), ("-x", negx), ("+y", posy), ("-y", negy), ("+z", posz), ("-z", negz)):
|
|
904
|
+
idx = np.argwhere(mask)
|
|
905
|
+
if idx.size == 0:
|
|
906
|
+
continue
|
|
907
|
+
k, j, i = idx[:, 0], idx[:, 1], idx[:, 2]
|
|
908
|
+
Vp, Tp, start_idx = _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx)
|
|
909
|
+
verts_all.append(Vp)
|
|
910
|
+
tris_all.append(Tp)
|
|
911
|
+
|
|
912
|
+
V = np.vstack(verts_all)
|
|
913
|
+
F = np.vstack(tris_all)
|
|
914
|
+
mesh = trimesh.Trimesh(vertices=V, faces=F, process=False)
|
|
915
|
+
rgba = np.array([int(rgb[0]), int(rgb[1]), int(rgb[2]), 255], dtype=np.uint8)
|
|
916
|
+
mesh.visual.face_colors = np.tile(rgba, (len(F), 1))
|
|
917
|
+
return mesh, len(F)
|
|
918
|
+
|
|
919
|
+
def _greedy_rectangles(mask2d):
|
|
920
|
+
h, w = mask2d.shape
|
|
921
|
+
visited = np.zeros_like(mask2d, dtype=bool)
|
|
922
|
+
rects = []
|
|
923
|
+
for u in range(h):
|
|
924
|
+
v = 0
|
|
925
|
+
while v < w:
|
|
926
|
+
if visited[u, v] or not mask2d[u, v]:
|
|
927
|
+
v += 1
|
|
928
|
+
continue
|
|
929
|
+
# width
|
|
930
|
+
width = 1
|
|
931
|
+
while v + width < w and mask2d[u, v + width] and not visited[u, v + width]:
|
|
932
|
+
width += 1
|
|
933
|
+
# height
|
|
934
|
+
height = 1
|
|
935
|
+
done = False
|
|
936
|
+
while u + height < h and not done:
|
|
937
|
+
for k_ in range(width):
|
|
938
|
+
if (not mask2d[u + height, v + k_]) or visited[u + height, v + k_]:
|
|
939
|
+
done = True
|
|
940
|
+
break
|
|
941
|
+
if not done:
|
|
942
|
+
height += 1
|
|
943
|
+
visited[u:u + height, v:v + width] = True
|
|
944
|
+
rects.append((u, v, height, width))
|
|
945
|
+
v += width
|
|
946
|
+
return rects
|
|
947
|
+
|
|
948
|
+
def make_voxel_mesh_uniform_color_greedy(occ_mask, X, Y, Z, rgb, name="class"):
|
|
949
|
+
posx, negx, posy, negy, posz, negz = _exposed_face_masks(occ_mask.astype(bool))
|
|
950
|
+
total_faces_naive = int(posx.sum() + negx.sum() + posy.sum() + negy.sum() + posz.sum() + negz.sum())
|
|
951
|
+
if total_faces_naive == 0:
|
|
952
|
+
return None, 0
|
|
953
|
+
|
|
954
|
+
dx = (X[1] - X[0]) if len(X) > 1 else 1.0
|
|
955
|
+
dy = (Y[1] - Y[0]) if len(Y) > 1 else 1.0
|
|
956
|
+
dz = (Z[1] - Z[0]) if len(Z) > 1 else 1.0
|
|
957
|
+
hx, hy, hz = dx / 2.0, dy / 2.0, dz / 2.0
|
|
958
|
+
|
|
959
|
+
V_list = []
|
|
960
|
+
F_list = []
|
|
961
|
+
start_idx = 0
|
|
962
|
+
|
|
963
|
+
def add_quad(P, Q, R, S, order):
|
|
964
|
+
nonlocal start_idx
|
|
965
|
+
V_list.extend([P, Q, R, S])
|
|
966
|
+
a = start_idx
|
|
967
|
+
b = start_idx + 1
|
|
968
|
+
c = start_idx + 2
|
|
969
|
+
d = start_idx + 3
|
|
970
|
+
start_idx += 4
|
|
971
|
+
if order == "default":
|
|
972
|
+
F_list.append([a, b, c])
|
|
973
|
+
F_list.append([a, c, d])
|
|
974
|
+
else: # flip
|
|
975
|
+
F_list.append([a, c, b])
|
|
976
|
+
F_list.append([a, d, c])
|
|
977
|
+
|
|
978
|
+
K, J, I = occ_mask.shape
|
|
979
|
+
|
|
980
|
+
# +x and -x: iterate i, mask over (k,j)
|
|
981
|
+
for plane, mask3 in (("+x", posx), ("-x", negx)):
|
|
982
|
+
order = "default"
|
|
983
|
+
for i in range(I):
|
|
984
|
+
m2 = mask3[:, :, i]
|
|
985
|
+
if not np.any(m2):
|
|
986
|
+
continue
|
|
987
|
+
for u, v, h, w in _greedy_rectangles(m2):
|
|
988
|
+
k0, j0 = u, v
|
|
989
|
+
k1, j1 = u + h, v + w
|
|
990
|
+
z0 = Z[k0] - hz
|
|
991
|
+
z1 = Z[k1 - 1] + hz
|
|
992
|
+
y0 = Y[j0] - hy
|
|
993
|
+
y1 = Y[j1 - 1] + hy
|
|
994
|
+
x_center = X[i]
|
|
995
|
+
if plane == "+x":
|
|
996
|
+
x = x_center + hx
|
|
997
|
+
P = (x, y0, z0)
|
|
998
|
+
Q = (x, y1, z0)
|
|
999
|
+
R = (x, y1, z1)
|
|
1000
|
+
S = (x, y0, z1)
|
|
1001
|
+
else: # -x
|
|
1002
|
+
x = x_center - hx
|
|
1003
|
+
P = (x, y0, z1)
|
|
1004
|
+
Q = (x, y1, z1)
|
|
1005
|
+
R = (x, y1, z0)
|
|
1006
|
+
S = (x, y0, z0)
|
|
1007
|
+
add_quad(P, Q, R, S, order)
|
|
1008
|
+
|
|
1009
|
+
# +y and -y: iterate j, mask over (k,i)
|
|
1010
|
+
for plane, mask3 in (("+y", posy), ("-y", negy)):
|
|
1011
|
+
order = "flip" # enforce outward normals like original
|
|
1012
|
+
for j in range(J):
|
|
1013
|
+
m2 = mask3[:, j, :]
|
|
1014
|
+
if not np.any(m2):
|
|
1015
|
+
continue
|
|
1016
|
+
for u, v, h, w in _greedy_rectangles(m2):
|
|
1017
|
+
k0, i0 = u, v
|
|
1018
|
+
k1, i1 = u + h, v + w
|
|
1019
|
+
z0 = Z[k0] - hz
|
|
1020
|
+
z1 = Z[k1 - 1] + hz
|
|
1021
|
+
x0 = X[i0] - hx
|
|
1022
|
+
x1 = X[i1 - 1] + hx
|
|
1023
|
+
y_center = Y[j]
|
|
1024
|
+
if plane == "+y":
|
|
1025
|
+
y = y_center + hy
|
|
1026
|
+
P = (x0, y, z0)
|
|
1027
|
+
Q = (x1, y, z0)
|
|
1028
|
+
R = (x1, y, z1)
|
|
1029
|
+
S = (x0, y, z1)
|
|
1030
|
+
else: # -y
|
|
1031
|
+
y = y_center - hy
|
|
1032
|
+
P = (x0, y, z1)
|
|
1033
|
+
Q = (x1, y, z1)
|
|
1034
|
+
R = (x1, y, z0)
|
|
1035
|
+
S = (x0, y, z0)
|
|
1036
|
+
add_quad(P, Q, R, S, order)
|
|
1037
|
+
|
|
1038
|
+
# +z and -z: iterate k, mask over (j,i)
|
|
1039
|
+
for plane, mask3 in (("+z", posz), ("-z", negz)):
|
|
1040
|
+
order = "default"
|
|
1041
|
+
for k in range(K):
|
|
1042
|
+
m2 = mask3[k, :, :]
|
|
1043
|
+
if not np.any(m2):
|
|
1044
|
+
continue
|
|
1045
|
+
for u, v, h, w in _greedy_rectangles(m2):
|
|
1046
|
+
j0, i0 = u, v
|
|
1047
|
+
j1, i1 = u + h, v + w
|
|
1048
|
+
y0 = Y[j0] - hy
|
|
1049
|
+
y1 = Y[j1 - 1] + hy
|
|
1050
|
+
x0 = X[i0] - hx
|
|
1051
|
+
x1 = X[i1 - 1] + hx
|
|
1052
|
+
z_center = Z[k]
|
|
1053
|
+
if plane == "+z":
|
|
1054
|
+
z = z_center + hz
|
|
1055
|
+
P = (x0, y0, z)
|
|
1056
|
+
Q = (x1, y0, z)
|
|
1057
|
+
R = (x1, y1, z)
|
|
1058
|
+
S = (x0, y1, z)
|
|
1059
|
+
else: # -z
|
|
1060
|
+
z = z_center - hz
|
|
1061
|
+
P = (x0, y1, z)
|
|
1062
|
+
Q = (x1, y1, z)
|
|
1063
|
+
R = (x1, y0, z)
|
|
1064
|
+
S = (x0, y0, z)
|
|
1065
|
+
add_quad(P, Q, R, S, order)
|
|
1066
|
+
|
|
1067
|
+
if not V_list or not F_list:
|
|
1068
|
+
return None, 0
|
|
1069
|
+
V = np.asarray(V_list, dtype=np.float64)
|
|
1070
|
+
F = np.asarray(F_list, dtype=np.int64)
|
|
1071
|
+
mesh = trimesh.Trimesh(vertices=V, faces=F, process=False)
|
|
1072
|
+
rgba = np.array([int(rgb[0]), int(rgb[1]), int(rgb[2]), 255], dtype=np.uint8)
|
|
1073
|
+
mesh.visual.face_colors = np.tile(rgba, (len(F), 1))
|
|
1074
|
+
return mesh, len(F)
|
|
1075
|
+
|
|
1076
|
+
def build_tm_isosurfaces_regular_grid(A_scalar, vmin, vmax, levels, dx, dy, dz, origin_xyz, cmap_name, opacity_points, max_opacity, iso_vmin=None, iso_vmax=None):
|
|
1077
|
+
cmap = cm.get_cmap(cmap_name)
|
|
1078
|
+
meshes = []
|
|
1079
|
+
if levels <= 0:
|
|
1080
|
+
return meshes
|
|
1081
|
+
ivmin = vmin if (iso_vmin is None) else float(iso_vmin)
|
|
1082
|
+
ivmax = vmax if (iso_vmax is None) else float(iso_vmax)
|
|
1083
|
+
if not (ivmin < ivmax):
|
|
1084
|
+
return meshes
|
|
1085
|
+
iso_vals = np.linspace(ivmin, ivmax, int(levels))
|
|
1086
|
+
for iso in iso_vals:
|
|
1087
|
+
a_base = float(opacity_at(iso, opacity_points or []))
|
|
1088
|
+
a_base = min(max(a_base, 0.0), 1.0)
|
|
1089
|
+
alpha = a_base * max_opacity
|
|
1090
|
+
if alpha <= 0.0:
|
|
1091
|
+
continue
|
|
1092
|
+
try:
|
|
1093
|
+
verts, faces, _, _ = skim.marching_cubes(A_scalar, level=iso, spacing=(dz, dy, dx))
|
|
1094
|
+
except Exception:
|
|
1095
|
+
continue
|
|
1096
|
+
if len(verts) == 0 or len(faces) == 0:
|
|
1097
|
+
continue
|
|
1098
|
+
V = verts[:, [2, 1, 0]].astype(np.float64)
|
|
1099
|
+
V += np.array(origin_xyz, dtype=np.float64)[None, :]
|
|
1100
|
+
m = trimesh.Trimesh(vertices=V, faces=faces.astype(np.int64), process=False)
|
|
1101
|
+
t = 0.0 if vmax <= vmin else (iso - vmin) / (vmax - vmin)
|
|
1102
|
+
r, g, b, _ = cmap(np.clip(t, 0.0, 1.0))
|
|
1103
|
+
rgba = (
|
|
1104
|
+
int(round(255 * r)),
|
|
1105
|
+
int(round(255 * g)),
|
|
1106
|
+
int(round(255 * b)),
|
|
1107
|
+
int(round(255 * alpha)),
|
|
1108
|
+
)
|
|
1109
|
+
m.visual.face_colors = np.tile(np.array(rgba, dtype=np.uint8), (len(m.faces), 1))
|
|
1110
|
+
meshes.append((iso, m, rgba))
|
|
1111
|
+
print(f"Iso {iso:.4f}: faces={len(m.faces):,}, alpha={alpha:.4f}")
|
|
1112
|
+
return meshes
|
|
1113
|
+
|
|
1114
|
+
def save_obj_with_mtl_and_normals(meshes_dict, output_path, base_filename):
|
|
1115
|
+
os.makedirs(output_path, exist_ok=True)
|
|
1116
|
+
obj_path = os.path.join(output_path, f"{base_filename}.obj")
|
|
1117
|
+
mtl_path = os.path.join(output_path, f"{base_filename}.mtl")
|
|
1118
|
+
|
|
1119
|
+
def to_uint8_rgba(arr):
|
|
1120
|
+
arr = np.asarray(arr)
|
|
1121
|
+
if arr.dtype != np.uint8:
|
|
1122
|
+
if arr.dtype.kind == "f":
|
|
1123
|
+
arr = np.clip(arr, 0.0, 1.0)
|
|
1124
|
+
arr = (arr * 255.0 + 0.5).astype(np.uint8)
|
|
1125
|
+
else:
|
|
1126
|
+
arr = arr.astype(np.uint8)
|
|
1127
|
+
if arr.shape[1] == 3:
|
|
1128
|
+
arr = np.concatenate([arr, np.full((arr.shape[0], 1), 255, np.uint8)], axis=1)
|
|
1129
|
+
return arr
|
|
1130
|
+
|
|
1131
|
+
color_to_id, ordered = {}, []
|
|
1132
|
+
|
|
1133
|
+
def mid_of(rgba):
|
|
1134
|
+
if rgba not in color_to_id:
|
|
1135
|
+
color_to_id[rgba] = len(ordered)
|
|
1136
|
+
ordered.append(rgba)
|
|
1137
|
+
return color_to_id[rgba]
|
|
1138
|
+
|
|
1139
|
+
for m in meshes_dict.values():
|
|
1140
|
+
fc = getattr(m.visual, "face_colors", None)
|
|
1141
|
+
if fc is None or len(fc) == 0:
|
|
1142
|
+
mid_of((200, 200, 200, 255))
|
|
1143
|
+
continue
|
|
1144
|
+
for rgba in np.unique(to_uint8_rgba(fc), axis=0):
|
|
1145
|
+
mid_of(tuple(int(x) for x in rgba.tolist()))
|
|
1146
|
+
|
|
1147
|
+
with open(mtl_path, "w") as mtl:
|
|
1148
|
+
for i, (r, g, b, a) in enumerate(ordered):
|
|
1149
|
+
kd = (r / 255.0, g / 255.0, b / 255.0)
|
|
1150
|
+
ka = kd
|
|
1151
|
+
dval = a / 255.0
|
|
1152
|
+
tr = max(0.0, min(1.0, 1.0 - dval))
|
|
1153
|
+
mtl.write(f"newmtl material_{i}\n")
|
|
1154
|
+
mtl.write(f"Kd {kd[0]:.6f} {kd[1]:.6f} {kd[2]:.6f}\n")
|
|
1155
|
+
mtl.write(f"Ka {ka[0]:.6f} {ka[1]:.6f} {ka[2]:.6f}\n")
|
|
1156
|
+
mtl.write("Ks 0.000000 0.000000 0.000000\n")
|
|
1157
|
+
mtl.write("Ns 0.000000\n")
|
|
1158
|
+
mtl.write("illum 1\n")
|
|
1159
|
+
mtl.write(f"d {dval:.6f}\n")
|
|
1160
|
+
mtl.write(f"Tr {tr:.6f}\n\n")
|
|
1161
|
+
|
|
1162
|
+
def face_normals(V, F):
|
|
1163
|
+
v0, v1, v2 = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
|
|
1164
|
+
n = np.cross(v1 - v0, v2 - v0)
|
|
1165
|
+
L = np.linalg.norm(n, axis=1)
|
|
1166
|
+
mask = L > 0
|
|
1167
|
+
n[mask] /= L[mask][:, None]
|
|
1168
|
+
if (~mask).any():
|
|
1169
|
+
n[~mask] = np.array([0.0, 0.0, 1.0])
|
|
1170
|
+
return n
|
|
1171
|
+
|
|
1172
|
+
with open(obj_path, "w") as obj:
|
|
1173
|
+
obj.write(f"mtllib {os.path.basename(mtl_path)}\n")
|
|
1174
|
+
v_offset = 0
|
|
1175
|
+
n_offset = 0
|
|
1176
|
+
for name, m in meshes_dict.items():
|
|
1177
|
+
V = np.asarray(m.vertices, dtype=np.float64)
|
|
1178
|
+
F = np.asarray(m.faces, dtype=np.int64)
|
|
1179
|
+
if len(V) == 0 or len(F) == 0:
|
|
1180
|
+
continue
|
|
1181
|
+
obj.write(f"o {name}\n")
|
|
1182
|
+
obj.write("s off\n")
|
|
1183
|
+
for vx, vy, vz in V:
|
|
1184
|
+
obj.write(f"v {vx:.6f} {vy:.6f} {vz:.6f}\n")
|
|
1185
|
+
|
|
1186
|
+
fc = getattr(m.visual, "face_colors", None)
|
|
1187
|
+
if fc is None or len(fc) != len(F):
|
|
1188
|
+
fc = np.tile(np.array([200, 200, 200, 255], dtype=np.uint8), (len(F), 1))
|
|
1189
|
+
else:
|
|
1190
|
+
fc = to_uint8_rgba(fc)
|
|
1191
|
+
uniq, inv = np.unique(fc, axis=0, return_inverse=True)
|
|
1192
|
+
color2mid = {tuple(int(x) for x in c.tolist()): mid_of(tuple(int(x) for x in c.tolist())) for c in uniq}
|
|
1193
|
+
|
|
1194
|
+
FN = face_normals(V, F)
|
|
1195
|
+
for nx, ny, nz in FN:
|
|
1196
|
+
obj.write(f"vn {float(nx):.6f} {float(ny):.6f} {float(nz):.6f}\n")
|
|
1197
|
+
|
|
1198
|
+
current_mid = None
|
|
1199
|
+
for i_face, face in enumerate(F):
|
|
1200
|
+
key = tuple(int(x) for x in uniq[inv[i_face]].tolist())
|
|
1201
|
+
mid = color2mid[key]
|
|
1202
|
+
if current_mid != mid:
|
|
1203
|
+
obj.write(f"usemtl material_{mid}\n")
|
|
1204
|
+
current_mid = mid
|
|
1205
|
+
a, b, c = face + 1 + v_offset
|
|
1206
|
+
ni = n_offset + i_face + 1
|
|
1207
|
+
obj.write(f"f {a}//{ni} {b}//{ni} {c}//{ni}\n")
|
|
1208
|
+
|
|
1209
|
+
v_offset += len(V)
|
|
1210
|
+
n_offset += len(F)
|
|
1211
|
+
|
|
1212
|
+
return obj_path, mtl_path
|
|
1213
|
+
|
|
1214
|
+
# Load VoxCity
|
|
1215
|
+
dsv = xr.open_dataset(voxcity_nc)
|
|
1216
|
+
if "voxels" not in dsv:
|
|
1217
|
+
raise KeyError("'voxels' not found in VoxCity dataset.")
|
|
1218
|
+
dav = dsv["voxels"]
|
|
1219
|
+
if tuple(dav.dims) != ("y", "x", "z") and all(d in dav.dims for d in ("y", "x", "z")):
|
|
1220
|
+
dav = dav.transpose("y", "x", "z")
|
|
1221
|
+
|
|
1222
|
+
Yv = dsv["y"].values.astype(float)
|
|
1223
|
+
Xv = dsv["x"].values.astype(float)
|
|
1224
|
+
Zv = dsv["z"].values.astype(float)
|
|
1225
|
+
|
|
1226
|
+
Av = dav.values # (y,x,z)
|
|
1227
|
+
Av_kji = np.transpose(Av, (2, 0, 1)) # (K=z, J=y, I=x)
|
|
1228
|
+
svz, svy, svx = stride_vox
|
|
1229
|
+
Av_kji = downsample3(Av_kji, svz, svy, svx)
|
|
1230
|
+
# Y flip (north-up)
|
|
1231
|
+
Av_kji = Av_kji[:, ::-1, :]
|
|
1232
|
+
|
|
1233
|
+
# VoxCity coordinate spacing (optionally override by vox_voxel_size)
|
|
1234
|
+
Ks, Js, Is = Av_kji.shape
|
|
1235
|
+
if vox_voxel_size is None:
|
|
1236
|
+
Zv_s = Zv[:: max(1, svz)].astype(float)
|
|
1237
|
+
Yv_s = (Yv[:: max(1, svy)] - Yv.min()).astype(float)
|
|
1238
|
+
Xv_s = (Xv[:: max(1, svx)] - Xv.min()).astype(float)
|
|
1239
|
+
else:
|
|
1240
|
+
if isinstance(vox_voxel_size, (int, float)):
|
|
1241
|
+
vx = vy = vz = float(vox_voxel_size)
|
|
1242
|
+
else:
|
|
1243
|
+
try:
|
|
1244
|
+
vx, vy, vz = (float(vox_voxel_size[0]), float(vox_voxel_size[1]), float(vox_voxel_size[2]))
|
|
1245
|
+
except Exception as e:
|
|
1246
|
+
raise ValueError("vox_voxel_size must be a float or a length-3 iterable of floats (vx,vy,vz)") from e
|
|
1247
|
+
Xv_s = (np.arange(Is, dtype=float) * vx)
|
|
1248
|
+
Yv_s = (np.arange(Js, dtype=float) * vy)
|
|
1249
|
+
Zv_s = (np.arange(Ks, dtype=float) * vz)
|
|
1250
|
+
|
|
1251
|
+
# Load scalar and georeference using lon/lat table
|
|
1252
|
+
dss = xr.open_dataset(scalar_nc, decode_coords="all", decode_times=True)
|
|
1253
|
+
tname, kname, jname, iname = find_dims(dss)
|
|
1254
|
+
if scalar_var not in dss:
|
|
1255
|
+
raise KeyError(f"{scalar_var} not found in scalar dataset")
|
|
1256
|
+
|
|
1257
|
+
A = squeeze_to_kji(dss[scalar_var], tname, kname, jname, iname).values # (K,J,I)
|
|
1258
|
+
K0, J0, I0 = map(int, A.shape)
|
|
1259
|
+
|
|
1260
|
+
ll = np.loadtxt(lonlat_txt, comments="#")
|
|
1261
|
+
ii = ll[:, 0].astype(int) - 1
|
|
1262
|
+
jj = ll[:, 1].astype(int) - 1
|
|
1263
|
+
lon = ll[:, 2].astype(float)
|
|
1264
|
+
lat = ll[:, 3].astype(float)
|
|
1265
|
+
I_ll = int(ii.max() + 1)
|
|
1266
|
+
J_ll = int(jj.max() + 1)
|
|
1267
|
+
lon_grid = np.full((J_ll, I_ll), np.nan, float)
|
|
1268
|
+
lat_grid = np.full((J_ll, I_ll), np.nan, float)
|
|
1269
|
+
lon_grid[jj, ii] = lon
|
|
1270
|
+
lat_grid[jj, ii] = lat
|
|
1271
|
+
|
|
1272
|
+
Jc = min(J0, J_ll)
|
|
1273
|
+
Ic = min(I0, I_ll)
|
|
1274
|
+
if (Jc != J0) or (Ic != I0):
|
|
1275
|
+
print(
|
|
1276
|
+
f"Warning: scalar (J,I)=({J0},{I0}) vs lonlat ({J_ll},{I_ll}); using common ({Jc},{Ic})."
|
|
1277
|
+
)
|
|
1278
|
+
A = A[:, :Jc, :Ic]
|
|
1279
|
+
lon_grid = lon_grid[:Jc, :Ic]
|
|
1280
|
+
lat_grid = lat_grid[:Jc, :Ic]
|
|
1281
|
+
|
|
1282
|
+
ssk, ssj, ssi = stride_scalar
|
|
1283
|
+
A_s = downsample3(A, ssk, ssj, ssi)
|
|
1284
|
+
lon_s = lon_grid[:: max(1, ssj), :: max(1, ssi)]
|
|
1285
|
+
lat_s = lat_grid[:: max(1, ssj), :: max(1, ssi)]
|
|
1286
|
+
Ks, Js, Is = A_s.shape
|
|
1287
|
+
|
|
1288
|
+
rect = np.array(json.loads(dsv.attrs.get("rectangle_vertices_lonlat_json", "[]")), float)
|
|
1289
|
+
if rect.size == 0:
|
|
1290
|
+
raise RuntimeError("VoxCity attribute 'rectangle_vertices_lonlat_json' missing.")
|
|
1291
|
+
lon0 = float(np.min(rect[:, 0]))
|
|
1292
|
+
lat0 = float(np.min(rect[:, 1]))
|
|
1293
|
+
lat_c = float(np.mean(rect[:, 1]))
|
|
1294
|
+
m_per_deg_lat, m_per_deg_lon = meters_per_degree(np.deg2rad(lat_c))
|
|
1295
|
+
Xs_m = (lon_s - lon0) * m_per_deg_lon
|
|
1296
|
+
Ys_m = (lat_s - lat0) * m_per_deg_lat
|
|
1297
|
+
|
|
1298
|
+
if (kname is not None) and (kname in dss.coords):
|
|
1299
|
+
zc = dss.coords[kname].values
|
|
1300
|
+
if np.issubdtype(zc.dtype, np.number) and zc.ndim == 1 and len(zc) >= Ks:
|
|
1301
|
+
Zk = zc.astype(float)[:: max(1, ssk)][:Ks]
|
|
1302
|
+
else:
|
|
1303
|
+
Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
|
|
1304
|
+
else:
|
|
1305
|
+
Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
|
|
1306
|
+
|
|
1307
|
+
# Mask scalar buildings
|
|
1308
|
+
bmask_scalar = downsample3(
|
|
1309
|
+
np.isclose(A, scalar_building_value, atol=scalar_building_tol), ssk, ssj, ssi
|
|
1310
|
+
)
|
|
1311
|
+
A_s = A_s.astype(float)
|
|
1312
|
+
A_s[bmask_scalar] = np.nan
|
|
1313
|
+
|
|
1314
|
+
finite_vals = A_s[np.isfinite(A_s)]
|
|
1315
|
+
if finite_vals.size == 0:
|
|
1316
|
+
raise RuntimeError("No finite scalar values after masking.")
|
|
1317
|
+
if (vmin is None) or (vmax is None):
|
|
1318
|
+
auto_vmin, auto_vmax = clip_minmax(finite_vals, 0.0)
|
|
1319
|
+
if vmin is None:
|
|
1320
|
+
vmin = auto_vmin
|
|
1321
|
+
if vmax is None:
|
|
1322
|
+
vmax = auto_vmax
|
|
1323
|
+
if not (vmin < vmax):
|
|
1324
|
+
raise ValueError("vmin must be less than vmax.")
|
|
1325
|
+
A_s[np.isnan(A_s)] = vmin - 1e6
|
|
1326
|
+
|
|
1327
|
+
# Determine iso-surface generation range (defaults to color mapping range)
|
|
1328
|
+
iso_vmin_eff = vmin if (iso_vmin is None) else float(iso_vmin)
|
|
1329
|
+
iso_vmax_eff = vmax if (iso_vmax is None) else float(iso_vmax)
|
|
1330
|
+
if not (iso_vmin_eff < iso_vmax_eff):
|
|
1331
|
+
raise ValueError("iso_vmin must be less than iso_vmax.")
|
|
1332
|
+
|
|
1333
|
+
Xmin, Xmax = np.nanmin(Xs_m), np.nanmax(Xs_m)
|
|
1334
|
+
Ymin, Ymax = np.nanmin(Ys_m), np.nanmax(Ys_m)
|
|
1335
|
+
dx_s = (Xmax - Xmin) / max(1, Is - 1)
|
|
1336
|
+
dy_s = (Ymax - Ymin) / max(1, Js - 1)
|
|
1337
|
+
dz_s = (Zk[-1] - Zk[0]) / max(1, Ks - 1) if Ks > 1 else 1.0
|
|
1338
|
+
if scalar_spacing is not None:
|
|
1339
|
+
try:
|
|
1340
|
+
dx_s, dy_s, dz_s = (float(scalar_spacing[0]), float(scalar_spacing[1]), float(scalar_spacing[2]))
|
|
1341
|
+
except Exception as e:
|
|
1342
|
+
raise ValueError("scalar_spacing must be a length-3 iterable of floats (dx,dy,dz)") from e
|
|
1343
|
+
origin_xyz = (float(Xmin), float(Ymin), float(Zk[0]))
|
|
1344
|
+
|
|
1345
|
+
vox_meshes = {}
|
|
1346
|
+
tm_meshes = {}
|
|
1347
|
+
|
|
1348
|
+
present = set(np.unique(Av_kji))
|
|
1349
|
+
present.discard(0)
|
|
1350
|
+
if classes_to_show is not None:
|
|
1351
|
+
present &= set(classes_to_show)
|
|
1352
|
+
present = sorted(present)
|
|
1353
|
+
|
|
1354
|
+
faces_total = 0
|
|
1355
|
+
voxel_color_map = get_voxel_color_map(color_scheme=voxel_color_scheme)
|
|
1356
|
+
for cls in present:
|
|
1357
|
+
mask = Av_kji == cls
|
|
1358
|
+
if not np.any(mask):
|
|
1359
|
+
continue
|
|
1360
|
+
rgb = voxel_color_map.get(int(cls), [200, 200, 200])
|
|
1361
|
+
if greedy_vox:
|
|
1362
|
+
m_cls, faces = make_voxel_mesh_uniform_color_greedy(mask, Xv_s, Yv_s, Zv_s, rgb=rgb, name=f"class_{int(cls)}")
|
|
1363
|
+
else:
|
|
1364
|
+
m_cls, faces = make_voxel_mesh_uniform_color(mask, Xv_s, Yv_s, Zv_s, rgb=rgb, name=f"class_{int(cls)}")
|
|
1365
|
+
if m_cls is not None:
|
|
1366
|
+
vox_meshes[f"voxclass_{int(cls)}"] = m_cls
|
|
1367
|
+
faces_total += faces
|
|
1368
|
+
print(f"[VoxCity] total voxel faces: {faces_total:,}")
|
|
1369
|
+
|
|
1370
|
+
iso_meshes = build_tm_isosurfaces_regular_grid(
|
|
1371
|
+
A_scalar=A_s,
|
|
1372
|
+
vmin=vmin,
|
|
1373
|
+
vmax=vmax,
|
|
1374
|
+
levels=contour_levels,
|
|
1375
|
+
dx=dx_s,
|
|
1376
|
+
dy=dy_s,
|
|
1377
|
+
dz=dz_s,
|
|
1378
|
+
origin_xyz=origin_xyz,
|
|
1379
|
+
cmap_name=cmap_name,
|
|
1380
|
+
opacity_points=opacity_points,
|
|
1381
|
+
max_opacity=max_opacity,
|
|
1382
|
+
iso_vmin=iso_vmin_eff,
|
|
1383
|
+
iso_vmax=iso_vmax_eff,
|
|
1384
|
+
)
|
|
1385
|
+
for iso, m, rgba in iso_meshes:
|
|
1386
|
+
tm_meshes[f"iso_{iso:.6f}"] = m
|
|
1387
|
+
|
|
1388
|
+
if not vox_meshes and not tm_meshes:
|
|
1389
|
+
raise RuntimeError("Nothing to export.")
|
|
1390
|
+
|
|
1391
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1392
|
+
obj_vox = mtl_vox = obj_tm = mtl_tm = None
|
|
1393
|
+
if vox_meshes:
|
|
1394
|
+
obj_vox, mtl_vox = save_obj_with_mtl_and_normals(vox_meshes, output_dir, vox_base_filename)
|
|
1395
|
+
if tm_meshes:
|
|
1396
|
+
obj_tm, mtl_tm = save_obj_with_mtl_and_normals(tm_meshes, output_dir, tm_base_filename)
|
|
1397
|
+
|
|
1398
|
+
print("Export finished.")
|
|
1399
|
+
if obj_vox:
|
|
1400
|
+
print(f"VoxCity OBJ: {obj_vox}")
|
|
1401
|
+
print(f"VoxCity MTL: {mtl_vox}")
|
|
1402
|
+
if obj_tm:
|
|
1403
|
+
print(f"Scalar Iso OBJ: {obj_tm}")
|
|
1404
|
+
print(f"Scalar Iso MTL: {mtl_tm}")
|
|
1405
|
+
|
|
1406
1406
|
return {"vox_obj": obj_vox, "vox_mtl": mtl_vox, "tm_obj": obj_tm, "tm_mtl": mtl_tm}
|