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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. voxcity/__init__.py +10 -4
  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 +9 -1
  10. voxcity/exporter/cityles.py +129 -34
  11. voxcity/exporter/envimet.py +51 -26
  12. voxcity/exporter/magicavoxel.py +42 -5
  13. voxcity/exporter/netcdf.py +27 -0
  14. voxcity/exporter/obj.py +103 -28
  15. voxcity/generator/__init__.py +47 -0
  16. voxcity/generator/api.py +721 -0
  17. voxcity/generator/grids.py +381 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/update.py +429 -0
  21. voxcity/generator/voxelizer.py +392 -0
  22. voxcity/geoprocessor/__init__.py +75 -6
  23. voxcity/geoprocessor/conversion.py +153 -0
  24. voxcity/geoprocessor/draw.py +1488 -1169
  25. voxcity/geoprocessor/heights.py +199 -0
  26. voxcity/geoprocessor/io.py +101 -0
  27. voxcity/geoprocessor/merge_utils.py +91 -0
  28. voxcity/geoprocessor/mesh.py +26 -10
  29. voxcity/geoprocessor/network.py +35 -6
  30. voxcity/geoprocessor/overlap.py +84 -0
  31. voxcity/geoprocessor/raster/__init__.py +82 -0
  32. voxcity/geoprocessor/raster/buildings.py +435 -0
  33. voxcity/geoprocessor/raster/canopy.py +258 -0
  34. voxcity/geoprocessor/raster/core.py +150 -0
  35. voxcity/geoprocessor/raster/export.py +93 -0
  36. voxcity/geoprocessor/raster/landcover.py +159 -0
  37. voxcity/geoprocessor/raster/raster.py +110 -0
  38. voxcity/geoprocessor/selection.py +85 -0
  39. voxcity/geoprocessor/utils.py +824 -820
  40. voxcity/models.py +113 -0
  41. voxcity/simulator/common/__init__.py +22 -0
  42. voxcity/simulator/common/geometry.py +98 -0
  43. voxcity/simulator/common/raytracing.py +450 -0
  44. voxcity/simulator/solar/__init__.py +66 -0
  45. voxcity/simulator/solar/integration.py +336 -0
  46. voxcity/simulator/solar/kernels.py +62 -0
  47. voxcity/simulator/solar/radiation.py +648 -0
  48. voxcity/simulator/solar/sky.py +668 -0
  49. voxcity/simulator/solar/temporal.py +792 -0
  50. voxcity/simulator/view.py +36 -2286
  51. voxcity/simulator/visibility/__init__.py +29 -0
  52. voxcity/simulator/visibility/landmark.py +392 -0
  53. voxcity/simulator/visibility/view.py +508 -0
  54. voxcity/utils/__init__.py +11 -0
  55. voxcity/utils/classes.py +194 -0
  56. voxcity/utils/lc.py +80 -39
  57. voxcity/utils/logging.py +61 -0
  58. voxcity/utils/orientation.py +51 -0
  59. voxcity/utils/shape.py +230 -0
  60. voxcity/utils/weather/__init__.py +26 -0
  61. voxcity/utils/weather/epw.py +146 -0
  62. voxcity/utils/weather/files.py +36 -0
  63. voxcity/utils/weather/onebuilding.py +486 -0
  64. voxcity/visualizer/__init__.py +24 -0
  65. voxcity/visualizer/builder.py +43 -0
  66. voxcity/visualizer/grids.py +141 -0
  67. voxcity/visualizer/maps.py +187 -0
  68. voxcity/visualizer/palette.py +228 -0
  69. voxcity/visualizer/renderer.py +1145 -0
  70. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
  71. voxcity-1.0.2.dist-info/RECORD +81 -0
  72. voxcity/generator.py +0 -1302
  73. voxcity/geoprocessor/grid.py +0 -1739
  74. voxcity/geoprocessor/polygon.py +0 -1344
  75. voxcity/simulator/solar.py +0 -2339
  76. voxcity/utils/visualization.py +0 -2849
  77. voxcity/utils/weather.py +0 -1038
  78. voxcity-0.6.26.dist-info/RECORD +0 -38
  79. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  80. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -14,25 +14,26 @@ Notes:
