voxcity 0.6.15__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 (78) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/citygml.py +32 -18
  4. voxcity/downloader/gba.py +210 -0
  5. voxcity/downloader/gee.py +5 -1
  6. voxcity/downloader/mbfp.py +1 -1
  7. voxcity/downloader/oemj.py +80 -8
  8. voxcity/downloader/osm.py +23 -7
  9. voxcity/downloader/overture.py +26 -1
  10. voxcity/downloader/utils.py +73 -73
  11. voxcity/errors.py +30 -0
  12. voxcity/exporter/__init__.py +13 -4
  13. voxcity/exporter/cityles.py +633 -535
  14. voxcity/exporter/envimet.py +728 -708
  15. voxcity/exporter/magicavoxel.py +334 -297
  16. voxcity/exporter/netcdf.py +238 -0
  17. voxcity/exporter/obj.py +1481 -655
  18. voxcity/generator/__init__.py +44 -0
  19. voxcity/generator/api.py +675 -0
  20. voxcity/generator/grids.py +379 -0
  21. voxcity/generator/io.py +94 -0
  22. voxcity/generator/pipeline.py +282 -0
  23. voxcity/generator/voxelizer.py +380 -0
  24. voxcity/geoprocessor/__init__.py +75 -6
  25. voxcity/geoprocessor/conversion.py +153 -0
  26. voxcity/geoprocessor/draw.py +62 -12
  27. voxcity/geoprocessor/heights.py +199 -0
  28. voxcity/geoprocessor/io.py +101 -0
  29. voxcity/geoprocessor/merge_utils.py +91 -0
  30. voxcity/geoprocessor/mesh.py +806 -790
  31. voxcity/geoprocessor/network.py +708 -679
  32. voxcity/geoprocessor/overlap.py +84 -0
  33. voxcity/geoprocessor/raster/__init__.py +82 -0
  34. voxcity/geoprocessor/raster/buildings.py +428 -0
  35. voxcity/geoprocessor/raster/canopy.py +258 -0
  36. voxcity/geoprocessor/raster/core.py +150 -0
  37. voxcity/geoprocessor/raster/export.py +93 -0
  38. voxcity/geoprocessor/raster/landcover.py +156 -0
  39. voxcity/geoprocessor/raster/raster.py +110 -0
  40. voxcity/geoprocessor/selection.py +85 -0
  41. voxcity/geoprocessor/utils.py +18 -14
  42. voxcity/models.py +113 -0
  43. voxcity/simulator/common/__init__.py +22 -0
  44. voxcity/simulator/common/geometry.py +98 -0
  45. voxcity/simulator/common/raytracing.py +450 -0
  46. voxcity/simulator/solar/__init__.py +43 -0
  47. voxcity/simulator/solar/integration.py +336 -0
  48. voxcity/simulator/solar/kernels.py +62 -0
  49. voxcity/simulator/solar/radiation.py +648 -0
  50. voxcity/simulator/solar/temporal.py +434 -0
  51. voxcity/simulator/view.py +36 -2286
  52. voxcity/simulator/visibility/__init__.py +29 -0
  53. voxcity/simulator/visibility/landmark.py +392 -0
  54. voxcity/simulator/visibility/view.py +508 -0
  55. voxcity/utils/logging.py +61 -0
  56. voxcity/utils/orientation.py +51 -0
  57. voxcity/utils/weather/__init__.py +26 -0
  58. voxcity/utils/weather/epw.py +146 -0
  59. voxcity/utils/weather/files.py +36 -0
  60. voxcity/utils/weather/onebuilding.py +486 -0
  61. voxcity/visualizer/__init__.py +24 -0
  62. voxcity/visualizer/builder.py +43 -0
  63. voxcity/visualizer/grids.py +141 -0
  64. voxcity/visualizer/maps.py +187 -0
  65. voxcity/visualizer/palette.py +228 -0
  66. voxcity/visualizer/renderer.py +928 -0
  67. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
  68. voxcity-0.7.0.dist-info/RECORD +77 -0
  69. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
  70. voxcity/generator.py +0 -1137
  71. voxcity/geoprocessor/grid.py +0 -1568
  72. voxcity/geoprocessor/polygon.py +0 -1344
  73. voxcity/simulator/solar.py +0 -2329
  74. voxcity/utils/visualization.py +0 -2660
  75. voxcity/utils/weather.py +0 -817
  76. voxcity-0.6.15.dist-info/RECORD +0 -37
  77. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
  78. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,928 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import numpy as np
