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 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}