14
14
  import os
15
15
  import numpy as np
16
16
  from pathlib import Path
17
+ from ..models import VoxCity
17
18
 
18
19
 
19
20
  # VoxCity standard land cover classes after conversion
20
- # Based on convert_land_cover function output
21
+ # Based on convert_land_cover function output (1-based indices)
21
22
  VOXCITY_STANDARD_CLASSES = {
22
- 0: 'Bareland',
23
- 1: 'Rangeland',
24
- 2: 'Shrub',
25
- 3: 'Agriculture land',
26
- 4: 'Tree',
27
- 5: 'Moss and lichen',
28
- 6: 'Wet land',
29
- 7: 'Mangrove',
30
- 8: 'Water',
31
- 9: 'Snow and ice',
32
- 10: 'Developed space',
33
- 11: 'Road',
34
- 12: 'Building',
35
- 13: 'No Data'
23
+ 1: 'Bareland',
24
+ 2: 'Rangeland',
25
+ 3: 'Shrub',
26
+ 4: 'Agriculture land',
27
+ 5: 'Tree',
28
+ 6: 'Moss and lichen',
29
+ 7: 'Wet land',
30
+ 8: 'Mangrove',
31
+ 9: 'Water',
32
+ 10: 'Snow and ice',
33
+ 11: 'Developed space',
34
+ 12: 'Road',
35
+ 13: 'Building',
36
+ 14: 'No Data'
36
37
  }
37
38
 
38
39
  ## Source-specific class name to CityLES land use mappings
@@ -189,6 +190,27 @@ def _build_index_to_cityles_map(land_cover_source):
189
190
  return index_to_code, class_names
190
191
 
191
192
 
193
+ def _resolve_under_tree_code(under_tree_class_name, under_tree_cityles_code, land_cover_source):
194
+ """Resolve the CityLES land-use code used under tree canopy.
195
+
196
+ Priority:
197
+ 1) Explicit numeric code if provided
198
+ 2) Class name using the source-specific mapping
199
+ 3) Class name using the standard (OSM) mapping
200
+ 4) Default to 9 (Bare Land)
201
+ """
202
+ if under_tree_cityles_code is not None:
203
+ try:
204
+ return int(under_tree_cityles_code)
205
+ except Exception:
206
+ pass
207
+ name_to_code = _get_source_name_mapping(land_cover_source)
208
+ code = name_to_code.get(under_tree_class_name)
209
+ if code is None:
210
+ code = OSM_CLASS_TO_CITYLES.get(under_tree_class_name, 9)
211
+ return code
212
+
213
+
192
214
  def export_topog(building_height_grid, building_id_grid, output_path,
193
215
  building_material='default', cityles_landuse_grid=None):
194
216
  """
@@ -239,7 +261,9 @@ def export_topog(building_height_grid, building_id_grid, output_path,
239
261
  f.write(f"{i_1based} {j_1based} {height:.1f} {material_code_cell} 0.0 0.0 102\n")
240
262
 
241
263
 
242
- def export_landuse(land_cover_grid, output_path, land_cover_source=None):
264
+ def export_landuse(land_cover_grid, output_path, land_cover_source=None,
265
+ canopy_height_grid=None, building_height_grid=None,
266
+ under_tree_class_name='Bareland', under_tree_cityles_code=None):
243
267
  """
244
268
  Export landuse.txt file for CityLES
245
269
 
@@ -251,6 +275,17 @@ def export_landuse(land_cover_grid, output_path, land_cover_source=None):
251
275
  Output directory path
252
276
  land_cover_source : str, optional
253
277
  Source of land cover data