5
+ import trimesh
6
+ import matplotlib.cm as cm
7
+ import matplotlib.colors as mcolors
8
+ try:
9
+ import plotly.graph_objects as go
10
+ except ImportError: # optional dependency
11
+ go = None # type: ignore
12
+
13
+ from ..models import VoxCity
14
+ from .builder import MeshBuilder
15
+ from .palette import get_voxel_color_map
16
+ from ..geoprocessor.mesh import create_sim_surface_mesh
17
+ try:
18
+ import pyvista as pv
19
+ except ImportError: # optional dependency
20
+ pv = None # type: ignore
21
+
22
+
23
+ def _rgb_tuple_to_plotly_color(rgb_tuple):
24
+ """
25
+ Convert [R, G, B] or (R, G, B) with 0-255 range to plotly 'rgb(r,g,b)' string.
26
+ """
27
+ try:
28
+ r, g, b = rgb_tuple
29
+ r = int(max(0, min(255, r)))
30
+ g = int(max(0, min(255, g)))
31
+ b = int(max(0, min(255, b)))
32
+ return f"rgb({r},{g},{b})"
33
+ except Exception:
34
+ return "rgb(128,128,128)"
35
+
36
+
37
+ def _mpl_cmap_to_plotly_colorscale(cmap_name, n=256):
38
+ """
39
+ Convert a matplotlib colormap name to a Plotly colorscale list.
40
+ """
41
+ try:
42
+ cmap = cm.get_cmap(cmap_name)
43
+ except Exception:
44
+ cmap = cm.get_cmap('viridis')
45
+ if n < 2:
46
+ n = 2
47
+ scale = []
48
+ for i in range(n):
49
+ x = i / (n - 1)
50
+ r, g, b, _ = cmap(x)
51
+ scale.append([x, f"rgb({int(255*r)},{int(255*g)},{int(255*b)})"])
52
+ return scale
53
+
54
+
55
+ def visualize_voxcity_plotly(
56
+ voxel_array,
57
+ meshsize,
58
+ classes=None,
59
+ voxel_color_map='default',
60
+ opacity=1.0,
61
+ max_dimension=160,
62
+ downsample=None,
63
+ title=None,
64
+ width=1000,
65
+ height=800,
66
+ show=True,
67
+ return_fig=False,
68
+ # Building simulation overlay
69
+ building_sim_mesh=None,
70
+ building_value_name='svf_values',
71
+ building_colormap='viridis',
72
+ building_vmin=None,
73
+ building_vmax=None,
74
+ building_nan_color='gray',
75
+ building_opacity=1.0,
76
+ building_shaded=False,
77
+ render_voxel_buildings=False,
78
+ # Ground simulation surface overlay
79
+ ground_sim_grid=None,
80
+ ground_dem_grid=None,
81
+ ground_z_offset=None,
82
+ ground_view_point_height=None,
83
+ ground_colormap='viridis',
84
+ ground_vmin=None,
85
+ ground_vmax=None,
86
+ sim_surface_opacity=0.95,
87
+ ground_shaded=False,
88
+ ):
89
+ """
90
+ Interactive 3D visualization using Plotly Mesh3d of voxel faces and optional overlays.
91
+ """
92
+ # Validate optional dependency
93
+ if go is None:
94
+ raise ImportError("Plotly is required for interactive visualization. Install with: pip install plotly")
95
+ # Validate/prepare voxels
96
+ if voxel_array is None or getattr(voxel_array, 'ndim', 0) != 3:
97
+ if building_sim_mesh is None and (ground_sim_grid is None or ground_dem_grid is None):
98
+ raise ValueError("voxel_array must be a 3D numpy array when no overlays are provided")
99
+ vox = None
100
+ else:
101
+ vox = voxel_array
102
+
103
+ # Downsample strategy
104
+ stride = 1
105
+ if vox is not None:
106
+ if downsample is not None:
107
+ stride = max(1, int(downsample))
108
+ else:
109
+ nx_tmp, ny_tmp, nz_tmp = vox.shape
110
+ max_dim = max(nx_tmp, ny_tmp, nz_tmp)
111
+ if max_dim > max_dimension:
112
+ stride = int(np.ceil(max_dim / max_dimension))
113
+ if stride > 1:
114
+ # Surface-aware downsampling: stride X/Y, pick topmost non-zero along Z in each window
115
+ orig = voxel_array
116
+ nx0, ny0, nz0 = orig.shape
117
+ xs = orig[::stride, ::stride, :]
118
+ nx_ds, ny_ds, _ = xs.shape
119
+ nz_ds = int(np.ceil(nz0 / float(stride)))
120
+ vox = np.zeros((nx_ds, ny_ds, nz_ds), dtype=orig.dtype)
121
+ for k in range(nz_ds):
122
+ z0w = k * stride
123
+ z1w = min(z0w + stride, nz0)
124
+ W = xs[:, :, z0w:z1w]
125
+ if W.size == 0:
126
+ continue
127
+ nz_mask = (W != 0)
128
+ has_any = nz_mask.any(axis=2)
129
+ rev_mask = nz_mask[:, :, ::-1]
130
+ idx_rev = rev_mask.argmax(axis=2)
131
+ real_idx = (W.shape[2] - 1) - idx_rev
132
+ gathered = np.take_along_axis(W, real_idx[..., None], axis=2).squeeze(-1)
133
+ vox[:, :, k] = np.where(has_any, gathered, 0)
134
+
135
+ nx, ny, nz = vox.shape
136
+ dx = meshsize * stride
137
+ dy = meshsize * stride
138
+ dz = meshsize * stride
139
+ x = np.arange(nx, dtype=float) * dx
140
+ y = np.arange(ny, dtype=float) * dy
141
+ z = np.arange(nz, dtype=float) * dz
142
+
143
+ # Choose classes
144
+ if classes is None:
145
+ classes_all = np.unique(vox[vox != 0]).tolist()
146
+ else:
147
+ classes_all = list(classes)
148
+ if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
149
+ classes_to_draw = classes_all if render_voxel_buildings else [c for c in classes_all if int(c) != -3]
150
+ else:
151
+ classes_to_draw = classes_all
152
+
153
+ # Resolve colors
154
+ if isinstance(voxel_color_map, dict):
155
+ vox_dict = voxel_color_map
156
+ else:
157
+ vox_dict = get_voxel_color_map(voxel_color_map)
158
+
159
+ # Occluder mask (any occupancy)
160
+ if stride > 1:
161
+ def _bool_max_pool_3d(arr_bool, sx):
162
+ if isinstance(sx, (tuple, list, np.ndarray)):
163
+ sx, sy, sz = int(sx[0]), int(sx[1]), int(sx[2])
164
+ else:
165
+ sy = sz = int(sx)
166
+ sx = int(sx)
167
+ a = np.asarray(arr_bool, dtype=bool)
168
+ nx_, ny_, nz_ = a.shape
169
+ px = (sx - (nx_ % sx)) % sx
170
+ py = (sy - (ny_ % sy)) % sy
171
+ pz = (sz - (nz_ % sz)) % sz
172
+ if px or py or pz:
173
+ a = np.pad(a, ((0, px), (0, py), (0, pz)), constant_values=False)
174
+ nxp, nyp, nzp = a.shape
175
+ a = a.reshape(nxp // sx, sx, nyp // sy, sy, nzp // sz, sz)
176
+ a = a.max(axis=1).max(axis=2).max(axis=4)
177
+ return a
178
+ occluder = _bool_max_pool_3d((voxel_array != 0), stride)
179
+ else:
180
+ occluder = (vox != 0)
181
+
182
+ def exposed_face_masks(occ, occ_any):
183
+ p = np.pad(occ_any, ((0,1),(0,0),(0,0)), constant_values=False)
184
+ posx = occ & (~p[1:,:,:])
185
+ p = np.pad(occ_any, ((1,0),(0,0),(0,0)), constant_values=False)
186
+ negx = occ & (~p[:-1,:,:])
187
+ p = np.pad(occ_any, ((0,0),(0,1),(0,0)), constant_values=False)
188
+ posy = occ & (~p[:,1:,:])
189
+ p = np.pad(occ_any, ((0,0),(1,0),(0,0)), constant_values=False)
190
+ negy = occ & (~p[:,:-1,:])
191
+ p = np.pad(occ_any, ((0,0),(0,0),(0,1)), constant_values=False)
192
+ posz = occ & (~p[:,:,1:])
193
+ p = np.pad(occ_any, ((0,0),(0,0),(1,0)), constant_values=False)
194
+ negz = occ & (~p[:,:,:-1])
195
+ return posx, negx, posy, negy, posz, negz
196
+
197
+ fig = go.Figure()
198
+
199
+ def add_faces(mask, plane, color_rgb):
200
+ idx = np.argwhere(mask)
201
+ if idx.size == 0:
202
+ return
203
+ xi, yi, zi = idx[:,0], idx[:,1], idx[:,2]
204
+ xc = x[xi]; yc = y[yi]; zc = z[zi]
205
+ x0, x1 = xc - dx/2.0, xc + dx/2.0
206
+ y0, y1 = yc - dy/2.0, yc + dy/2.0
207
+ z0, z1 = zc - dz/2.0, zc + dz/2.0
208
+
209
+ if plane == '+x':
210
+ vx = np.stack([x1, x1, x1, x1], axis=1)
211
+ vy = np.stack([y0, y1, y1, y0], axis=1)
212
+ vz = np.stack([z0, z0, z1, z1], axis=1)
213
+ elif plane == '-x':
214
+ vx = np.stack([x0, x0, x0, x0], axis=1)
215
+ vy = np.stack([y0, y1, y1, y0], axis=1)
216
+ vz = np.stack([z1, z1, z0, z0], axis=1)
217
+ elif plane == '+y':
218
+ vx = np.stack([x0, x1, x1, x0], axis=1)
219
+ vy = np.stack([y1, y1, y1, y1], axis=1)
220
+ vz = np.stack([z0, z0, z1, z1], axis=1)
221
+ elif plane == '-y':
222
+ vx = np.stack([x0, x1, x1, x0], axis=1)
223
+ vy = np.stack([y0, y0, y0, y0], axis=1)
224
+ vz = np.stack([z1, z1, z0, z0], axis=1)
225
+ elif plane == '+z':
226
+ vx = np.stack([x0, x1, x1, x0], axis=1)
227
+ vy = np.stack([y0, y0, y1, y1], axis=1)
228
+ vz = np.stack([z1, z1, z1, z1], axis=1)
229
+ elif plane == '-z':
230
+ vx = np.stack([x0, x1, x1, x0], axis=1)
231
+ vy = np.stack([y1, y1, y0, y0], axis=1)
232
+ vz = np.stack([z0, z0, z0, z0], axis=1)
233
+ else:
234
+ return
235
+
236
+ V = np.column_stack([vx.reshape(-1), vy.reshape(-1), vz.reshape(-1)])
237
+ n = idx.shape[0]
238
+ starts = np.arange(0, 4*n, 4, dtype=np.int32)
239
+ tris = np.vstack([
240
+ np.stack([starts, starts+1, starts+2], axis=1),
241
+ np.stack([starts, starts+2, starts+3], axis=1)
242
+ ])
243
+
244
+ lighting = dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
245
+ cx = (x.min() + x.max()) * 0.5 if len(x) > 0 else 0.0
246
+ cy = (y.min() + y.max()) * 0.5 if len(y) > 0 else 0.0
247
+ cz = (z.min() + z.max()) * 0.5 if len(z) > 0 else 0.0
248
+ lx = cx + (x.max() - x.min() + dx) * 0.9
249
+ ly = cy + (y.max() - y.min() + dy) * 0.6
250
+ lz = cz + (z.max() - z.min() + dz) * 1.4
251
+
252
+ fig.add_trace(
253
+ go.Mesh3d(
254
+ x=V[:,0], y=V[:,1], z=V[:,2],
255
+ i=tris[:,0], j=tris[:,1], k=tris[:,2],
256
+ color=_rgb_tuple_to_plotly_color(color_rgb),
257
+ opacity=float(opacity),
258
+ flatshading=False,
259
+ lighting=lighting,
260
+ lightposition=dict(x=lx, y=ly, z=lx),
261
+ name=f"{plane}"
262
+ )
263
+ )
264
+
265
+ # Draw voxel faces
266
+ if vox is not None and classes_to_draw:
267
+ for cls in classes_to_draw:
268
+ if not np.any(vox == cls):
269
+ continue
270
+ occ = (vox == cls)
271
+ p = np.pad(occluder, ((0,1),(0,0),(0,0)), constant_values=False); posx = occ & (~p[1:,:,:])
272
+ p = np.pad(occluder, ((1,0),(0,0),(0,0)), constant_values=False); negx = occ & (~p[:-1,:,:])
273
+ p = np.pad(occluder, ((0,0),(0,1),(0,0)), constant_values=False); posy = occ & (~p[:,1:,:])
274
+ p = np.pad(occluder, ((0,0),(1,0),(0,0)), constant_values=False); negy = occ & (~p[:,:-1,:])
275
+ p = np.pad(occluder, ((0,0),(0,0),(0,1)), constant_values=False); posz = occ & (~p[:,:,1:])
276
+ p = np.pad(occluder, ((0,0),(0,0),(1,0)), constant_values=False); negz = occ & (~p[:,:,:-1])
277
+ color_rgb = vox_dict.get(int(cls), [128,128,128])
278
+ add_faces(posx, '+x', color_rgb)
279
+ add_faces(negx, '-x', color_rgb)
280
+ add_faces(posy, '+y', color_rgb)
281
+ add_faces(negy, '-y', color_rgb)
282
+ add_faces(posz, '+z', color_rgb)
283
+ add_faces(negz, '-z', color_rgb)
284
+
285
+ # Building overlay
286
+ if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
287
+ Vb = np.asarray(building_sim_mesh.vertices)
288
+ Fb = np.asarray(building_sim_mesh.faces)
289
+ values = None
290
+ if hasattr(building_sim_mesh, 'metadata') and isinstance(building_sim_mesh.metadata, dict):
291
+ values = building_sim_mesh.metadata.get(building_value_name)
292
+ if values is not None:
293
+ values = np.asarray(values)
294
+
295
+ face_vals = None
296
+ if values is not None and len(values) == len(Fb):
297
+ face_vals = values.astype(float)
298
+ elif values is not None and len(values) == len(Vb):
299
+ vals_v = values.astype(float)
300
+ face_vals = np.nanmean(vals_v[Fb], axis=1)
301
+
302
+ facecolor = None
303
+ if face_vals is not None:
304
+ finite = np.isfinite(face_vals)
305
+ vmin_b = building_vmin if building_vmin is not None else (float(np.nanmin(face_vals[finite])) if np.any(finite) else 0.0)
306
+ vmax_b = building_vmax if building_vmax is not None else (float(np.nanmax(face_vals[finite])) if np.any(finite) else 1.0)
307
+ norm_b = mcolors.Normalize(vmin=vmin_b, vmax=vmax_b)
308
+ cmap_b = cm.get_cmap(building_colormap)
309
+ colors_rgba = np.zeros((len(Fb), 4), dtype=float)
310
+ colors_rgba[finite] = cmap_b(norm_b(face_vals[finite]))
311
+ nan_rgba = np.array(mcolors.to_rgba(building_nan_color))
312
+ colors_rgba[~finite] = nan_rgba
313
+ facecolor = [f"rgb({int(255*c[0])},{int(255*c[1])},{int(255*c[2])})" for c in colors_rgba]
314
+
315
+ lighting_b = (dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
316
+ if building_shaded else dict(ambient=1.0, diffuse=0.0, specular=0.0, roughness=0.0, fresnel=0.0))
317
+
318
+ cx = float((Vb[:,0].min() + Vb[:,0].max()) * 0.5)
319
+ cy = float((Vb[:,1].min() + Vb[:,1].max()) * 0.5)
320
+ lx = cx + (Vb[:,0].max() - Vb[:,0].min() + meshsize) * 0.9
321
+ ly = cy + (Vb[:,1].max() - Vb[:,1].min() + meshsize) * 0.6
322
+ lz = float((Vb[:,2].min() + Vb[:,2].max()) * 0.5) + (Vb[:,2].max() - Vb[:,2].min() + meshsize) * 1.4
323
+
324
+ fig.add_trace(
325
+ go.Mesh3d(
326
+ x=Vb[:,0], y=Vb[:,1], z=Vb[:,2],
327
+ i=Fb[:,0], j=Fb[:,1], k=Fb[:,2],
328
+ facecolor=facecolor if facecolor is not None else None,
329
+ color=None if facecolor is not None else 'rgb(200,200,200)',
330
+ opacity=float(building_opacity),
331
+ flatshading=False,
332
+ lighting=lighting_b,
333
+ lightposition=dict(x=lx, y=ly, z=lz),
334
+ name=building_value_name if facecolor is not None else 'building_mesh'
335
+ )
336
+ )
337
+
338
+ if face_vals is not None:
339
+ colorscale_b = _mpl_cmap_to_plotly_colorscale(building_colormap)
340
+ fig.add_trace(
341
+ go.Scatter3d(
342
+ x=[None], y=[None], z=[None],
343
+ mode='markers',
344
+ marker=dict(size=0.1, color=[vmin_b, vmax_b], colorscale=colorscale_b, cmin=vmin_b, cmax=vmax_b,
345
+ colorbar=dict(title=building_value_name, len=0.5, y=0.8), showscale=True),
346
+ showlegend=False, hoverinfo='skip')
347
+ )
348
+
349
+ # Ground simulation surface overlay
350
+ if ground_sim_grid is not None and ground_dem_grid is not None:
351
+ sim_vals = np.asarray(ground_sim_grid, dtype=float)
352
+ finite = np.isfinite(sim_vals)
353
+ vmin_g = ground_vmin if ground_vmin is not None else (float(np.nanmin(sim_vals[finite])) if np.any(finite) else 0.0)
354
+ vmax_g = ground_vmax if ground_vmax is not None else (float(np.nanmax(sim_vals[finite])) if np.any(finite) else 1.0)
355
+ z_off = ground_z_offset if ground_z_offset is not None else ground_view_point_height
356
+ try:
357
+ z_off = float(z_off) if z_off is not None else 1.5
358
+ except Exception:
359
+ z_off = 1.5
360
+ try:
361
+ ms = float(meshsize)
362
+ z_off = (z_off // ms + 1.0) * ms
363
+ except Exception:
364
+ pass
365
+ try:
366
+ dem_norm = np.asarray(ground_dem_grid, dtype=float)
367
+ dem_norm = dem_norm - np.nanmin(dem_norm)
368
+ except Exception:
369
+ dem_norm = ground_dem_grid
370
+
371
+ sim_mesh = create_sim_surface_mesh(
372
+ ground_sim_grid,
373
+ dem_norm,
374
+ meshsize=meshsize,
375
+ z_offset=z_off,
376
+ cmap_name=ground_colormap,
377
+ vmin=vmin_g,
378
+ vmax=vmax_g,
379
+ )
380
+
381
+ if sim_mesh is not None and getattr(sim_mesh, 'vertices', None) is not None:
382
+ V = np.asarray(sim_mesh.vertices)
383
+ F = np.asarray(sim_mesh.faces)
384
+ facecolor = None
385
+ try:
386
+ colors_rgba = np.asarray(sim_mesh.visual.face_colors)
387
+ if colors_rgba.ndim == 2 and colors_rgba.shape[0] == len(F):
388
+ facecolor = [f"rgb({int(c[0])},{int(c[1])},{int(c[2])})" for c in colors_rgba]
389
+ except Exception:
390
+ facecolor = None
391
+
392
+ lighting = (dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
393
+ if ground_shaded else dict(ambient=1.0, diffuse=0.0, specular=0.0, roughness=0.0, fresnel=0.0))
394
+
395
+ cx = float((V[:,0].min() + V[:,0].max()) * 0.5)
396
+ cy = float((V[:,1].min() + V[:,1].max()) * 0.5)
397
+ lx = cx + (V[:,0].max() - V[:,0].min() + meshsize) * 0.9
398
+ ly = cy + (V[:,1].max() - V[:,1].min() + meshsize) * 0.6
399
+ lz = float((V[:,2].min() + V[:,2].max()) * 0.5) + (V[:,2].max() - V[:,2].min() + meshsize) * 1.4
400
+
401
+ fig.add_trace(
402
+ go.Mesh3d(
403
+ x=V[:,0], y=V[:,1], z=V[:,2],
404
+ i=F[:,0], j=F[:,1], k=F[:,2],
405
+ facecolor=facecolor,
406
+ color=None if facecolor is not None else 'rgb(200,200,200)',
407
+ opacity=float(sim_surface_opacity),
408
+ flatshading=False,
409
+ lighting=lighting,
410
+ lightposition=dict(x=lx, y=ly, z=lz),
411
+ name='sim_surface'
412
+ )
413
+ )
414
+
415
+ colorscale_g = _mpl_cmap_to_plotly_colorscale(ground_colormap)
416
+ fig.add_trace(
417
+ go.Scatter3d(
418
+ x=[None], y=[None], z=[None],
419
+ mode='markers',
420
+ marker=dict(size=0.1, color=[vmin_g, vmax_g], colorscale=colorscale_g, cmin=vmin_g, cmax=vmax_g,
421
+ colorbar=dict(title='ground', len=0.5, y=0.2), showscale=True),
422
+ showlegend=False, hoverinfo='skip')
423
+ )
424
+
425
+ fig.update_layout(
426
+ title=title,
427
+ width=width,
428
+ height=height,
429
+ scene=dict(
430
+ xaxis_title="X (m)",
431
+ yaxis_title="Y (m)",
432
+ zaxis_title="Z (m)",
433
+ aspectmode="data",
434
+ camera=dict(eye=dict(x=1.6, y=1.6, z=1.0)),
435
+ )
436
+ )
437
+
438
+ if show:
439
+ fig.show()
440
+ if return_fig:
441
+ return fig
442
+ return None
443
+
444
+
445
+ def create_multi_view_scene(meshes, output_directory="output", projection_type="perspective", distance_factor=1.0):
446
+ """
447
+ Creates multiple rendered views of 3D city meshes from different camera angles.
448
+ """
449
+ if pv is None:
450
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
451
+ pv_meshes = {}
452
+ for class_id, mesh in meshes.items():
453
+ if mesh is None or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
454
+ continue
455
+ faces = np.hstack([[3, *face] for face in mesh.faces])
456
+ pv_mesh = pv.PolyData(mesh.vertices, faces)
457
+ colors = getattr(mesh.visual, 'face_colors', None)
458
+ if colors is not None:
459
+ colors = np.asarray(colors)
460
+ if colors.size and colors.max() > 1:
461
+ colors = colors / 255.0
462
+ pv_mesh.cell_data['colors'] = colors
463
+ pv_meshes[class_id] = pv_mesh
464
+
465
+ min_xyz = np.array([np.inf, np.inf, np.inf], dtype=float)
466
+ max_xyz = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
467
+ for mesh in meshes.values():
468
+ if mesh is None or len(mesh.vertices) == 0:
469
+ continue
470
+ v = mesh.vertices
471
+ min_xyz = np.minimum(min_xyz, v.min(axis=0))
472
+ max_xyz = np.maximum(max_xyz, v.max(axis=0))
473
+ bbox = np.vstack([min_xyz, max_xyz])
474
+
475
+ center = (bbox[1] + bbox[0]) / 2
476
+ diagonal = np.linalg.norm(bbox[1] - bbox[0])
477
+
478
+ if projection_type.lower() == "orthographic":
479
+ distance = diagonal * 5
480
+ else:
481
+ distance = diagonal * 1.8 * distance_factor
482
+
483
+ iso_angles = {
484
+ 'iso_front_right': (1, 1, 0.7),
485
+ 'iso_front_left': (-1, 1, 0.7),
486
+ 'iso_back_right': (1, -1, 0.7),
487
+ 'iso_back_left': (-1, -1, 0.7)
488
+ }
489
+
490
+ camera_positions = {}
491
+ for name, direction in iso_angles.items():
492
+ direction = np.array(direction)
493
+ direction = direction / np.linalg.norm(direction)
494
+ camera_pos = center + direction * distance
495
+ camera_positions[name] = [camera_pos, center, (0, 0, 1)]
496
+
497
+ ortho_views = {
498
+ 'xy_top': [center + np.array([0, 0, distance]), center, (-1, 0, 0)],
499
+ 'yz_right': [center + np.array([distance, 0, 0]), center, (0, 0, 1)],
500
+ 'xz_front': [center + np.array([0, distance, 0]), center, (0, 0, 1)],
501
+ 'yz_left': [center + np.array([-distance, 0, 0]), center, (0, 0, 1)],
502
+ 'xz_back': [center + np.array([0, -distance, 0]), center, (0, 0, 1)]
503
+ }
504
+ camera_positions.update(ortho_views)
505
+
506
+ images = []
507
+ for view_name, camera_pos in camera_positions.items():
508
+ plotter = pv.Plotter(off_screen=True)
509
+ if projection_type.lower() == "orthographic":
510
+ plotter.enable_parallel_projection()
511
+ plotter.camera.parallel_scale = diagonal * 0.4 * distance_factor
512
+ elif projection_type.lower() != "perspective":
513
+ print(f"Warning: Unknown projection_type '{projection_type}'. Using perspective projection.")
514
+ for class_id, pv_mesh in pv_meshes.items():
515
+ has_colors = 'colors' in pv_mesh.cell_data
516
+ plotter.add_mesh(pv_mesh, rgb=True, scalars='colors' if has_colors else None)
517
+ plotter.camera_position = camera_pos
518
+ filename = f'{output_directory}/city_view_{view_name}.png'
519
+ plotter.screenshot(filename)
520
+ images.append((view_name, filename))
521
+ plotter.close()
522
+ return images
523
+
524
+
525
+ class PyVistaRenderer:
526
+ """Renderer that uses PyVista to produce multi-view images from meshes or VoxCity."""
527
+
528
+ def render_city(self, city: VoxCity, projection_type: str = "perspective", distance_factor: float = 1.0,
529
+ output_directory: str = "output", voxel_color_map: "str|dict" = "default",
530
+ building_sim_mesh=None, building_value_name: str = 'svf_values',
531
+ building_colormap: str = 'viridis', building_vmin=None, building_vmax=None,
532
+ building_nan_color: str = 'gray', building_opacity: float = 1.0,
533
+ render_voxel_buildings: bool = False,
534
+ ground_sim_grid=None, ground_dem_grid=None,
535
+ ground_z_offset: float | None = None, ground_view_point_height: float | None = None,
536
+ ground_colormap: str = 'viridis', ground_vmin=None, ground_vmax=None):
537
+ """
538
+ Render city to static images with optional simulation overlays.
539
+
540
+ Parameters
541
+ ----------
542
+ city : VoxCity
543
+ VoxCity object to render
544
+ projection_type : str
545
+ "perspective" or "orthographic"
546
+ distance_factor : float
547
+ Camera distance multiplier
548
+ output_directory : str
549
+ Directory to save rendered images
550
+ voxel_color_map : str or dict
551
+ Color mapping for voxel classes
552
+ building_sim_mesh : trimesh.Trimesh, optional
553
+ Building mesh with simulation results
554
+ building_value_name : str
555
+ Metadata key for building values
556
+ building_colormap : str
557
+ Colormap for building values
558
+ building_vmin, building_vmax : float, optional
559
+ Color scale limits for buildings
560
+ building_nan_color : str
561
+ Color for NaN values
562
+ building_opacity : float
563
+ Building mesh opacity
564
+ render_voxel_buildings : bool
565
+ Whether to render voxel buildings when building_sim_mesh is provided
566
+ ground_sim_grid : np.ndarray, optional
567
+ Ground-level simulation grid
568
+ ground_dem_grid : np.ndarray, optional
569
+ DEM grid for ground surface positioning
570
+ ground_z_offset : float, optional
571
+ Height offset for ground surface
572
+ ground_view_point_height : float, optional
573
+ Alternative height parameter
574
+ ground_colormap : str
575
+ Colormap for ground values
576
+ ground_vmin, ground_vmax : float, optional
577
+ Color scale limits for ground
578
+ """
579
+ if pv is None:
580
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
581
+
582
+ meshsize = city.voxels.meta.meshsize
583
+ trimesh_dict = {}
584
+
585
+ # Build voxel meshes (always generate to show ground, trees, etc.)
586
+ collection = MeshBuilder.from_voxel_grid(city.voxels, meshsize=meshsize, voxel_color_map=voxel_color_map)
587
+ for key, mm in collection.items.items():
588
+ if mm.vertices.size == 0 or mm.faces.size == 0:
589
+ continue
590
+ # Skip building voxels if we have building_sim_mesh and don't want to render both
591
+ if not render_voxel_buildings and building_sim_mesh is not None and int(key) == -3:
592
+ continue
593
+ tri = trimesh.Trimesh(vertices=mm.vertices, faces=mm.faces, process=False)
594
+ if mm.colors is not None:
595
+ tri.visual.face_colors = mm.colors
596
+ trimesh_dict[key] = tri
597
+
598
+ # Add building simulation mesh overlay
599
+ if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
600
+ Vb = np.asarray(building_sim_mesh.vertices)
601
+ Fb = np.asarray(building_sim_mesh.faces)
602
+
603
+ # Get simulation values from metadata
604
+ values = None
605
+ if hasattr(building_sim_mesh, 'metadata') and isinstance(building_sim_mesh.metadata, dict):
606
+ values = building_sim_mesh.metadata.get(building_value_name)
607
+
608
+ if values is not None:
609
+ values = np.asarray(values)
610
+
611
+ # Determine if values are per-face or per-vertex
612
+ face_vals = None
613
+ if len(values) == len(Fb):
614
+ face_vals = values.astype(float)
615
+ elif len(values) == len(Vb):
616
+ vals_v = values.astype(float)
617
+ face_vals = np.nanmean(vals_v[Fb], axis=1)
618
+
619
+ if face_vals is not None:
620
+ # Apply colormap
621
+ finite = np.isfinite(face_vals)
622
+ vmin_b = building_vmin if building_vmin is not None else (float(np.nanmin(face_vals[finite])) if np.any(finite) else 0.0)
623
+ vmax_b = building_vmax if building_vmax is not None else (float(np.nanmax(face_vals[finite])) if np.any(finite) else 1.0)
624
+ norm_b = mcolors.Normalize(vmin=vmin_b, vmax=vmax_b)
625
+ cmap_b = cm.get_cmap(building_colormap)
626
+
627
+ colors_rgba = np.zeros((len(Fb), 4), dtype=np.uint8)
628
+ if np.any(finite):
629
+ colors_float = cmap_b(norm_b(face_vals[finite]))
630
+ colors_rgba[finite] = (colors_float * 255).astype(np.uint8)
631
+
632
+ # Handle NaN values
633
+ nan_rgba = np.array(mcolors.to_rgba(building_nan_color))
634
+ colors_rgba[~finite] = (nan_rgba * 255).astype(np.uint8)
635
+
636
+ # Create trimesh with colors
637
+ building_tri = trimesh.Trimesh(vertices=Vb, faces=Fb, process=False)
638
+ building_tri.visual.face_colors = colors_rgba
639
+ trimesh_dict['building_sim'] = building_tri
640
+ else:
641
+ # No values, just add the mesh with default color
642
+ building_tri = trimesh.Trimesh(vertices=Vb, faces=Fb, process=False)
643
+ trimesh_dict['building_sim'] = building_tri
644
+
645
+ # Add ground simulation surface overlay
646
+ if ground_sim_grid is not None and ground_dem_grid is not None:
647
+ z_off = ground_z_offset if ground_z_offset is not None else ground_view_point_height
648
+ try:
649
+ z_off = float(z_off) if z_off is not None else 1.5
650
+ except Exception:
651
+ z_off = 1.5
652
+
653
+ # Snap to grid
654
+ try:
655
+ z_off = (z_off // meshsize + 1.0) * meshsize
656
+ except Exception:
657
+ pass
658
+
659
+ # Normalize DEM
660
+ try:
661
+ dem_norm = np.asarray(ground_dem_grid, dtype=float)
662
+ dem_norm = dem_norm - np.nanmin(dem_norm)
663
+ except Exception:
664
+ dem_norm = ground_dem_grid
665
+
666
+ # Determine color range
667
+ sim_vals = np.asarray(ground_sim_grid, dtype=float)
668
+ finite = np.isfinite(sim_vals)
669
+ vmin_g = ground_vmin if ground_vmin is not None else (float(np.nanmin(sim_vals[finite])) if np.any(finite) else 0.0)
670
+ vmax_g = ground_vmax if ground_vmax is not None else (float(np.nanmax(sim_vals[finite])) if np.any(finite) else 1.0)
671
+
672
+ # Create ground simulation mesh
673
+ sim_mesh = create_sim_surface_mesh(
674
+ ground_sim_grid,
675
+ dem_norm,
676
+ meshsize=meshsize,
677
+ z_offset=z_off,
678
+ cmap_name=ground_colormap,
679
+ vmin=vmin_g,
680
+ vmax=vmax_g,
681
+ )
682
+
683
+ if sim_mesh is not None and getattr(sim_mesh, 'vertices', None) is not None:
684
+ trimesh_dict['ground_sim'] = sim_mesh
685
+
686
+ os.makedirs(output_directory, exist_ok=True)
687
+ return create_multi_view_scene(trimesh_dict, output_directory=output_directory,
688
+ projection_type=projection_type, distance_factor=distance_factor)
689
+
690
+
691
+
692
+ def visualize_voxcity(
693
+ city: VoxCity,
694
+ mode: str = "interactive",
695
+ *,
696
+ # Common options
697
+ voxel_color_map: "str|dict" = "default",
698
+ classes=None,
699
+ title: str | None = None,
700
+ # Interactive (Plotly) options
701
+ opacity: float = 1.0,
702
+ max_dimension: int = 160,
703
+ downsample: int | None = None,
704
+ width: int = 1000,
705
+ height: int = 800,
706
+ show: bool = True,
707
+ return_fig: bool = False,
708
+ # Static (PyVista) options
709
+ output_directory: str = "output",
710
+ projection_type: str = "perspective",
711
+ distance_factor: float = 1.0,
712
+ # Building simulation overlay options
713
+ building_sim_mesh=None,
714
+ building_value_name: str = 'svf_values',
715
+ building_colormap: str = 'viridis',
716
+ building_vmin: float | None = None,
717
+ building_vmax: float | None = None,
718
+ building_nan_color: str = 'gray',
719
+ building_opacity: float = 1.0,
720
+ building_shaded: bool = False,
721
+ render_voxel_buildings: bool = False,
722
+ # Ground simulation surface overlay options
723
+ ground_sim_grid=None,
724
+ ground_dem_grid=None,
725
+ ground_z_offset: float | None = None,
726
+ ground_view_point_height: float | None = None,
727
+ ground_colormap: str = 'viridis',
728
+ ground_vmin: float | None = None,
729
+ ground_vmax: float | None = None,
730
+ sim_surface_opacity: float = 0.95,
731
+ ground_shaded: bool = False,
732
+ ):
733
+ """
734
+ Visualize a VoxCity object with optional simulation result overlays.
735
+
736
+ Parameters
737
+ ----------
738
+ city : VoxCity
739
+ VoxCity object to visualize
740
+ mode : str, default="interactive"
741
+ Visualization mode: "interactive" (Plotly) or "static" (PyVista)
742
+
743
+ Common Options
744
+ --------------
745
+ voxel_color_map : str or dict, default="default"
746
+ Color mapping for voxel classes
747
+ classes : list, optional
748
+ Specific voxel classes to render
749
+ title : str, optional
750
+ Plot title
751
+
752
+ Interactive Mode Options (Plotly)
753
+ ----------------------------------
754
+ opacity : float, default=1.0
755
+ Voxel opacity (0-1)
756
+ max_dimension : int, default=160
757
+ Maximum grid dimension before downsampling
758
+ downsample : int, optional
759
+ Manual downsampling stride
760
+ width : int, default=1000
761
+ Plot width in pixels
762
+ height : int, default=800
763
+ Plot height in pixels
764
+ show : bool, default=True
765
+ Whether to display the plot
766
+ return_fig : bool, default=False
767
+ Whether to return the figure object
768
+
769
+ Static Mode Options (PyVista)
770
+ ------------------------------
771
+ output_directory : str, default="output"
772
+ Directory for saving rendered images
773
+ projection_type : str, default="perspective"
774
+ Camera projection: "perspective" or "orthographic"
775
+ distance_factor : float, default=1.0
776
+ Camera distance multiplier
777
+
778
+ Building Simulation Overlay Options
779
+ ------------------------------------
780
+ building_sim_mesh : trimesh.Trimesh, optional
781
+ Building mesh with simulation results in metadata.
782
+ Typically created by get_surface_view_factor() or get_building_solar_irradiance().
783
+ building_value_name : str, default='svf_values'
784
+ Metadata key to use for coloring (e.g., 'svf_values', 'global', 'direct', 'diffuse')
785
+ building_colormap : str, default='viridis'
786
+ Matplotlib colormap for building values
787
+ building_vmin : float, optional
788
+ Minimum value for color scale
789
+ building_vmax : float, optional
790
+ Maximum value for color scale
791
+ building_nan_color : str, default='gray'
792
+ Color for NaN/invalid values
793
+ building_opacity : float, default=1.0
794
+ Building mesh opacity (0-1)
795
+ building_shaded : bool, default=False
796
+ Whether to apply shading to building mesh
797
+ render_voxel_buildings : bool, default=False
798
+ Whether to render voxel buildings when building_sim_mesh is provided
799
+
800
+ Ground Simulation Surface Overlay Options
801
+ ------------------------------------------
802
+ ground_sim_grid : np.ndarray, optional
803
+ 2D array of ground-level simulation values (e.g., Green View Index, solar radiation).
804
+ Should have the same shape as the city's 2D grids.
805
+ ground_dem_grid : np.ndarray, optional
806
+ 2D DEM array for positioning the ground simulation surface.
807
+ If None, uses city.dem.elevation when ground_sim_grid is provided.
808
+ ground_z_offset : float, optional
809
+ Height offset for ground simulation surface above DEM
810
+ ground_view_point_height : float, optional
811
+ Alternative parameter for ground surface height (used if ground_z_offset is None)
812
+ ground_colormap : str, default='viridis'
813
+ Matplotlib colormap for ground values
814
+ ground_vmin : float, optional
815
+ Minimum value for color scale
816
+ ground_vmax : float, optional
817
+ Maximum value for color scale
818
+ sim_surface_opacity : float, default=0.95
819
+ Ground simulation surface opacity (0-1)
820
+ ground_shaded : bool, default=False
821
+ Whether to apply shading to ground surface
822
+
823
+ Returns
824
+ -------
825
+ For mode="interactive":
826
+ plotly.graph_objects.Figure or None
827
+ Returns Figure if return_fig=True, otherwise None
828
+
829
+ For mode="static":
830
+ list of (view_name, filepath) tuples
831
+ List of rendered view names and their file paths
832
+
833
+ Examples
834
+ --------
835
+ Basic visualization:
836
+ >>> visualize_voxcity(city, mode="interactive")
837
+
838
+ With building solar irradiance results:
839
+ >>> building_mesh = get_building_solar_irradiance(city, ...)
840
+ >>> visualize_voxcity(city, mode="interactive",
841
+ ... building_sim_mesh=building_mesh,
842
+ ... building_value_name='global')
843
+
844
+ With ground-level Green View Index:
845
+ >>> visualize_voxcity(city, mode="interactive",
846
+ ... ground_sim_grid=gvi_array,
847
+ ... ground_colormap='YlGn')
848
+
849
+ Static rendering with simulation overlays:
850
+ >>> visualize_voxcity(city, mode="static",
851
+ ... building_sim_mesh=svf_mesh,
852
+ ... output_directory="renders")
853
+ """
854
+ if not isinstance(mode, str):
855
+ raise ValueError("mode must be a string: 'interactive' or 'static'")
856
+
857
+ mode_l = mode.lower().strip()
858
+ meshsize = getattr(city.voxels.meta, "meshsize", None)
859
+
860
+ # Auto-fill ground_dem_grid from city if ground_sim_grid is provided but ground_dem_grid is not
861
+ if ground_sim_grid is not None and ground_dem_grid is None:
862
+ ground_dem_grid = getattr(city.dem, "elevation", None)
863
+
864
+ if mode_l == "interactive":
865
+ voxel_array = getattr(city.voxels, "classes", None)
866
+ return visualize_voxcity_plotly(
867
+ voxel_array=voxel_array,
868
+ meshsize=meshsize,
869
+ classes=classes,
870
+ voxel_color_map=voxel_color_map,
871
+ opacity=opacity,
872
+ max_dimension=max_dimension,
873
+ downsample=downsample,
874
+ title=title,
875
+ width=width,
876
+ height=height,
877
+ show=show,
878
+ return_fig=return_fig,
879
+ # Building simulation overlay
880
+ building_sim_mesh=building_sim_mesh,
881
+ building_value_name=building_value_name,
882
+ building_colormap=building_colormap,
883
+ building_vmin=building_vmin,
884
+ building_vmax=building_vmax,
885
+ building_nan_color=building_nan_color,
886
+ building_opacity=building_opacity,
887
+ building_shaded=building_shaded,
888
+ render_voxel_buildings=render_voxel_buildings,
889
+ # Ground simulation surface overlay
890
+ ground_sim_grid=ground_sim_grid,
891
+ ground_dem_grid=ground_dem_grid,
892
+ ground_z_offset=ground_z_offset,
893
+ ground_view_point_height=ground_view_point_height,
894
+ ground_colormap=ground_colormap,
895
+ ground_vmin=ground_vmin,
896
+ ground_vmax=ground_vmax,
897
+ sim_surface_opacity=sim_surface_opacity,
898
+ ground_shaded=ground_shaded,
899
+ )
900
+
901
+ if mode_l == "static":
902
+ renderer = PyVistaRenderer()
903
+ return renderer.render_city(
904
+ city,
905
+ projection_type=projection_type,
906
+ distance_factor=distance_factor,
907
+ output_directory=output_directory,
908
+ voxel_color_map=voxel_color_map,
909
+ # Pass simulation overlay parameters
910
+ building_sim_mesh=building_sim_mesh,
911
+ building_value_name=building_value_name,
912
+ building_colormap=building_colormap,
913
+ building_vmin=building_vmin,
914
+ building_vmax=building_vmax,
915
+ building_nan_color=building_nan_color,
916
+ building_opacity=building_opacity,
917
+ render_voxel_buildings=render_voxel_buildings,
918
+ ground_sim_grid=ground_sim_grid,
919
+ ground_dem_grid=ground_dem_grid,
920
+ ground_z_offset=ground_z_offset,
921
+ ground_view_point_height=ground_view_point_height,
922
+ ground_colormap=ground_colormap,
923
+ ground_vmin=ground_vmin,
924
+ ground_vmax=ground_vmax,
925
+ )
926
+
927
+ raise ValueError("Unknown mode. Use 'interactive' or 'static'.")
928
+