voxcity 0.6.26__py3-none-any.whl → 0.7.0__py3-none-any.whl

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