278
+ canopy_height_grid : numpy.ndarray, optional
279
+ 2D array of canopy heights; if provided, cells with canopy (>0) will be
280
+ assigned the ground class under the canopy instead of a tree class.
281
+ building_height_grid : numpy.ndarray, optional
282
+ 2D array of building heights; if provided, canopy overrides will not be
283
+ applied where buildings exist (height > 0).
284
+ under_tree_class_name : str, optional
285
+ Name of ground land-cover class to use under tree canopy. Defaults to 'Bareland'.
286
+ under_tree_cityles_code : int, optional
287
+ Explicit CityLES land-use code to use under canopy; if provided it takes
288
+ precedence over under_tree_class_name.
254
289
  """
255
290
  filename = output_path / 'landuse.txt'
256
291
 
@@ -261,7 +296,12 @@ def export_landuse(land_cover_grid, output_path, land_cover_source=None):
261
296
 
262
297
  print(f"Land cover source: {land_cover_source} (raw indices)")
263
298
 
264
- # Create mapping statistics
299
+ # Resolve the CityLES code to use under tree canopy
300
+ under_tree_code = _resolve_under_tree_code(
301
+ under_tree_class_name, under_tree_cityles_code, land_cover_source
302
+ )
303
+
304
+ # Create mapping statistics: per raw index, count per resulting CityLES code
265
305
  mapping_stats = {}
266
306
  # Prepare grid to return
267
307
  cityles_landuse_grid = np.zeros((ny, nx), dtype=int)
@@ -272,24 +312,33 @@ def export_landuse(land_cover_grid, output_path, land_cover_source=None):
272
312
  for i in range(nx):
273
313
  idx = int(land_cover_grid[j, i])
274
314
  cityles_code = index_to_code.get(idx, 4)
315
+
316
+ # If a canopy grid is provided, override tree canopy cells to the
317
+ # specified ground class, optionally skipping where buildings exist.
318
+ if canopy_height_grid is not None:
319
+ has_canopy = float(canopy_height_grid[j, i]) > 0.0
320
+ has_building = False
321
+ if building_height_grid is not None:
322
+ has_building = float(building_height_grid[j, i]) > 0.0
323
+ if has_canopy and not has_building:
324
+ cityles_code = under_tree_code
275
325
  f.write(f"{cityles_code}\n")
276
326
 
277
327
  cityles_landuse_grid[j, i] = cityles_code
278
328
 
279
329
  # Track mapping statistics
280
330
  if idx not in mapping_stats:
281
- mapping_stats[idx] = {'cityles_code': cityles_code, 'count': 0}
282
- mapping_stats[idx]['count'] += 1
331
+ mapping_stats[idx] = {}
332
+ mapping_stats[idx][cityles_code] = mapping_stats[idx].get(cityles_code, 0) + 1
283
333
 
284
334
  # Print mapping summary
285
335
  print("\nLand cover mapping summary (by source class):")
286
336
  total = ny * nx
287
337
  for idx in sorted(mapping_stats.keys()):
288
- stats = mapping_stats[idx]
289
- percentage = (stats['count'] / total) * 100
290
338
  class_name = class_names[idx] if 0 <= idx < len(class_names) else 'Unknown'
291
- print(f" {idx}: {class_name} -> CityLES {stats['cityles_code']}: "
292
- f"{stats['count']} cells ({percentage:.1f}%)")
339
+ for code, count in sorted(mapping_stats[idx].items()):
340
+ percentage = (count / total) * 100
341
+ print(f" {idx}: {class_name} -> CityLES {code}: {count} cells ({percentage:.1f}%)")
293
342
 
294
343
  return cityles_landuse_grid
295
344
 
@@ -322,7 +371,7 @@ def export_dem(dem_grid, output_path):
322
371
  f.write(f"{i_1based} {j_1based} {elevation:.1f}\n")
323
372
 
324
373
 
325
- def export_vmap(canopy_height_grid, output_path, tree_base_ratio=0.3, tree_type='default', building_height_grid=None, canopy_bottom_height_grid=None):
374
+ def export_vmap(canopy_height_grid, output_path, trunk_height_ratio=0.3, tree_type='default', building_height_grid=None, canopy_bottom_height_grid=None):
326
375
  """
327
376
  Export vmap.txt file for CityLES
328
377
 
