voxcity 0.6.17__tar.gz → 0.6.18__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

Files changed (38) hide show
  1. {voxcity-0.6.17 → voxcity-0.6.18}/PKG-INFO +4 -2
  2. {voxcity-0.6.17 → voxcity-0.6.18}/README.md +1 -1
  3. {voxcity-0.6.17 → voxcity-0.6.18}/pyproject.toml +3 -1
  4. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/__init__.py +2 -1
  5. voxcity-0.6.18/src/voxcity/exporter/netcdf.py +211 -0
  6. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/obj.py +538 -1
  7. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/visualization.py +31 -0
  8. {voxcity-0.6.17 → voxcity-0.6.18}/AUTHORS.rst +0 -0
  9. {voxcity-0.6.17 → voxcity-0.6.18}/LICENSE +0 -0
  10. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/__init__.py +0 -0
  11. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/__init__.py +0 -0
  12. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/citygml.py +0 -0
  13. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/eubucco.py +0 -0
  14. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/gee.py +0 -0
  15. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/mbfp.py +0 -0
  16. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/oemj.py +0 -0
  17. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/osm.py +0 -0
  18. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/overture.py +0 -0
  19. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/utils.py +0 -0
  20. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/cityles.py +0 -0
  21. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/envimet.py +0 -0
  22. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/magicavoxel.py +0 -0
  23. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/generator.py +0 -0
  24. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/__init__.py +0 -0
  25. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/draw.py +0 -0
  26. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/grid.py +0 -0
  27. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/mesh.py +0 -0
  28. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/network.py +0 -0
  29. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/polygon.py +0 -0
  30. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/utils.py +0 -0
  31. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/__init__.py +0 -0
  32. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/solar.py +0 -0
  33. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/utils.py +0 -0
  34. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/view.py +0 -0
  35. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/__init__.py +0 -0
  36. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/lc.py +0 -0
  37. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/material.py +0 -0
  38. {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/weather.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: voxcity
3
- Version: 0.6.17
3
+ Version: 0.6.18
4
4
  Summary: voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data
5
5
  License: MIT
6
6
  Author: Kunihiko Fujiwara
@@ -27,6 +27,7 @@ Requires-Dist: ipyleaflet
27
27
  Requires-Dist: joblib
28
28
  Requires-Dist: lxml
29
29
  Requires-Dist: matplotlib
30
+ Requires-Dist: netCDF4
30
31
  Requires-Dist: numba
31
32
  Requires-Dist: numpy
32
33
  Requires-Dist: osmnx
@@ -51,6 +52,7 @@ Requires-Dist: timezonefinder
51
52
  Requires-Dist: tqdm
52
53
  Requires-Dist: trimesh
53
54
  Requires-Dist: typer
55
+ Requires-Dist: xarray
54
56
  Project-URL: bugs, https://github.com/kunifujiwara/voxcity/issues
55
57
  Project-URL: changelog, https://github.com/kunifujiwara/voxcity/blob/master/changelog.md
56
58
  Project-URL: homepage, https://github.com/kunifujiwara/voxcity
@@ -232,7 +234,7 @@ Generate voxel data grids and corresponding building geoJSON:
232
234
  from voxcity.generator import get_voxcity
233
235
 
234
236
  voxcity_grid, building_height_grid, building_min_height_grid, \
235
- building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, \
237
+ building_id_grid, canopy_height_grid, canopy_bottom_height_grid, land_cover_grid, dem_grid, \
236
238
  building_gdf = get_voxcity(
237
239
  rectangle_vertices,
238
240
  building_source,
@@ -174,7 +174,7 @@ Generate voxel data grids and corresponding building geoJSON:
174
174
  from voxcity.generator import get_voxcity
175
175
 
176
176
  voxcity_grid, building_height_grid, building_min_height_grid, \
177
- building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, \
177
+ building_id_grid, canopy_height_grid, canopy_bottom_height_grid, land_cover_grid, dem_grid, \
178
178
  building_gdf = get_voxcity(
179
179
  rectangle_vertices,
180
180
  building_source,
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "voxcity"
3
- version = "0.6.17"
3
+ version = "0.6.18"
4
4
  description = "voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -56,6 +56,8 @@ pyvista = "*"
56
56
  IPython = "*"
57
57
  lxml = "*"
58
58
  scikit-learn = "*"
59
+ xarray = "*"
60
+ netCDF4 = "*"
59
61
 
60
62
  [tool.poetry.group.dev.dependencies]
61
63
  coverage = "*"
@@ -1,4 +1,5 @@
1
1
  from .envimet import *
2
2
  from .magicavoxel import *
3
3
  from .obj import *
4
- from .cityles import *
4
+ from .cityles import *
5
+ from .netcdf import *
@@ -0,0 +1,211 @@
1
+ """
2
+ NetCDF export utilities for VoxCity.
3
+
4
+ This module provides functions to convert a 3D voxel grid produced by
5
+ `voxcity.generator.get_voxcity` into a NetCDF file for portable storage
6
+ and downstream analysis.
7
+
8
+ The voxel values follow VoxCity conventions (see generator.create_3d_voxel):
9
+ - -3: built structures (buildings)
10
+ - -2: vegetation canopy
11
+ - -1: subsurface/underground
12
+ - >= 1: ground-surface land cover code (offset by +1 from source classes)
13
+
14
+ Notes
15
+ -----
16
+ - This writer prefers xarray for NetCDF export. If xarray is not installed,
17
+ a clear error is raised with installation hints.
18
+ - Coordinates are stored as index-based distances in meters from the grid
19
+ origin along the y, x, and z axes. Geographic metadata such as the
20
+ `rectangle_vertices` and `meshsize_m` are stored as global attributes to
21
+ avoid making assumptions about map projection or geodesic conversions.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+ from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence, Tuple
28
+ import json
29
+
30
+ import numpy as np
31
+
32
+ try: # xarray is the preferred backend for NetCDF writing
33
+ import xarray as xr # type: ignore
34
+ XR_AVAILABLE = True
35
+ except Exception: # pragma: no cover - optional dependency
36
+ XR_AVAILABLE = False
37
+
38
+ __all__ = [
39
+ "voxel_to_xarray_dataset",
40
+ "save_voxel_netcdf",
41
+ ]
42
+
43
+
44
+ def _ensure_parent_dir(path: Path) -> None:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+
47
+
48
+ def voxel_to_xarray_dataset(
49
+ voxcity_grid: np.ndarray,
50
+ voxel_size_m: float,
51
+ rectangle_vertices: Optional[Sequence[Tuple[float, float]]] = None,
52
+ extra_attrs: Optional[Mapping[str, Any]] = None,
53
+ ) -> "xr.Dataset":
54
+ """Create an xarray Dataset from a VoxCity voxel grid.
55
+
56
+ Parameters
57
+ ----------
58
+ voxcity_grid
59
+ 3D numpy array with shape (rows, cols, levels) as returned by
60
+ `get_voxcity` (first element of the returned tuple).
61
+ voxel_size_m
62
+ Voxel size (mesh size) in meters.
63
+ rectangle_vertices
64
+ Optional polygon vertices defining the area of interest in
65
+ longitude/latitude pairs, typically the same list passed to
66
+ `get_voxcity`.
67
+ extra_attrs
68
+ Optional mapping of additional global attributes to store in the
69
+ dataset.
70
+
71
+ Returns
72
+ -------
73
+ xr.Dataset
74
+ Dataset containing one DataArray named "voxels" with dims (y, x, z)
75
+ and coordinate variables in meters from origin.
76
+ """
77
+ if not XR_AVAILABLE: # pragma: no cover - optional dependency
78
+ raise ImportError(
79
+ "xarray is required to export NetCDF. Install with: \n"
80
+ " pip install xarray netCDF4\n"
81
+ "or: \n"
82
+ " pip install xarray h5netcdf"
83
+ )
84
+
85
+ if voxcity_grid.ndim != 3:
86
+ raise ValueError(
87
+ f"voxcity_grid must be 3D (rows, cols, levels); got shape={voxcity_grid.shape}"
88
+ )
89
+
90
+ rows, cols, levels = voxcity_grid.shape
91
+
92
+ # Coordinate vectors in meters relative to the grid origin
93
+ # y increases with row index, x increases with column index
94
+ y_m = np.arange(rows, dtype=float) * float(voxel_size_m)
95
+ x_m = np.arange(cols, dtype=float) * float(voxel_size_m)
96
+ z_m = np.arange(levels, dtype=float) * float(voxel_size_m)
97
+
98
+ ds_attrs: MutableMapping[str, Any] = {
99
+ "title": "VoxCity voxel grid",
100
+ "institution": "VoxCity",
101
+ "source": "voxcity.generator.create_3d_voxel",
102
+ "Conventions": "CF-1.10 (partial)",
103
+ # NetCDF attributes must be basic types; serialize complex structures as strings
104
+ "vox_value_meanings": [
105
+ "-3: building",
106
+ "-2: vegetation_canopy",
107
+ "-1: subsurface",
108
+ ">=1: surface_land_cover_code (offset +1)",
109
+ ],
110
+ "meshsize_m": float(voxel_size_m),
111
+ # Store vertices as JSON string for portability
112
+ "rectangle_vertices_lonlat_json": (
113
+ json.dumps([[float(v[0]), float(v[1])] for v in rectangle_vertices])
114
+ if rectangle_vertices is not None else ""
115
+ ),
116
+ "vertical_reference": "z=0 corresponds to min(DEM) as used in voxel construction",
117
+ }
118
+ if extra_attrs:
119
+ ds_attrs.update(dict(extra_attrs))
120
+
121
+ da = xr.DataArray(
122
+ voxcity_grid,
123
+ dims=("y", "x", "z"),
124
+ coords={
125
+ "y": ("y", y_m, {"units": "m", "long_name": "row_distance_from_origin"}),
126
+ "x": ("x", x_m, {"units": "m", "long_name": "col_distance_from_origin"}),
127
+ "z": ("z", z_m, {"units": "m", "positive": "up", "long_name": "height_above_vertical_origin"}),
128
+ },
129
+ name="voxels",
130
+ attrs={
131
+ "units": "category",
132
+ "description": "VoxCity voxel values; see global attribute 'vox_value_meanings'",
133
+ },
134
+ )
135
+
136
+ ds = xr.Dataset({"voxels": da}, attrs=ds_attrs)
137
+ return ds
138
+
139
+
140
+ def save_voxel_netcdf(
141
+ voxcity_grid: np.ndarray,
142
+ output_path: str | Path,
143
+ voxel_size_m: float,
144
+ rectangle_vertices: Optional[Sequence[Tuple[float, float]]] = None,
145
+ extra_attrs: Optional[Mapping[str, Any]] = None,
146
+ engine: Optional[str] = None,
147
+ ) -> str:
148
+ """Save a VoxCity voxel grid to a NetCDF file.
149
+
150
+ Parameters
151
+ ----------
152
+ voxcity_grid
153
+ 3D numpy array (rows, cols, levels) of voxel values.
154
+ output_path
155
+ Path to the NetCDF file to be written. Parent directories will be
156
+ created as needed.
157
+ voxel_size_m
158
+ Voxel size in meters.
159
+ rectangle_vertices
160
+ Optional list of (lon, lat) pairs defining the area of interest.
161
+ Stored as dataset metadata only.
162
+ extra_attrs
163
+ Optional additional global attributes to embed in the dataset.
164
+ engine
165
+ Optional xarray engine, e.g., "netcdf4" or "h5netcdf". If not provided,
166
+ xarray will choose a default; on failure we retry alternate engines.
167
+
168
+ Returns
169
+ -------
170
+ str
171
+ The string path to the written NetCDF file.
172
+ """
173
+ if not XR_AVAILABLE: # pragma: no cover - optional dependency
174
+ raise ImportError(
175
+ "xarray is required to export NetCDF. Install with: \n"
176
+ " pip install xarray netCDF4\n"
177
+ "or: \n"
178
+ " pip install xarray h5netcdf"
179
+ )
180
+
181
+ path = Path(output_path)
182
+ _ensure_parent_dir(path)
183
+
184
+ ds = voxel_to_xarray_dataset(
185
+ voxcity_grid=voxcity_grid,
186
+ voxel_size_m=voxel_size_m,
187
+ rectangle_vertices=rectangle_vertices,
188
+ extra_attrs=extra_attrs,
189
+ )
190
+
191
+ # Attempt to save with the requested or default engine; on failure, try a fallback
192
+ tried_engines = []
193
+ try:
194
+ ds.to_netcdf(path, engine=engine) # type: ignore[call-arg]
195
+ except Exception as e_first: # pragma: no cover - I/O backend dependent
196
+ tried_engines.append(engine or "default")
197
+ for fallback in ("netcdf4", "h5netcdf"):
198
+ try:
199
+ ds.to_netcdf(path, engine=fallback) # type: ignore[call-arg]
200
+ break
201
+ except Exception:
202
+ tried_engines.append(fallback)
203
+ else:
204
+ raise RuntimeError(
205
+ f"Failed to write NetCDF using engines: {tried_engines}. "
206
+ f"Original error: {e_first}"
207
+ )
208
+
209
+ return str(path)
210
+
211
+
@@ -652,4 +652,541 @@ def grid_to_obj(value_array_ori, dem_array_ori, output_dir, file_name, cell_size
652
652
  f.write(f'd {a:.6f}\n') # Transparency (alpha)
653
653
  f.write('\n')
654
654
 
655
- print(f'OBJ and MTL files have been generated in {output_dir} with the base name "{file_name}".')
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
+ opacity_points=None,
673
+ max_opacity=0.10,
674
+ classes_to_show=None,
675
+ voxel_color_scheme="default",
676
+ max_faces_warn=1_000_000,
677
+ ):
678
+ """
679
+ Export two OBJ/MTL files using the same local meter frame:
680
+ - VoxCity voxels: opaque, per-class color, fixed face winding and normals
681
+ - Scalar iso-surfaces: colormap colors with variable transparency
682
+
683
+ The two outputs share the same XY origin and axes (X east, Y north, Z up),
684
+ anchored at the minimum lon/lat of the VoxCity bounding rectangle.
685
+
686
+ Args:
687
+ voxcity_nc (str): Path to VoxCity NetCDF (must include variable 'voxels' and coords 'x','y','z').
688
+ scalar_nc (str): Path to scalar NetCDF containing variable specified by scalar_var.
689
+ lonlat_txt (str): Text file with columns: i j lon lat (1-based indices) describing the scalar grid georef.
690
+ output_dir (str): Directory to write results.
691
+ vox_base_filename (str): Base filename for VoxCity OBJ/MTL.
692
+ tm_base_filename (str): Base filename for scalar iso-surfaces OBJ/MTL.
693
+ scalar_var (str): Name of scalar variable in scalar_nc.
694
+ scalar_building_value (float): Value used in scalar field to mark buildings (to be masked).
695
+ scalar_building_tol (float): Tolerance for building masking (isclose).
696
+ stride_vox (tuple[int,int,int]): Downsampling strides for VoxCity (z,y,x) in voxels.
697
+ stride_scalar (tuple[int,int,int]): Downsampling strides for scalar (k,j,i).
698
+ contour_levels (int): Number of iso-surface levels between vmin and vmax.
699
+ cmap_name (str): Matplotlib colormap name for iso-surfaces.
700
+ opacity_points (list[tuple[float,float]]|None): Transfer function control points (value, alpha in [0..1]).
701
+ max_opacity (float): Global max opacity multiplier for iso-surfaces (0..1).
702
+ classes_to_show (set[int]|None): Optional subset of voxel classes to export; None -> all present (except 0).
703
+ voxel_color_scheme (str): Color scheme name passed to get_voxel_color_map.
704
+ max_faces_warn (int): Warn if a single class exceeds this many faces.
705
+
706
+ Returns:
707
+ dict: Paths of written files: keys 'vox_obj','vox_mtl','tm_obj','tm_mtl' (values may be None).
708
+ """
709
+ import json
710
+ import numpy as np
711
+ import os
712
+ import xarray as xr
713
+ import trimesh
714
+
715
+ try:
716
+ from skimage import measure as skim
717
+ except Exception as e: # pragma: no cover - optional dependency
718
+ raise ImportError(
719
+ "scikit-image is required for iso-surface generation. Install 'scikit-image'."
720
+ ) from e
721
+
722
+ from matplotlib import cm
723
+
724
+ if opacity_points is None:
725
+ opacity_points = [(-0.2, 0.00), (2.0, 1.00)]
726
+
727
+ def find_dims(ds):
728
+ lvl = ["k", "level", "lev", "z", "height", "alt", "plev"]
729
+ yy = ["j", "y", "south_north", "lat", "latitude"]
730
+ xx = ["i", "x", "west_east", "lon", "longitude"]
731
+ tt = ["time", "Times"]
732
+
733
+ def pick(cands):
734
+ for c in cands:
735
+ if c in ds.dims:
736
+ return c
737
+ return None
738
+
739
+ t = pick(tt)
740
+ k = pick(lvl)
741
+ j = pick(yy)
742
+ i = pick(xx)
743
+ if (k is None or j is None or i is None) and len(ds.dims) >= 3:
744
+ dims = list(ds.dims)
745
+ k = k or dims[0]
746
+ j = j or dims[-2]
747
+ i = i or dims[-1]
748
+ return t, k, j, i
749
+
750
+ def squeeze_to_kji(da, tname, kname, jname, iname, time_index=0):
751
+ if tname and tname in da.dims:
752
+ da = da.isel({tname: time_index})
753
+ for d in list(da.dims):
754
+ if d not in (kname, jname, iname):
755
+ da = da.isel({d: 0})
756
+ return da.transpose(*(d for d in (kname, jname, iname) if d in da.dims))
757
+
758
+ def downsample3(a, sk, sj, si):
759
+ return a[:: max(1, sk), :: max(1, sj), :: max(1, si)]
760
+
761
+ def clip_minmax(arr, frac):
762
+ v = np.asarray(arr)
763
+ v = v[np.isfinite(v)]
764
+ if v.size == 0:
765
+ return 0.0, 1.0
766
+ if frac <= 0:
767
+ return float(np.nanmin(v)), float(np.nanmax(v))
768
+ vmin_ = float(np.nanpercentile(v, 100 * frac))
769
+ vmax_ = float(np.nanpercentile(v, 100 * (1 - frac)))
770
+ if vmin_ >= vmax_:
771
+ vmin_, vmax_ = float(np.nanmin(v)), float(np.nanmax(v))
772
+ return vmin_, vmax_
773
+
774
+ def meters_per_degree(lat_rad):
775
+ 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)
776
+ m_per_deg_lon = 111412.84 * np.cos(lat_rad) - 93.5 * np.cos(3 * lat_rad) + 0.118 * np.cos(5 * lat_rad)
777
+ return m_per_deg_lat, m_per_deg_lon
778
+
779
+ def opacity_at(v, points):
780
+ if not points:
781
+ return 0.0 if np.isscalar(v) else np.zeros_like(v)
782
+ pts = sorted((float(x), float(a)) for x, a in points)
783
+ xs = np.array([p[0] for p in pts], dtype=float)
784
+ as_ = np.array([p[1] for p in pts], dtype=float)
785
+ v_arr = np.asarray(v, dtype=float)
786
+ out = np.empty_like(v_arr, dtype=float)
787
+ out[v_arr <= xs[0]] = as_[0]
788
+ out[v_arr >= xs[-1]] = as_[-1]
789
+ idx = np.searchsorted(xs, v_arr, side="right") - 1
790
+ idx = np.clip(idx, 0, len(xs) - 2)
791
+ x0, x1 = xs[idx], xs[idx + 1]
792
+ a0, a1 = as_[idx], as_[idx + 1]
793
+ t = np.where(x1 > x0, (v_arr - x0) / (x1 - x0), 0.0)
794
+ mid = (v_arr > xs[0]) & (v_arr < xs[-1])
795
+ out[mid] = a0[mid] + t[mid] * (a1[mid] - a0[mid])
796
+ return out.item() if np.isscalar(v) else out
797
+
798
+ def _exposed_face_masks(occ):
799
+ K, J, I = occ.shape
800
+ p = np.pad(occ, ((0, 0), (0, 0), (0, 1)), constant_values=False)
801
+ posx = occ & (~p[..., 1:])
802
+ p = np.pad(occ, ((0, 0), (0, 0), (1, 0)), constant_values=False)
803
+ negx = occ & (~p[..., :-1])
804
+ p = np.pad(occ, ((0, 0), (0, 1), (0, 0)), constant_values=False)
805
+ posy = occ & (~p[:, 1:, :])
806
+ p = np.pad(occ, ((0, 0), (1, 0), (0, 0)), constant_values=False)
807
+ negy = occ & (~p[:, :-1, :])
808
+ p = np.pad(occ, ((0, 1), (0, 0), (0, 0)), constant_values=False)
809
+ posz = occ & (~p[1:, :, :])
810
+ p = np.pad(occ, ((1, 0), (0, 0), (0, 0)), constant_values=False)
811
+ negz = occ & (~p[:-1, :, :])
812
+ return posx, negx, posy, negy, posz, negz
813
+
814
+ def _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx):
815
+ N = k.size
816
+ if N == 0:
817
+ return np.empty((0, 3)), np.empty((0, 3), dtype=np.int64), start_idx
818
+
819
+ dx = (X[1] - X[0]) if len(X) > 1 else 1.0
820
+ dy = (Y[1] - Y[0]) if len(Y) > 1 else 1.0
821
+ dz = (Z[1] - Z[0]) if len(Z) > 1 else 1.0
822
+
823
+ x = X[i].astype(np.float64)
824
+ y = Y[j].astype(np.float64)
825
+ z = Z[k].astype(np.float64)
826
+ hx, hy, hz = dx / 2.0, dy / 2.0, dz / 2.0
827
+
828
+ if plane == "+x":
829
+ P = np.column_stack([x + hx, y - hy, z - hz])
830
+ Q = np.column_stack([x + hx, y + hy, z - hz])
831
+ R = np.column_stack([x + hx, y + hy, z + hz])
832
+ S = np.column_stack([x + hx, y - hy, z + hz])
833
+ order = "default"
834
+ elif plane == "-x":
835
+ P = np.column_stack([x - hx, y - hy, z + hz])
836
+ Q = np.column_stack([x - hx, y + hy, z + hz])
837
+ R = np.column_stack([x - hx, y + hy, z - hz])
838
+ S = np.column_stack([x - hx, y - hy, z - hz])
839
+ order = "default"
840
+ elif plane == "+y":
841
+ P = np.column_stack([x - hx, y + hy, z - hz])
842
+ Q = np.column_stack([x + hx, y + hy, z - hz])
843
+ R = np.column_stack([x + hx, y + hy, z + hz])
844
+ S = np.column_stack([x - hx, y + hy, z + hz])
845
+ order = "flip" # enforce outward normals
846
+ elif plane == "-y":
847
+ P = np.column_stack([x - hx, y - hy, z + hz])
848
+ Q = np.column_stack([x + hx, y - hy, z + hz])
849
+ R = np.column_stack([x + hx, y - hy, z - hz])
850
+ S = np.column_stack([x - hx, y - hy, z - hz])
851
+ order = "flip"
852
+ elif plane == "+z":
853
+ P = np.column_stack([x - hx, y - hy, z + hz])
854
+ Q = np.column_stack([x + hx, y - hy, z + hz])
855
+ R = np.column_stack([x + hx, y + hy, z + hz])
856
+ S = np.column_stack([x - hx, y + hy, z + hz])
857
+ order = "default"
858
+ else: # "-z"
859
+ P = np.column_stack([x - hx, y + hy, z - hz])
860
+ Q = np.column_stack([x + hx, y + hy, z - hz])
861
+ R = np.column_stack([x + hx, y - hy, z - hz])
862
+ S = np.column_stack([x - hx, y - hy, z - hz])
863
+ order = "default"
864
+
865
+ verts = np.vstack([P, Q, R, S])
866
+ a = np.arange(N, dtype=np.int64) + start_idx
867
+ b = a + N
868
+ c = a + 2 * N
869
+ d = a + 3 * N
870
+
871
+ if order == "default":
872
+ tris = np.vstack([np.column_stack([a, b, c]), np.column_stack([a, c, d])])
873
+ else:
874
+ tris = np.vstack([np.column_stack([a, c, b]), np.column_stack([a, d, c])])
875
+
876
+ return verts, tris, start_idx + 4 * N
877
+
878
+ def make_voxel_mesh_uniform_color(occ_mask, X, Y, Z, rgb, name="class"):
879
+ posx, negx, posy, negy, posz, negz = _exposed_face_masks(occ_mask.astype(bool))
880
+ total_faces = int(posx.sum() + negx.sum() + posy.sum() + negy.sum() + posz.sum() + negz.sum())
881
+ if total_faces == 0:
882
+ return None, 0
883
+ if total_faces > max_faces_warn:
884
+ print(f" Warning: {name} faces={total_faces:,} (> {max_faces_warn:,}). Consider increasing stride.")
885
+
886
+ verts_all, tris_all, start_idx = [], [], 0
887
+ for plane, mask in (("+x", posx), ("-x", negx), ("+y", posy), ("-y", negy), ("+z", posz), ("-z", negz)):
888
+ idx = np.argwhere(mask)
889
+ if idx.size == 0:
890
+ continue
891
+ k, j, i = idx[:, 0], idx[:, 1], idx[:, 2]
892
+ Vp, Tp, start_idx = _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx)
893
+ verts_all.append(Vp)
894
+ tris_all.append(Tp)
895
+
896
+ V = np.vstack(verts_all)
897
+ F = np.vstack(tris_all)
898
+ mesh = trimesh.Trimesh(vertices=V, faces=F, process=False)
899
+ rgba = np.array([int(rgb[0]), int(rgb[1]), int(rgb[2]), 255], dtype=np.uint8)
900
+ mesh.visual.face_colors = np.tile(rgba, (len(F), 1))
901
+ return mesh, len(F)
902
+
903
+ def build_tm_isosurfaces_regular_grid(A_scalar, vmin, vmax, levels, dx, dy, dz, origin_xyz, cmap_name, opacity_points, max_opacity):
904
+ cmap = cm.get_cmap(cmap_name)
905
+ meshes = []
906
+ if levels <= 0:
907
+ return meshes
908
+ iso_vals = np.linspace(vmin, vmax, int(levels))
909
+ for iso in iso_vals:
910
+ a_base = float(opacity_at(iso, opacity_points or []))
911
+ a_base = min(max(a_base, 0.0), 1.0)
912
+ alpha = a_base * max_opacity
913
+ if alpha <= 0.0:
914
+ continue
915
+ try:
916
+ verts, faces, _, _ = skim.marching_cubes(A_scalar, level=iso, spacing=(dz, dy, dx))
917
+ except Exception:
918
+ continue
919
+ if len(verts) == 0 or len(faces) == 0:
920
+ continue
921
+ V = verts[:, [2, 1, 0]].astype(np.float64)
922
+ V += np.array(origin_xyz, dtype=np.float64)[None, :]
923
+ m = trimesh.Trimesh(vertices=V, faces=faces.astype(np.int64), process=False)
924
+ t = 0.0 if vmax <= vmin else (iso - vmin) / (vmax - vmin)
925
+ r, g, b, _ = cmap(np.clip(t, 0.0, 1.0))
926
+ rgba = (
927
+ int(round(255 * r)),
928
+ int(round(255 * g)),
929
+ int(round(255 * b)),
930
+ int(round(255 * alpha)),
931
+ )
932
+ m.visual.face_colors = np.tile(np.array(rgba, dtype=np.uint8), (len(m.faces), 1))
933
+ meshes.append((iso, m, rgba))
934
+ print(f"Iso {iso:.4f}: faces={len(m.faces):,}, alpha={alpha:.4f}")
935
+ return meshes
936
+
937
+ def save_obj_with_mtl_and_normals(meshes_dict, output_path, base_filename):
938
+ os.makedirs(output_path, exist_ok=True)
939
+ obj_path = os.path.join(output_path, f"{base_filename}.obj")
940
+ mtl_path = os.path.join(output_path, f"{base_filename}.mtl")
941
+
942
+ def to_uint8_rgba(arr):
943
+ arr = np.asarray(arr)
944
+ if arr.dtype != np.uint8:
945
+ if arr.dtype.kind == "f":
946
+ arr = np.clip(arr, 0.0, 1.0)
947
+ arr = (arr * 255.0 + 0.5).astype(np.uint8)
948
+ else:
949
+ arr = arr.astype(np.uint8)
950
+ if arr.shape[1] == 3:
951
+ arr = np.concatenate([arr, np.full((arr.shape[0], 1), 255, np.uint8)], axis=1)
952
+ return arr
953
+
954
+ color_to_id, ordered = {}, []
955
+
956
+ def mid_of(rgba):
957
+ if rgba not in color_to_id:
958
+ color_to_id[rgba] = len(ordered)
959
+ ordered.append(rgba)
960
+ return color_to_id[rgba]
961
+
962
+ for m in meshes_dict.values():
963
+ fc = getattr(m.visual, "face_colors", None)
964
+ if fc is None or len(fc) == 0:
965
+ mid_of((200, 200, 200, 255))
966
+ continue
967
+ for rgba in np.unique(to_uint8_rgba(fc), axis=0):
968
+ mid_of(tuple(int(x) for x in rgba.tolist()))
969
+
970
+ with open(mtl_path, "w") as mtl:
971
+ for i, (r, g, b, a) in enumerate(ordered):
972
+ kd = (r / 255.0, g / 255.0, b / 255.0)
973
+ ka = kd
974
+ dval = a / 255.0
975
+ tr = max(0.0, min(1.0, 1.0 - dval))
976
+ mtl.write(f"newmtl material_{i}\n")
977
+ mtl.write(f"Kd {kd[0]:.6f} {kd[1]:.6f} {kd[2]:.6f}\n")
978
+ mtl.write(f"Ka {ka[0]:.6f} {ka[1]:.6f} {ka[2]:.6f}\n")
979
+ mtl.write("Ks 0.000000 0.000000 0.000000\n")
980
+ mtl.write("Ns 0.000000\n")
981
+ mtl.write("illum 1\n")
982
+ mtl.write(f"d {dval:.6f}\n")
983
+ mtl.write(f"Tr {tr:.6f}\n\n")
984
+
985
+ def face_normals(V, F):
986
+ v0, v1, v2 = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
987
+ n = np.cross(v1 - v0, v2 - v0)
988
+ L = np.linalg.norm(n, axis=1)
989
+ mask = L > 0
990
+ n[mask] /= L[mask][:, None]
991
+ if (~mask).any():
992
+ n[~mask] = np.array([0.0, 0.0, 1.0])
993
+ return n
994
+
995
+ with open(obj_path, "w") as obj:
996
+ obj.write(f"mtllib {os.path.basename(mtl_path)}\n")
997
+ v_offset = 0
998
+ n_offset = 0
999
+ for name, m in meshes_dict.items():
1000
+ V = np.asarray(m.vertices, dtype=np.float64)
1001
+ F = np.asarray(m.faces, dtype=np.int64)
1002
+ if len(V) == 0 or len(F) == 0:
1003
+ continue
1004
+ obj.write(f"o {name}\n")
1005
+ obj.write("s off\n")
1006
+ for vx, vy, vz in V:
1007
+ obj.write(f"v {vx:.6f} {vy:.6f} {vz:.6f}\n")
1008
+
1009
+ fc = getattr(m.visual, "face_colors", None)
1010
+ if fc is None or len(fc) != len(F):
1011
+ fc = np.tile(np.array([200, 200, 200, 255], dtype=np.uint8), (len(F), 1))
1012
+ else:
1013
+ fc = to_uint8_rgba(fc)
1014
+ uniq, inv = np.unique(fc, axis=0, return_inverse=True)
1015
+ color2mid = {tuple(int(x) for x in c.tolist()): mid_of(tuple(int(x) for x in c.tolist())) for c in uniq}
1016
+
1017
+ FN = face_normals(V, F)
1018
+ for nx, ny, nz in FN:
1019
+ obj.write(f"vn {float(nx):.6f} {float(ny):.6f} {float(nz):.6f}\n")
1020
+
1021
+ current_mid = None
1022
+ for i_face, face in enumerate(F):
1023
+ key = tuple(int(x) for x in uniq[inv[i_face]].tolist())
1024
+ mid = color2mid[key]
1025
+ if current_mid != mid:
1026
+ obj.write(f"usemtl material_{mid}\n")
1027
+ current_mid = mid
1028
+ a, b, c = face + 1 + v_offset
1029
+ ni = n_offset + i_face + 1
1030
+ obj.write(f"f {a}//{ni} {b}//{ni} {c}//{ni}\n")
1031
+
1032
+ v_offset += len(V)
1033
+ n_offset += len(F)
1034
+
1035
+ return obj_path, mtl_path
1036
+
1037
+ # Load VoxCity
1038
+ dsv = xr.open_dataset(voxcity_nc)
1039
+ if "voxels" not in dsv:
1040
+ raise KeyError("'voxels' not found in VoxCity dataset.")
1041
+ dav = dsv["voxels"]
1042
+ if tuple(dav.dims) != ("y", "x", "z") and all(d in dav.dims for d in ("y", "x", "z")):
1043
+ dav = dav.transpose("y", "x", "z")
1044
+
1045
+ Yv = dsv["y"].values.astype(float)
1046
+ Xv = dsv["x"].values.astype(float)
1047
+ Zv = dsv["z"].values.astype(float)
1048
+
1049
+ Av = dav.values # (y,x,z)
1050
+ Av_kji = np.transpose(Av, (2, 0, 1)) # (K=z, J=y, I=x)
1051
+ svz, svy, svx = stride_vox
1052
+ Av_kji = downsample3(Av_kji, svz, svy, svx)
1053
+ # Y flip (north-up)
1054
+ Av_kji = Av_kji[:, ::-1, :]
1055
+
1056
+ Zv_s = Zv[:: max(1, svz)].astype(float)
1057
+ Yv_s = (Yv[:: max(1, svy)] - Yv.min()).astype(float)
1058
+ Xv_s = (Xv[:: max(1, svx)] - Xv.min()).astype(float)
1059
+
1060
+ # Load scalar and georeference using lon/lat table
1061
+ dss = xr.open_dataset(scalar_nc, decode_coords="all", decode_times=True)
1062
+ tname, kname, jname, iname = find_dims(dss)
1063
+ if scalar_var not in dss:
1064
+ raise KeyError(f"{scalar_var} not found in scalar dataset")
1065
+
1066
+ A = squeeze_to_kji(dss[scalar_var], tname, kname, jname, iname).values # (K,J,I)
1067
+ K0, J0, I0 = map(int, A.shape)
1068
+
1069
+ ll = np.loadtxt(lonlat_txt, comments="#")
1070
+ ii = ll[:, 0].astype(int) - 1
1071
+ jj = ll[:, 1].astype(int) - 1
1072
+ lon = ll[:, 2].astype(float)
1073
+ lat = ll[:, 3].astype(float)
1074
+ I_ll = int(ii.max() + 1)
1075
+ J_ll = int(jj.max() + 1)
1076
+ lon_grid = np.full((J_ll, I_ll), np.nan, float)
1077
+ lat_grid = np.full((J_ll, I_ll), np.nan, float)
1078
+ lon_grid[jj, ii] = lon
1079
+ lat_grid[jj, ii] = lat
1080
+
1081
+ Jc = min(J0, J_ll)
1082
+ Ic = min(I0, I_ll)
1083
+ if (Jc != J0) or (Ic != I0):
1084
+ print(
1085
+ f"Warning: scalar (J,I)=({J0},{I0}) vs lonlat ({J_ll},{I_ll}); using common ({Jc},{Ic})."
1086
+ )
1087
+ A = A[:, :Jc, :Ic]
1088
+ lon_grid = lon_grid[:Jc, :Ic]
1089
+ lat_grid = lat_grid[:Jc, :Ic]
1090
+
1091
+ ssk, ssj, ssi = stride_scalar
1092
+ A_s = downsample3(A, ssk, ssj, ssi)
1093
+ lon_s = lon_grid[:: max(1, ssj), :: max(1, ssi)]
1094
+ lat_s = lat_grid[:: max(1, ssj), :: max(1, ssi)]
1095
+ Ks, Js, Is = A_s.shape
1096
+
1097
+ rect = np.array(json.loads(dsv.attrs.get("rectangle_vertices_lonlat_json", "[]")), float)
1098
+ if rect.size == 0:
1099
+ raise RuntimeError("VoxCity attribute 'rectangle_vertices_lonlat_json' missing.")
1100
+ lon0 = float(np.min(rect[:, 0]))
1101
+ lat0 = float(np.min(rect[:, 1]))
1102
+ lat_c = float(np.mean(rect[:, 1]))
1103
+ m_per_deg_lat, m_per_deg_lon = meters_per_degree(np.deg2rad(lat_c))
1104
+ Xs_m = (lon_s - lon0) * m_per_deg_lon
1105
+ Ys_m = (lat_s - lat0) * m_per_deg_lat
1106
+
1107
+ if (kname is not None) and (kname in dss.coords):
1108
+ zc = dss.coords[kname].values
1109
+ if np.issubdtype(zc.dtype, np.number) and zc.ndim == 1 and len(zc) >= Ks:
1110
+ Zk = zc.astype(float)[:: max(1, ssk)][:Ks]
1111
+ else:
1112
+ Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
1113
+ else:
1114
+ Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
1115
+
1116
+ # Mask scalar buildings
1117
+ bmask_scalar = downsample3(
1118
+ np.isclose(A, scalar_building_value, atol=scalar_building_tol), ssk, ssj, ssi
1119
+ )
1120
+ A_s = A_s.astype(float)
1121
+ A_s[bmask_scalar] = np.nan
1122
+
1123
+ finite_vals = A_s[np.isfinite(A_s)]
1124
+ if finite_vals.size == 0:
1125
+ raise RuntimeError("No finite scalar values after masking.")
1126
+ vmin, vmax = clip_minmax(finite_vals, 0.0)
1127
+ A_s[np.isnan(A_s)] = vmin - 1e6
1128
+
1129
+ Xmin, Xmax = np.nanmin(Xs_m), np.nanmax(Xs_m)
1130
+ Ymin, Ymax = np.nanmin(Ys_m), np.nanmax(Ys_m)
1131
+ dx_s = (Xmax - Xmin) / max(1, Is - 1)
1132
+ dy_s = (Ymax - Ymin) / max(1, Js - 1)
1133
+ dz_s = (Zk[-1] - Zk[0]) / max(1, Ks - 1) if Ks > 1 else 1.0
1134
+ origin_xyz = (float(Xmin), float(Ymin), float(Zk[0]))
1135
+
1136
+ vox_meshes = {}
1137
+ tm_meshes = {}
1138
+
1139
+ present = set(np.unique(Av_kji))
1140
+ present.discard(0)
1141
+ if classes_to_show is not None:
1142
+ present &= set(classes_to_show)
1143
+ present = sorted(present)
1144
+
1145
+ faces_total = 0
1146
+ voxel_color_map = get_voxel_color_map(color_scheme=voxel_color_scheme)
1147
+ for cls in present:
1148
+ mask = Av_kji == cls
1149
+ if not np.any(mask):
1150
+ continue
1151
+ rgb = voxel_color_map.get(int(cls), [200, 200, 200])
1152
+ m_cls, faces = make_voxel_mesh_uniform_color(mask, Xv_s, Yv_s, Zv_s, rgb=rgb, name=f"class_{int(cls)}")
1153
+ if m_cls is not None:
1154
+ vox_meshes[f"voxclass_{int(cls)}"] = m_cls
1155
+ faces_total += faces
1156
+ print(f"[VoxCity] total voxel faces: {faces_total:,}")
1157
+
1158
+ iso_meshes = build_tm_isosurfaces_regular_grid(
1159
+ A_scalar=A_s,
1160
+ vmin=vmin,
1161
+ vmax=vmax,
1162
+ levels=contour_levels,
1163
+ dx=dx_s,
1164
+ dy=dy_s,
1165
+ dz=dz_s,
1166
+ origin_xyz=origin_xyz,
1167
+ cmap_name=cmap_name,
1168
+ opacity_points=opacity_points,
1169
+ max_opacity=max_opacity,
1170
+ )
1171
+ for iso, m, rgba in iso_meshes:
1172
+ tm_meshes[f"iso_{iso:.6f}"] = m
1173
+
1174
+ if not vox_meshes and not tm_meshes:
1175
+ raise RuntimeError("Nothing to export.")
1176
+
1177
+ os.makedirs(output_dir, exist_ok=True)
1178
+ obj_vox = mtl_vox = obj_tm = mtl_tm = None
1179
+ if vox_meshes:
1180
+ obj_vox, mtl_vox = save_obj_with_mtl_and_normals(vox_meshes, output_dir, vox_base_filename)
1181
+ if tm_meshes:
1182
+ obj_tm, mtl_tm = save_obj_with_mtl_and_normals(tm_meshes, output_dir, tm_base_filename)
1183
+
1184
+ print("Export finished.")
1185
+ if obj_vox:
1186
+ print(f"VoxCity OBJ: {obj_vox}")
1187
+ print(f"VoxCity MTL: {mtl_vox}")
1188
+ if obj_tm:
1189
+ print(f"Scalar Iso OBJ: {obj_tm}")
1190
+ print(f"Scalar Iso MTL: {mtl_tm}")
1191
+
1192
+ return {"vox_obj": obj_vox, "vox_mtl": mtl_vox, "tm_obj": obj_tm, "tm_mtl": mtl_tm}
@@ -111,6 +111,7 @@ def get_voxel_color_map(color_scheme='default'):
111
111
  - 'pastel': Softer, muted colors for aesthetic appeal
112
112
  - 'dark_mode': Darker colors for dark backgrounds
113
113
  - 'grayscale': Black and white gradient with color accents
114
+ - 'white_mode': Light minimal palette for white backgrounds
114
115
 
115
116
  Thematic Schemes:
116
117
  - 'autumn': Warm reds, oranges, and browns
@@ -329,6 +330,36 @@ def get_voxel_color_map(color_scheme='default'):
329
330
  14: [230, 230, 230], # 'No Data'
330
331
  }
331
332
 
333
+ elif color_scheme == 'white_mode':
334
+ return {
335
+ -99: [0, 0, 0], # void (transparent in rendering)
336
+ -30: [220, 80, 80], # subtle highlight for landmarks
337
+ -17: [250, 250, 250], # plaster (near white)
338
+ -16: [210, 225, 235], # glass (light blue-gray)
339
+ -15: [230, 225, 215], # stone (warm light gray)
340
+ -14: [225, 230, 235], # metal (cool light gray)
341
+ -13: [236, 236, 236], # concrete (very light gray)
342
+ -12: [245, 232, 210], # wood (light beige)
343
+ -11: [235, 210, 205], # brick (light rose)
344
+ -3: [225, 230, 240], # Building (soft blue-gray)
345
+ -2: [190, 210, 190], # Tree (soft green)
346
+ -1: [230, 215, 215], # Underground (soft pinkish)
347
+ 1: [248, 245, 235], # Bareland
348
+ 2: [225, 235, 215], # Rangeland
349
+ 3: [220, 235, 220], # Shrub
350
+ 4: [240, 235, 215], # Agriculture land
351
+ 5: [210, 230, 210], # Tree (ground)
352
+ 6: [245, 250, 235], # Moss and lichen
353
+ 7: [220, 235, 230], # Wet land
354
+ 8: [205, 215, 210], # Mangrove
355
+ 9: [200, 220, 245], # Water (pale blue)
356
+ 10: [252, 252, 252], # Snow and ice (almost white)
357
+ 11: [230, 230, 230], # Developed space
358
+ 12: [210, 210, 215], # Road (light neutral)
359
+ 13: [230, 235, 240], # Building (ground surface)
360
+ 14: [248, 245, 235], # No Data
361
+ }
362
+
332
363
  elif color_scheme == 'autumn':
333
364
  return {
334
365
  -99: [0, 0, 0], # void
File without changes
File without changes