@@ -332,7 +381,7 @@ def export_vmap(canopy_height_grid, output_path, tree_base_ratio=0.3, tree_type=
332
381
  2D array of canopy heights
333
382
  output_path : Path
334
383
  Output directory path
335
- tree_base_ratio : float
384
+ trunk_height_ratio : float
336
385
  Ratio of tree base height to total canopy height
337
386
  tree_type : str
338
387
  Tree type for mapping
@@ -366,7 +415,7 @@ def export_vmap(canopy_height_grid, output_path, tree_base_ratio=0.3, tree_type=
366
415
  if canopy_bottom_height_grid is not None:
367
416
  lower_height = float(np.clip(canopy_bottom_height_grid[j, i], 0.0, total_height))
368
417
  else:
369
- lower_height = total_height * tree_base_ratio
418
+ lower_height = total_height * trunk_height_ratio
370
419
  upper_height = total_height
371
420
  # Format: i j lower_height upper_height tree_type
372
421
  f.write(f"{i_1based} {j_1based} {lower_height:.1f} {upper_height:.1f} {tree_code}\n")
@@ -412,11 +461,16 @@ def export_lonlat(rectangle_vertices, grid_shape, output_path):
412
461
  f.write(f"{i_1based} {j_1based} {lon:.7f} {lat:.8f}\n")
413
462
 
414
463
 
415
- def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
416
- land_cover_grid, dem_grid, meshsize, land_cover_source,
417
- rectangle_vertices, output_directory="output/cityles",
418
- building_material='default', tree_type='default',
419
- tree_base_ratio=0.3, canopy_bottom_height_grid=None, **kwargs):
464
+ def export_cityles(city: VoxCity,
465
+ output_directory: str = "output/cityles",
466
+ building_material: str = 'default',
467
+ tree_type: str = 'default',
468
+ trunk_height_ratio: float = 0.3,
469
+ canopy_bottom_height_grid=None,
470
+ under_tree_class_name: str = 'Bareland',
471
+ under_tree_cityles_code=None,
472
+ land_cover_source: str | None = None,
473
+ **kwargs):
420
474
  """
421
475
  Export VoxCity data to CityLES format
422
476
 
@@ -444,7 +498,7 @@ def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
444
498
  Building material type for mapping
445
499
  tree_type : str
446
500
  Tree type for mapping
447
- tree_base_ratio : float
501
+ trunk_height_ratio : float
448
502
  Ratio of tree base height to total canopy height
449
503
  **kwargs : dict
450
504
  Additional parameters (for compatibility)
@@ -457,11 +511,29 @@ def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
457
511
  output_path = create_cityles_directories(output_directory)
458
512
 
459
513
  print(f"Exporting CityLES files to: {output_path}")
514
+ # Resolve data from VoxCity
515
+ building_height_grid = city.buildings.heights
516
+ building_id_grid = city.buildings.ids if city.buildings.ids is not None else np.zeros_like(building_height_grid, dtype=int)
517
+ canopy_height_grid = city.tree_canopy.top if city.tree_canopy is not None else np.zeros_like(city.land_cover.classes, dtype=float)
518
+ land_cover_grid = city.land_cover.classes
519
+ dem_grid = city.dem.elevation
520
+ meshsize = float(city.voxels.meta.meshsize)
521
+ rectangle_vertices = city.extras.get("rectangle_vertices") or [(0.0, 0.0)] * 4
522
+ land_cover_source = land_cover_source or city.extras.get("land_cover_source", "Standard")
523
+
460
524
  print(f"Land cover source: {land_cover_source}")
461
525
 
462
526
  # Export individual files
463
527
  print("\nExporting landuse.txt...")
464
- cityles_landuse_grid = export_landuse(land_cover_grid, output_path, land_cover_source)
528
+ cityles_landuse_grid = export_landuse(
529
+ land_cover_grid,
530
+ output_path,
531
+ land_cover_source,
532
+ canopy_height_grid=canopy_height_grid,
533
+ building_height_grid=building_height_grid,
534
+ under_tree_class_name=under_tree_class_name,
535
+ under_tree_cityles_code=under_tree_cityles_code,
536
+ )
465
537
 
466
538
  print("\nExporting topog.txt...")
467
539
  export_topog(
@@ -476,7 +548,7 @@ def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
476
548
  export_dem(dem_grid, output_path)
477
549
 
478
550
  print("\nExporting vmap.txt...")
479
- export_vmap(canopy_height_grid, output_path, tree_base_ratio, tree_type, building_height_grid=building_height_grid, canopy_bottom_height_grid=canopy_bottom_height_grid)
551
+ export_vmap(canopy_height_grid, output_path, trunk_height_ratio, tree_type, building_height_grid=building_height_grid, canopy_bottom_height_grid=canopy_bottom_height_grid)
480
552
 
481
553
  print("\nExporting lonlat.txt...")
482
554
  export_lonlat(rectangle_vertices, building_height_grid.shape, output_path)
@@ -497,6 +569,11 @@ def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
497
569
  # Trees count after removing overlaps with buildings
498
570
  trees_count = int(np.sum(np.where(building_height_grid > 0, 0.0, canopy_height_grid) > 0))
499
571
  f.write(f"Trees: {trees_count}\n")
572
+ # Under-tree land-use selection
573
+ under_tree_code = _resolve_under_tree_code(
574
+ under_tree_class_name, under_tree_cityles_code, land_cover_source
575
+ )
576
+ f.write(f"Under-tree land use: {under_tree_class_name} (CityLES {under_tree_code})\n")
500
577
 
501
578
  # Add land use value ranges
502
579
  f.write(f"\nLand cover value range: {land_cover_grid.min()} - {land_cover_grid.max()}\n")
@@ -507,6 +584,24 @@ def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
507
584
  return str(output_path)
508
585
 
509
586
 
587
+ class CityLesExporter:
588
+ """Exporter adapter to write a VoxCity model to CityLES text files."""
589
+
590
+ def export(self, obj, output_directory: str, base_filename: str, **kwargs):
591
+ if not isinstance(obj, VoxCity):
592
+ raise TypeError("CityLesExporter expects a VoxCity instance")
593
+ city: VoxCity = obj
594
+ # CityLES writes multiple files; use output_directory/base_filename as folder/name
595
+ out_dir = os.path.join(output_directory, base_filename)
596
+ os.makedirs(out_dir, exist_ok=True)
597
+ export_cityles(
598
+ city,
599
+ output_directory=out_dir,
600
+ **kwargs,
601
+ )
602
+ return out_dir
603
+
604
+
510
605
  # Helper function to apply VoxCity's convert_land_cover if needed
511
606
  def ensure_converted_land_cover(land_cover_grid, land_cover_source):
512
607
  """
@@ -27,9 +27,10 @@ import os
27
27
  import numpy as np
28
28
  import datetime
29
29
 
30
- from ..geoprocessor.grid import apply_operation, translate_array, group_and_label_cells, process_grid
30
+ from ..geoprocessor.raster import apply_operation, translate_array, group_and_label_cells, process_grid
31
31
  from ..geoprocessor.utils import get_city_country_name_from_rectangle, get_timezone_info
32
32
  from ..utils.lc import convert_land_cover
33
+ from ..models import VoxCity
33
34
 
34
35
  def array_to_string(arr):
35
36
  """Convert a 2D numpy array to a string representation with comma-separated values.
@@ -136,21 +137,26 @@ def prepare_grids(building_height_grid_ori, building_id_grid_ori, canopy_height_
136
137
  building_height_grid = apply_operation(building_height_grid, meshsize)
137
138
 
138
139
  # Convert land cover if needed based on source
139
- if (land_cover_source == 'OpenEarthMapJapan') or (land_cover_source == 'OpenStreetMap'):
140
- land_cover_grid_converted = land_cover_grid_ori
140
+ if land_cover_source == 'OpenStreetMap':
141
+ # OpenStreetMap uses Standard classification, just shift to 1-based
142
+ land_cover_grid_converted = land_cover_grid_ori + 1
141
143
  else:
144
+ # All other sources need remapping to standard indices
142
145
  land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=land_cover_source)
143
146
 
144
- land_cover_grid = np.flipud(land_cover_grid_converted).copy() + 1
147
+ land_cover_grid = np.flipud(land_cover_grid_converted).copy()
145
148
 
146
149
  # Dictionary mapping land cover types to vegetation codes
150
+ # Standard 1-based indices: 1=Bareland, 2=Rangeland, 3=Shrub, 4=Agriculture, 5=Tree,
151
+ # 6=Moss/lichen, 7=Wetland, 8=Mangrove, 9=Water, 10=Snow,
152
+ # 11=Developed, 12=Road, 13=Building, 14=NoData
147
153
  veg_translation_dict = {
148
154
  1: '', # Bareland
149
155
  2: '0200XX', # Rangeland
150
156
  3: '0200H1', # Shrub
151
- 4: '0200XX', # Moss and lichen
152
- 5: '0200XX', # Agriculture land
153
- 6: '', # Tree
157
+ 4: '0200XX', # Agriculture land
158
+ 5: '', # Tree (handled separately as 3D vegetation)
159
+ 6: '0200XX', # Moss and lichen
154
160
  7: '0200XX', # Wet land
155
161
  8: '' # Mangroves
156
162
  }
@@ -161,9 +167,9 @@ def prepare_grids(building_height_grid_ori, building_id_grid_ori, canopy_height_
161
167
  1: '000000', # Bareland
162
168
  2: '000000', # Rangeland
163
169
  3: '000000', # Shrub
164
- 4: '000000', # Moss and lichen
165
- 5: '000000', # Agriculture land
166
- 6: '000000', # Tree
170
+ 4: '000000', # Agriculture land
171
+ 5: '000000', # Tree
172
+ 6: '000000', # Moss and lichen
167
173
  7: '0200WW', # Wet land
168
174
  8: '0200WW', # Mangroves
169
175
  9: '0200WW', # Water
@@ -478,25 +484,18 @@ def save_file(content, output_file_path):
478
484
  with open(output_file_path, 'w', encoding='utf-8') as file:
479
485
  file.write(content)
480
486
 
481
- def export_inx(building_height_grid_ori, building_id_grid_ori, canopy_height_grid_ori, land_cover_grid_ori, dem_grid_ori, meshsize, land_cover_source, rectangle_vertices, **kwargs):
487
+ def export_inx(city: VoxCity, output_directory: str, file_basename: str = 'voxcity', land_cover_source: str | None = None, **kwargs):
482
488
  """Export model data to ENVI-met INX file format.
483
489
 
484
490
  This is the main function for exporting voxel city data to ENVI-met format.
485
491
  It coordinates the entire export process from grid preparation to file saving.
486
492
 
487
493
  Args:
488
- building_height_grid_ori (numpy.ndarray): Original building height grid
489
- building_id_grid_ori (numpy.ndarray): Original building ID grid
490
- canopy_height_grid_ori (numpy.ndarray): Original canopy height grid
491
- land_cover_grid_ori (numpy.ndarray): Original land cover grid
492
- dem_grid_ori (numpy.ndarray): Original DEM grid
493
- meshsize (float): Size of mesh cells in meters
494
- land_cover_source (str): Source of land cover data
495
- rectangle_vertices (list): Vertices defining model area
496
- **kwargs: Additional keyword arguments:
497
- - output_directory (str): Directory to save output
498
- - file_basename (str): Base filename for output
499
- - Other args passed to create_xml_content()
494
+ city (VoxCity): VoxCity instance to export
495
+ output_directory (str): Directory to save output
496
+ file_basename (str): Base filename (without extension)
497
+ land_cover_source (str | None): Optional override for land cover source; defaults to city.extras
498
+ **kwargs: Additional keyword arguments passed to create_xml_content()
500
499
 
501
500
  Notes:
502
501
  - Creates output directory if it doesn't exist
@@ -504,20 +503,46 @@ def export_inx(building_height_grid_ori, building_id_grid_ori, canopy_height_gri
504
503
  - Generates complete INX file with all required data
505
504
  - Uses standardized file naming convention
506
505
  """
506
+ # Resolve inputs from VoxCity
507
+ meshsize = float(city.voxels.meta.meshsize)
508
+ rectangle_vertices = city.extras.get("rectangle_vertices") or [(0.0, 0.0)] * 4
509
+ lc_source = land_cover_source or city.extras.get("land_cover_source", "Standard")
510
+
507
511
  # Prepare grids
508
512
  building_height_grid_inx, building_id_grid, land_cover_veg_grid_inx, land_cover_mat_grid_inx, canopy_height_grid_inx, dem_grid_inx = prepare_grids(
509
- building_height_grid_ori.copy(), building_id_grid_ori.copy(), canopy_height_grid_ori.copy(), land_cover_grid_ori.copy(), dem_grid_ori.copy(), meshsize, land_cover_source)
513
+ city.buildings.heights.copy(),
514
+ (city.buildings.ids if city.buildings.ids is not None else np.zeros_like(city.buildings.heights, dtype=int)).copy(),
515
+ (city.tree_canopy.top if city.tree_canopy is not None else np.zeros_like(city.land_cover.classes, dtype=float)).copy(),
516
+ city.land_cover.classes.copy(),
517
+ city.dem.elevation.copy(),
518
+ meshsize,
519
+ lc_source)
510
520
 
511
521
  # Create XML content
512
522
  xml_content = create_xml_content(building_height_grid_inx, building_id_grid, land_cover_veg_grid_inx, land_cover_mat_grid_inx, canopy_height_grid_inx, dem_grid_inx, meshsize, rectangle_vertices, **kwargs)
513
523
 
514
524
  # Save the output
515
- output_dir = kwargs.get("output_directory", 'output')
525
+ output_dir = output_directory or 'output'
516
526
  os.makedirs(output_dir, exist_ok=True)
517
- file_basename = kwargs.get("file_basename", 'voxcity')
518
527
  output_file_path = os.path.join(output_dir, f"{file_basename}.INX")
519
528
  save_file(xml_content, output_file_path)
520
529
 
530
+
531
+ class EnvimetExporter:
532
+ """Exporter adapter to write a VoxCity model to ENVI-met INX format."""
533
+
534
+ def export(self, obj, output_directory: str, base_filename: str, **kwargs):
535
+ if not isinstance(obj, VoxCity):
536
+ raise TypeError("EnvimetExporter expects a VoxCity instance")
537
+ city: VoxCity = obj
538
+ export_inx(
539
+ city,
540
+ output_directory=output_directory,
541
+ file_basename=base_filename,
542
+ **kwargs,
543
+ )
544
+ return os.path.join(output_directory, f"{base_filename}.INX")
545
+
521
546
  def generate_edb_file(**kwargs):
522
547
  """Generate ENVI-met database file for 3D plants.
523
548
 
@@ -23,7 +23,7 @@ import numpy as np
23
23
  from pyvox.models import Vox
24
24
  from pyvox.writer import VoxWriter
25
25
  import os
26
- from ..utils.visualization import get_voxel_color_map
26
+ from ..visualizer import get_voxel_color_map
27
27
 
28
28
  def convert_colormap_and_array(original_map, original_array):
29
29
  """
@@ -270,14 +270,14 @@ def export_magicavoxel_vox(array, output_dir, base_filename='chunk', voxel_color
270
270
  4. Progress reporting
271
271
 
272
272
  Args:
273
- array (numpy.ndarray): 3D array containing voxel data.
274
- Values should correspond to keys in voxel_color_map.
273
+ array (numpy.ndarray | VoxCity): 3D array containing voxel data or a VoxCity instance.
274
+ When a VoxCity is provided, its voxel classes are exported.
275
275
  output_dir (str): Directory to save the .vox files.
276
276
  Will be created if it doesn't exist.
277
277
  base_filename (str, optional): Base name for the output files.
278
278
  Defaults to 'chunk'. Used when model is split into multiple files.
279
279
  voxel_color_map (dict, optional): Dictionary mapping indices to RGB color values.
280
- If None, uses default color map from utils.visualization.
280
+ If None, uses default color map from visualizer.
281
281
  Each value should be a list of [R,G,B] values (0-255).
282
282
 
283
283
  Note:
@@ -285,6 +285,14 @@ def export_magicavoxel_vox(array, output_dir, base_filename='chunk', voxel_color
285
285
  - Color mapping is optimized and made sequential
286
286
  - Progress information is printed to stdout
287
287
  """
288
+ # Accept VoxCity instance as first argument
289
+ try:
290
+ from ..models import VoxCity as _VoxCity
291
+ if isinstance(array, _VoxCity):
292
+ array = array.voxels.classes
293
+ except Exception:
294
+ pass
295
+
288
296
  # Use default color map if none provided
289
297
  if voxel_color_map is None:
290
298
  voxel_color_map = get_voxel_color_map()
@@ -294,4 +302,33 @@ def export_magicavoxel_vox(array, output_dir, base_filename='chunk', voxel_color
294
302
 
295
303
  # Export the model and print confirmation
296
304
  value_mapping, palette = export_large_voxel_model(converted_array, converted_voxel_color_map, output_dir, base_filename=base_filename)
297
- print(f"\tvox files was successfully exported in {output_dir}")
305
+ print(f"\tvox files was successfully exported in {output_dir}")
306
+
307
+
308
+ class MagicaVoxelExporter:
309
+ """Exporter adapter to write VoxCity voxels as MagicaVoxel .vox chunks.
310
+
311
+ Accepts either a VoxCity instance (uses `voxels.classes`) or a raw 3D numpy array.
312
+ """
313
+
314
+ def export(self, obj, output_directory: str, base_filename: str, **kwargs):
315
+ import numpy as _np
316
+ os.makedirs(output_directory, exist_ok=True)
317
+ try:
318
+ from ..models import VoxCity as _VoxCity
319
+ if isinstance(obj, _VoxCity):
320
+ export_magicavoxel_vox(
321
+ obj.voxels.classes,
322
+ output_directory,
323
+ base_filename,
324
+ voxel_color_map=kwargs.get("voxel_color_map"),
325
+ )
326
+ return output_directory
327
+ except Exception:
328
+ pass
329
+
330
+ if isinstance(obj, _np.ndarray) and obj.ndim == 3:
331
+ export_magicavoxel_vox(obj, output_directory, base_filename, voxel_color_map=kwargs.get("voxel_color_map"))
332
+ return output_directory
333
+
334
+ raise TypeError("MagicaVoxelExporter.export expects VoxCity or a 3D numpy array")
@@ -38,6 +38,7 @@ except Exception: # pragma: no cover - optional dependency
38
38
  __all__ = [
39
39
  "voxel_to_xarray_dataset",
40
40
  "save_voxel_netcdf",
41
+ "NetCDFExporter",
41
42
  ]
42
43
 
43
44
 
@@ -209,3 +210,29 @@ def save_voxel_netcdf(
209
210
  return str(path)
210
211
 
211
212
 
213
+ class NetCDFExporter:
214
+ """Exporter adapter to write a VoxCity voxel grid to NetCDF."""
215
+
216
+ def export(self, obj, output_directory: str, base_filename: str, **kwargs):
217
+ from ..models import VoxCity
218
+ path = Path(output_directory) / f"{base_filename}.nc"
219
+ if not isinstance(obj, VoxCity):
220
+ raise TypeError("NetCDFExporter expects a VoxCity instance")
221
+ rect = obj.extras.get("rectangle_vertices")
222
+ # Merge default attrs with user-provided extras
223
+ user_extra = kwargs.get("extra_attrs") or {}
224
+ attrs = {
225
+ "land_cover_source": obj.extras.get("land_cover_source", ""),
226
+ "building_source": obj.extras.get("building_source", ""),
227
+ "dem_source": obj.extras.get("dem_source", ""),
228
+ }
229
+ attrs.update(user_extra)
230
+ return save_voxel_netcdf(
231
+ voxcity_grid=obj.voxels.classes,
232
+ output_path=path,
233
+ voxel_size_m=obj.voxels.meta.meshsize,
234
+ rectangle_vertices=rect,
235
+ extra_attrs=attrs,
236
+ engine=kwargs.get("engine"),
237
+ )
238
+