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
@@ -0,0 +1,1145 @@
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
+ image_size: "tuple[int, int] | None" = None, fixed_bounds: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None):
447
+ """
448
+ Creates multiple rendered views of 3D city meshes from different camera angles.
449
+ """
450
+ if pv is None:
451
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
452
+ # NOTE: image_size is now supported via Plotter.window_size when invoked from renderer
453
+ pv_meshes = {}
454
+ for class_id, mesh in meshes.items():
455
+ if mesh is None or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
456
+ continue
457
+ faces = np.hstack([[3, *face] for face in mesh.faces])
458
+ pv_mesh = pv.PolyData(mesh.vertices, faces)
459
+ colors = getattr(mesh.visual, 'face_colors', None)
460
+ if colors is not None:
461
+ colors = np.asarray(colors)
462
+ if colors.size and colors.max() > 1:
463
+ colors = colors / 255.0
464
+ pv_mesh.cell_data['colors'] = colors
465
+ pv_meshes[class_id] = pv_mesh
466
+
467
+ if fixed_bounds is not None:
468
+ try:
469
+ fb = np.asarray(fixed_bounds, dtype=float)
470
+ if fb.shape == (2, 3):
471
+ bbox = fb
472
+ else:
473
+ raise ValueError
474
+ except Exception:
475
+ # Fallback to computed bounds if provided value is invalid
476
+ fixed_bounds = None
477
+
478
+ if fixed_bounds is None:
479
+ min_xyz = np.array([np.inf, np.inf, np.inf], dtype=float)
480
+ max_xyz = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
481
+ for mesh in meshes.values():
482
+ if mesh is None or len(mesh.vertices) == 0:
483
+ continue
484
+ v = mesh.vertices
485
+ min_xyz = np.minimum(min_xyz, v.min(axis=0))
486
+ max_xyz = np.maximum(max_xyz, v.max(axis=0))
487
+ bbox = np.vstack([min_xyz, max_xyz])
488
+
489
+ center = (bbox[1] + bbox[0]) / 2
490
+ diagonal = np.linalg.norm(bbox[1] - bbox[0])
491
+
492
+ if projection_type.lower() == "orthographic":
493
+ distance = diagonal * 5
494
+ else:
495
+ distance = diagonal * 1.8 * distance_factor
496
+
497
+ iso_angles = {
498
+ 'iso_front_right': (1, 1, 0.7),
499
+ 'iso_front_left': (-1, 1, 0.7),
500
+ 'iso_back_right': (1, -1, 0.7),
501
+ 'iso_back_left': (-1, -1, 0.7)
502
+ }
503
+
504
+ camera_positions = {}
505
+ for name, direction in iso_angles.items():
506
+ direction = np.array(direction)
507
+ direction = direction / np.linalg.norm(direction)
508
+ camera_pos = center + direction * distance
509
+ camera_positions[name] = [camera_pos, center, (0, 0, 1)]
510
+
511
+ ortho_views = {
512
+ 'xy_top': [center + np.array([0, 0, distance]), center, (-1, 0, 0)],
513
+ 'yz_right': [center + np.array([distance, 0, 0]), center, (0, 0, 1)],
514
+ 'xz_front': [center + np.array([0, distance, 0]), center, (0, 0, 1)],
515
+ 'yz_left': [center + np.array([-distance, 0, 0]), center, (0, 0, 1)],
516
+ 'xz_back': [center + np.array([0, -distance, 0]), center, (0, 0, 1)]
517
+ }
518
+ camera_positions.update(ortho_views)
519
+
520
+ images = []
521
+ for view_name, camera_pos in camera_positions.items():
522
+ plotter = pv.Plotter(off_screen=True, window_size=image_size if image_size is not None else None)
523
+ if projection_type.lower() == "orthographic":
524
+ plotter.enable_parallel_projection()
525
+ plotter.camera.parallel_scale = diagonal * 0.4 * distance_factor
526
+ elif projection_type.lower() != "perspective":
527
+ print(f"Warning: Unknown projection_type '{projection_type}'. Using perspective projection.")
528
+ for class_id, pv_mesh in pv_meshes.items():
529
+ has_colors = 'colors' in pv_mesh.cell_data
530
+ plotter.add_mesh(pv_mesh, rgb=True, scalars='colors' if has_colors else None)
531
+ plotter.camera_position = camera_pos
532
+ filename = f'{output_directory}/city_view_{view_name}.png'
533
+ plotter.screenshot(filename)
534
+ images.append((view_name, filename))
535
+ plotter.close()
536
+ return images
537
+
538
+
539
+ def create_rotation_view_scene(
540
+ meshes,
541
+ output_directory: str = "output",
542
+ projection_type: str = "perspective",
543
+ distance_factor: float = 1.0,
544
+ frames_per_segment: int = 60,
545
+ close_loop: bool = False,
546
+ file_prefix: str = "city_rotation",
547
+ image_size: "tuple[int, int] | None" = None,
548
+ fixed_bounds: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None,
549
+ ):
550
+ """
551
+ Creates a sequence of rendered frames forming a smooth isometric rotation that
552
+ passes through: iso_front_right -> iso_front_left -> iso_back_left -> iso_back_right.
553
+
554
+ Parameters
555
+ ----------
556
+ meshes : dict[Any, trimesh.Trimesh]
557
+ Dictionary of trimesh meshes keyed by class/label.
558
+ output_directory : str
559
+ Directory to save frames.
560
+ projection_type : str
561
+ "perspective" or "orthographic".
562
+ distance_factor : float
563
+ Camera distance multiplier.
564
+ frames_per_segment : int
565
+ Number of frames between each consecutive isometric anchor.
566
+ close_loop : bool
567
+ If True, also generates frames to return from iso_back_right to iso_front_right.
568
+ file_prefix : str
569
+ Prefix for saved frame filenames.
570
+
571
+ Returns
572
+ -------
573
+ list[str]
574
+ List of saved frame file paths in order.
575
+ """
576
+ if pv is None:
577
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
578
+
579
+ os.makedirs(output_directory, exist_ok=True)
580
+
581
+ # Prepare PyVista meshes
582
+ pv_meshes = {}
583
+ for class_id, mesh in meshes.items():
584
+ if mesh is None or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
585
+ continue
586
+ faces = np.hstack([[3, *face] for face in mesh.faces])
587
+ pv_mesh = pv.PolyData(mesh.vertices, faces)
588
+ colors = getattr(mesh.visual, 'face_colors', None)
589
+ if colors is not None:
590
+ colors = np.asarray(colors)
591
+ if colors.size and colors.max() > 1:
592
+ colors = colors / 255.0
593
+ pv_mesh.cell_data['colors'] = colors
594
+ pv_meshes[class_id] = pv_mesh
595
+
596
+ # Compute scene bounds
597
+ if fixed_bounds is not None:
598
+ try:
599
+ fb = np.asarray(fixed_bounds, dtype=float)
600
+ if fb.shape == (2, 3):
601
+ bbox = fb
602
+ else:
603
+ raise ValueError
604
+ except Exception:
605
+ fixed_bounds = None
606
+
607
+ if fixed_bounds is None:
608
+ min_xyz = np.array([np.inf, np.inf, np.inf], dtype=float)
609
+ max_xyz = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
610
+ for mesh in meshes.values():
611
+ if mesh is None or len(mesh.vertices) == 0:
612
+ continue
613
+ v = mesh.vertices
614
+ min_xyz = np.minimum(min_xyz, v.min(axis=0))
615
+ max_xyz = np.maximum(max_xyz, v.max(axis=0))
616
+ bbox = np.vstack([min_xyz, max_xyz])
617
+
618
+ center = (bbox[1] + bbox[0]) / 2
619
+ diagonal = np.linalg.norm(bbox[1] - bbox[0])
620
+
621
+ # Camera distance
622
+ if projection_type.lower() == "orthographic":
623
+ distance = diagonal * 5
624
+ else:
625
+ distance = diagonal * 1.8 * distance_factor
626
+
627
+ # Define isometric anchor directions and derive constant elevation
628
+ # Anchors correspond to azimuths: 45°, 135°, 225°, 315°
629
+ anchor_azimuths = [np.pi / 4, 3 * np.pi / 4, 5 * np.pi / 4, 7 * np.pi / 4]
630
+ if close_loop:
631
+ anchor_azimuths.append(anchor_azimuths[0] + 2 * np.pi)
632
+
633
+ # Use the canonical iso direction (1,1,0.7) to compute elevation angle
634
+ iso_dir = np.array([1.0, 1.0, 0.7], dtype=float)
635
+ iso_dir = iso_dir / np.linalg.norm(iso_dir)
636
+ horiz_len = np.sqrt(iso_dir[0] ** 2 + iso_dir[1] ** 2)
637
+ elevation = np.arctan2(iso_dir[2], horiz_len) # radians
638
+ cos_elev = np.cos(elevation)
639
+ sin_elev = np.sin(elevation)
640
+
641
+ # Generate frames along segments between anchors
642
+ filenames = []
643
+ frame_idx = 0
644
+ num_segments = len(anchor_azimuths) - 1
645
+ for i in range(num_segments):
646
+ a0 = anchor_azimuths[i]
647
+ a1 = anchor_azimuths[i + 1]
648
+ for k in range(frames_per_segment):
649
+ t = k / float(frames_per_segment)
650
+ az = (1.0 - t) * a0 + t * a1
651
+ direction = np.array([
652
+ cos_elev * np.cos(az),
653
+ cos_elev * np.sin(az),
654
+ sin_elev
655
+ ], dtype=float)
656
+ direction = direction / np.linalg.norm(direction)
657
+ camera_pos = center + direction * distance
658
+ camera_tuple = [camera_pos, center, (0, 0, 1)]
659
+
660
+ plotter = pv.Plotter(off_screen=True, window_size=image_size if image_size is not None else None)
661
+ if projection_type.lower() == "orthographic":
662
+ plotter.enable_parallel_projection()
663
+ plotter.camera.parallel_scale = diagonal * 0.4 * distance_factor
664
+ elif projection_type.lower() != "perspective":
665
+ print(f"Warning: Unknown projection_type '{projection_type}'. Using perspective projection.")
666
+
667
+ for _, pv_mesh in pv_meshes.items():
668
+ has_colors = 'colors' in pv_mesh.cell_data
669
+ plotter.add_mesh(pv_mesh, rgb=True, scalars='colors' if has_colors else None)
670
+
671
+ plotter.camera_position = camera_tuple
672
+ filename = os.path.join(output_directory, f"{file_prefix}_{frame_idx:04d}.png")
673
+ plotter.screenshot(filename)
674
+ filenames.append(filename)
675
+ plotter.close()
676
+ frame_idx += 1
677
+
678
+ return filenames
679
+
680
+ class PyVistaRenderer:
681
+ """Renderer that uses PyVista to produce multi-view images from meshes or VoxCity."""
682
+
683
+ def render_city(self, city: VoxCity, projection_type: str = "perspective", distance_factor: float = 1.0,
684
+ output_directory: str = "output", voxel_color_map: "str|dict" = "default",
685
+ *, # static rendering specific toggles
686
+ rotation: bool = False,
687
+ rotation_frames_per_segment: int = 60,
688
+ rotation_close_loop: bool = False,
689
+ rotation_file_prefix: str = "city_rotation",
690
+ image_size: "tuple[int, int] | None" = None,
691
+ fixed_scene_bounds_real: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None,
692
+ building_sim_mesh=None, building_value_name: str = 'svf_values',
693
+ building_colormap: str = 'viridis', building_vmin=None, building_vmax=None,
694
+ building_nan_color: str = 'gray', building_opacity: float = 1.0,
695
+ render_voxel_buildings: bool = False,
696
+ ground_sim_grid=None, ground_dem_grid=None,
697
+ ground_z_offset: float | None = None, ground_view_point_height: float | None = None,
698
+ ground_colormap: str = 'viridis', ground_vmin=None, ground_vmax=None):
699
+ """
700
+ Render city to static images with optional simulation overlays.
701
+
702
+ Parameters
703
+ ----------
704
+ city : VoxCity
705
+ VoxCity object to render
706
+ projection_type : str
707
+ "perspective" or "orthographic"
708
+ distance_factor : float
709
+ Camera distance multiplier
710
+ output_directory : str
711
+ Directory to save rendered images
712
+ voxel_color_map : str or dict
713
+ Color mapping for voxel classes
714
+ rotation : bool
715
+ If True, generate rotating isometric frames instead of multi-view snapshots.
716
+ rotation_frames_per_segment : int
717
+ Number of frames between each isometric anchor when rotation=True.
718
+ rotation_close_loop : bool
719
+ If True, returns smoothly to the starting anchor when rotation=True.
720
+ rotation_file_prefix : str
721
+ Filename prefix for rotation frames when rotation=True.
722
+ image_size : (int, int) or None
723
+ Static rendering output image size (width, height). If None, uses default.
724
+ building_sim_mesh : trimesh.Trimesh, optional
725
+ Building mesh with simulation results
726
+ building_value_name : str
727
+ Metadata key for building values
728
+ building_colormap : str
729
+ Colormap for building values
730
+ building_vmin, building_vmax : float, optional
731
+ Color scale limits for buildings
732
+ building_nan_color : str
733
+ Color for NaN values
734
+ building_opacity : float
735
+ Building mesh opacity
736
+ render_voxel_buildings : bool
737
+ Whether to render voxel buildings when building_sim_mesh is provided
738
+ ground_sim_grid : np.ndarray, optional
739
+ Ground-level simulation grid
740
+ ground_dem_grid : np.ndarray, optional
741
+ DEM grid for ground surface positioning
742
+ ground_z_offset : float, optional
743
+ Height offset for ground surface
744
+ ground_view_point_height : float, optional
745
+ Alternative height parameter
746
+ ground_colormap : str
747
+ Colormap for ground values
748
+ ground_vmin, ground_vmax : float, optional
749
+ Color scale limits for ground
750
+ """
751
+ if pv is None:
752
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
753
+
754
+ meshsize = city.voxels.meta.meshsize
755
+ trimesh_dict = {}
756
+
757
+ # Build voxel meshes (always generate to show ground, trees, etc.)
758
+ collection = MeshBuilder.from_voxel_grid(city.voxels, meshsize=meshsize, voxel_color_map=voxel_color_map)
759
+ for key, mm in collection.items.items():
760
+ if mm.vertices.size == 0 or mm.faces.size == 0:
761
+ continue
762
+ # Skip building voxels if we have building_sim_mesh and don't want to render both
763
+ if not render_voxel_buildings and building_sim_mesh is not None and int(key) == -3:
764
+ continue
765
+ tri = trimesh.Trimesh(vertices=mm.vertices, faces=mm.faces, process=False)
766
+ if mm.colors is not None:
767
+ tri.visual.face_colors = mm.colors
768
+ trimesh_dict[key] = tri
769
+
770
+ # Add building simulation mesh overlay
771
+ if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
772
+ Vb = np.asarray(building_sim_mesh.vertices)
773
+ Fb = np.asarray(building_sim_mesh.faces)
774
+
775
+ # Get simulation values from metadata
776
+ values = None
777
+ if hasattr(building_sim_mesh, 'metadata') and isinstance(building_sim_mesh.metadata, dict):
778
+ values = building_sim_mesh.metadata.get(building_value_name)
779
+
780
+ if values is not None:
781
+ values = np.asarray(values)
782
+
783
+ # Determine if values are per-face or per-vertex
784
+ face_vals = None
785
+ if len(values) == len(Fb):
786
+ face_vals = values.astype(float)
787
+ elif len(values) == len(Vb):
788
+ vals_v = values.astype(float)
789
+ face_vals = np.nanmean(vals_v[Fb], axis=1)
790
+
791
+ if face_vals is not None:
792
+ # Apply colormap
793
+ finite = np.isfinite(face_vals)
794
+ vmin_b = building_vmin if building_vmin is not None else (float(np.nanmin(face_vals[finite])) if np.any(finite) else 0.0)
795
+ vmax_b = building_vmax if building_vmax is not None else (float(np.nanmax(face_vals[finite])) if np.any(finite) else 1.0)
796
+ norm_b = mcolors.Normalize(vmin=vmin_b, vmax=vmax_b)
797
+ cmap_b = cm.get_cmap(building_colormap)
798
+
799
+ colors_rgba = np.zeros((len(Fb), 4), dtype=np.uint8)
800
+ if np.any(finite):
801
+ colors_float = cmap_b(norm_b(face_vals[finite]))
802
+ colors_rgba[finite] = (colors_float * 255).astype(np.uint8)
803
+
804
+ # Handle NaN values
805
+ nan_rgba = np.array(mcolors.to_rgba(building_nan_color))
806
+ colors_rgba[~finite] = (nan_rgba * 255).astype(np.uint8)
807
+
808
+ # Create trimesh with colors
809
+ building_tri = trimesh.Trimesh(vertices=Vb, faces=Fb, process=False)
810
+ building_tri.visual.face_colors = colors_rgba
811
+ trimesh_dict['building_sim'] = building_tri
812
+ else:
813
+ # No values, just add the mesh with default color
814
+ building_tri = trimesh.Trimesh(vertices=Vb, faces=Fb, process=False)
815
+ trimesh_dict['building_sim'] = building_tri
816
+
817
+ # Add ground simulation surface overlay
818
+ if ground_sim_grid is not None and ground_dem_grid is not None:
819
+ z_off = ground_z_offset if ground_z_offset is not None else ground_view_point_height
820
+ try:
821
+ z_off = float(z_off) if z_off is not None else 1.5
822
+ except Exception:
823
+ z_off = 1.5
824
+
825
+ # Snap to grid
826
+ try:
827
+ z_off = (z_off // meshsize + 1.0) * meshsize
828
+ except Exception:
829
+ pass
830
+
831
+ # Normalize DEM
832
+ try:
833
+ dem_norm = np.asarray(ground_dem_grid, dtype=float)
834
+ dem_norm = dem_norm - np.nanmin(dem_norm)
835
+ except Exception:
836
+ dem_norm = ground_dem_grid
837
+
838
+ # Determine color range
839
+ sim_vals = np.asarray(ground_sim_grid, dtype=float)
840
+ finite = np.isfinite(sim_vals)
841
+ vmin_g = ground_vmin if ground_vmin is not None else (float(np.nanmin(sim_vals[finite])) if np.any(finite) else 0.0)
842
+ vmax_g = ground_vmax if ground_vmax is not None else (float(np.nanmax(sim_vals[finite])) if np.any(finite) else 1.0)
843
+
844
+ # Create ground simulation mesh
845
+ sim_mesh = create_sim_surface_mesh(
846
+ ground_sim_grid,
847
+ dem_norm,
848
+ meshsize=meshsize,
849
+ z_offset=z_off,
850
+ cmap_name=ground_colormap,
851
+ vmin=vmin_g,
852
+ vmax=vmax_g,
853
+ )
854
+
855
+ if sim_mesh is not None and getattr(sim_mesh, 'vertices', None) is not None:
856
+ trimesh_dict['ground_sim'] = sim_mesh
857
+
858
+ os.makedirs(output_directory, exist_ok=True)
859
+ if rotation:
860
+ return create_rotation_view_scene(
861
+ trimesh_dict,
862
+ output_directory=output_directory,
863
+ projection_type=projection_type,
864
+ distance_factor=distance_factor,
865
+ frames_per_segment=rotation_frames_per_segment,
866
+ close_loop=rotation_close_loop,
867
+ file_prefix=rotation_file_prefix,
868
+ image_size=image_size,
869
+ fixed_bounds=fixed_scene_bounds_real,
870
+ )
871
+ else:
872
+ return create_multi_view_scene(
873
+ trimesh_dict,
874
+ output_directory=output_directory,
875
+ projection_type=projection_type,
876
+ distance_factor=distance_factor,
877
+ image_size=image_size,
878
+ fixed_bounds=fixed_scene_bounds_real,
879
+ )
880
+
881
+
882
+
883
+ def visualize_voxcity(
884
+ city: VoxCity,
885
+ mode: str = "interactive",
886
+ *,
887
+ # Common options
888
+ voxel_color_map: "str|dict" = "default",
889
+ classes=None,
890
+ title: str | None = None,
891
+ # Interactive (Plotly) options
892
+ opacity: float = 1.0,
893
+ max_dimension: int = 160,
894
+ downsample: int | None = None,
895
+ show: bool = True,
896
+ return_fig: bool = False,
897
+ # Static (PyVista) options
898
+ output_directory: str = "output",
899
+ projection_type: str = "perspective",
900
+ distance_factor: float = 1.0,
901
+ rotation: bool = False,
902
+ rotation_frames_per_segment: int = 60,
903
+ rotation_close_loop: bool = False,
904
+ rotation_file_prefix: str = "city_rotation",
905
+ image_size: "tuple[int, int] | None" = None,
906
+ fixed_scene_bounds_real: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None,
907
+ # Building simulation overlay options
908
+ building_sim_mesh=None,
909
+ building_value_name: str = 'svf_values',
910
+ building_colormap: str = 'viridis',
911
+ building_vmin: float | None = None,
912
+ building_vmax: float | None = None,
913
+ building_nan_color: str = 'gray',
914
+ building_opacity: float = 1.0,
915
+ building_shaded: bool = False,
916
+ render_voxel_buildings: bool = False,
917
+ # Ground simulation surface overlay options
918
+ ground_sim_grid=None,
919
+ ground_dem_grid=None,
920
+ ground_z_offset: float | None = None,
921
+ ground_view_point_height: float | None = None,
922
+ ground_colormap: str = 'viridis',
923
+ ground_vmin: float | None = None,
924
+ ground_vmax: float | None = None,
925
+ sim_surface_opacity: float = 0.95,
926
+ ground_shaded: bool = False,
927
+ ):
928
+ """
929
+ Visualize a VoxCity object with optional simulation result overlays.
930
+
931
+ Parameters
932
+ ----------
933
+ city : VoxCity
934
+ VoxCity object to visualize
935
+ mode : str, default="interactive"
936
+ Visualization mode: "interactive" (Plotly) or "static" (PyVista)
937
+
938
+ Common Options
939
+ --------------
940
+ voxel_color_map : str or dict, default="default"
941
+ Color mapping for voxel classes
942
+ classes : list, optional
943
+ Specific voxel classes to render
944
+ title : str, optional
945
+ Plot title
946
+ image_size : (int, int) or None, default=None
947
+ Unified image size (width, height) applied across modes.
948
+ - Interactive: overrides width/height below when provided.
949
+ - Static (including rotation): sets PyVista window size for screenshots.
950
+
951
+ Interactive Mode Options (Plotly)
952
+ ----------------------------------
953
+ opacity : float, default=1.0
954
+ Voxel opacity (0-1)
955
+ max_dimension : int, default=160
956
+ Maximum grid dimension before downsampling
957
+ downsample : int, optional
958
+ Manual downsampling stride
959
+ show : bool, default=True
960
+ Whether to display the plot
961
+ return_fig : bool, default=False
962
+ Whether to return the figure object
963
+
964
+ Static Mode Options (PyVista)
965
+ ------------------------------
966
+ output_directory : str, default="output"
967
+ Directory for saving rendered images
968
+ projection_type : str, default="perspective"
969
+ Camera projection: "perspective" or "orthographic"
970
+ distance_factor : float, default=1.0
971
+ Camera distance multiplier
972
+ rotation : bool, default=False
973
+ If True, generate rotating isometric frames instead of multi-view snapshots
974
+ rotation_frames_per_segment : int, default=60
975
+ Frames between each isometric anchor when rotation=True
976
+ rotation_close_loop : bool, default=False
977
+ If True, continue frames to return to start when rotation=True
978
+ rotation_file_prefix : str, default="city_rotation"
979
+ Filename prefix for rotation frames when rotation=True
980
+ image_size : (int, int) or None, default=None
981
+ Static rendering output image size (width, height). If None, uses default.
982
+
983
+ Building Simulation Overlay Options
984
+ ------------------------------------
985
+ building_sim_mesh : trimesh.Trimesh, optional
986
+ Building mesh with simulation results in metadata.
987
+ Typically created by get_surface_view_factor() or get_building_solar_irradiance().
988
+ building_value_name : str, default='svf_values'
989
+ Metadata key to use for coloring (e.g., 'svf_values', 'global', 'direct', 'diffuse')
990
+ building_colormap : str, default='viridis'
991
+ Matplotlib colormap for building values
992
+ building_vmin : float, optional
993
+ Minimum value for color scale
994
+ building_vmax : float, optional
995
+ Maximum value for color scale
996
+ building_nan_color : str, default='gray'
997
+ Color for NaN/invalid values
998
+ building_opacity : float, default=1.0
999
+ Building mesh opacity (0-1)
1000
+ building_shaded : bool, default=False
1001
+ Whether to apply shading to building mesh
1002
+ render_voxel_buildings : bool, default=False
1003
+ Whether to render voxel buildings when building_sim_mesh is provided
1004
+
1005
+ Ground Simulation Surface Overlay Options
1006
+ ------------------------------------------
1007
+ ground_sim_grid : np.ndarray, optional
1008
+ 2D array of ground-level simulation values (e.g., Green View Index, solar radiation).
1009
+ Should have the same shape as the city's 2D grids.
1010
+ ground_dem_grid : np.ndarray, optional
1011
+ 2D DEM array for positioning the ground simulation surface.
1012
+ If None, uses city.dem.elevation when ground_sim_grid is provided.
1013
+ ground_z_offset : float, optional
1014
+ Height offset for ground simulation surface above DEM
1015
+ ground_view_point_height : float, optional
1016
+ Alternative parameter for ground surface height (used if ground_z_offset is None)
1017
+ ground_colormap : str, default='viridis'
1018
+ Matplotlib colormap for ground values
1019
+ ground_vmin : float, optional
1020
+ Minimum value for color scale
1021
+ ground_vmax : float, optional
1022
+ Maximum value for color scale
1023
+ sim_surface_opacity : float, default=0.95
1024
+ Ground simulation surface opacity (0-1)
1025
+ ground_shaded : bool, default=False
1026
+ Whether to apply shading to ground surface
1027
+
1028
+ Returns
1029
+ -------
1030
+ For mode="interactive":
1031
+ plotly.graph_objects.Figure or None
1032
+ Returns Figure if return_fig=True, otherwise None
1033
+
1034
+ For mode="static":
1035
+ list of (view_name, filepath) tuples
1036
+ List of rendered view names and their file paths
1037
+
1038
+ Examples
1039
+ --------
1040
+ Basic visualization:
1041
+ >>> visualize_voxcity(city, mode="interactive")
1042
+
1043
+ With building solar irradiance results:
1044
+ >>> building_mesh = get_building_solar_irradiance(city, ...)
1045
+ >>> visualize_voxcity(city, mode="interactive",
1046
+ ... building_sim_mesh=building_mesh,
1047
+ ... building_value_name='global')
1048
+
1049
+ With ground-level Green View Index:
1050
+ >>> visualize_voxcity(city, mode="interactive",
1051
+ ... ground_sim_grid=gvi_array,
1052
+ ... ground_colormap='YlGn')
1053
+
1054
+ Static rendering with simulation overlays:
1055
+ >>> visualize_voxcity(city, mode="static",
1056
+ ... building_sim_mesh=svf_mesh,
1057
+ ... output_directory="renders")
1058
+ """
1059
+ if not isinstance(mode, str):
1060
+ raise ValueError("mode must be a string: 'interactive' or 'static'")
1061
+
1062
+ mode_l = mode.lower().strip()
1063
+ meshsize = getattr(city.voxels.meta, "meshsize", None)
1064
+
1065
+ # Auto-fill ground_dem_grid from city if ground_sim_grid is provided but ground_dem_grid is not
1066
+ if ground_sim_grid is not None and ground_dem_grid is None:
1067
+ ground_dem_grid = getattr(city.dem, "elevation", None)
1068
+
1069
+ if mode_l == "interactive":
1070
+ voxel_array = getattr(city.voxels, "classes", None)
1071
+ # Build kwargs to optionally pass width/height when image_size is provided
1072
+ size_kwargs = {}
1073
+ if image_size is not None:
1074
+ try:
1075
+ size_kwargs = {"width": int(image_size[0]), "height": int(image_size[1])}
1076
+ except Exception:
1077
+ size_kwargs = {}
1078
+ return visualize_voxcity_plotly(
1079
+ voxel_array=voxel_array,
1080
+ meshsize=meshsize,
1081
+ classes=classes,
1082
+ voxel_color_map=voxel_color_map,
1083
+ opacity=opacity,
1084
+ max_dimension=max_dimension,
1085
+ downsample=downsample,
1086
+ title=title,
1087
+ show=show,
1088
+ return_fig=return_fig,
1089
+ **size_kwargs,
1090
+ # Building simulation overlay
1091
+ building_sim_mesh=building_sim_mesh,
1092
+ building_value_name=building_value_name,
1093
+ building_colormap=building_colormap,
1094
+ building_vmin=building_vmin,
1095
+ building_vmax=building_vmax,
1096
+ building_nan_color=building_nan_color,
1097
+ building_opacity=building_opacity,
1098
+ building_shaded=building_shaded,
1099
+ render_voxel_buildings=render_voxel_buildings,
1100
+ # Ground simulation surface overlay
1101
+ ground_sim_grid=ground_sim_grid,
1102
+ ground_dem_grid=ground_dem_grid,
1103
+ ground_z_offset=ground_z_offset,
1104
+ ground_view_point_height=ground_view_point_height,
1105
+ ground_colormap=ground_colormap,
1106
+ ground_vmin=ground_vmin,
1107
+ ground_vmax=ground_vmax,
1108
+ sim_surface_opacity=sim_surface_opacity,
1109
+ ground_shaded=ground_shaded,
1110
+ )
1111
+
1112
+ if mode_l == "static":
1113
+ renderer = PyVistaRenderer()
1114
+ return renderer.render_city(
1115
+ city,
1116
+ projection_type=projection_type,
1117
+ distance_factor=distance_factor,
1118
+ output_directory=output_directory,
1119
+ voxel_color_map=voxel_color_map,
1120
+ rotation=rotation,
1121
+ rotation_frames_per_segment=rotation_frames_per_segment,
1122
+ rotation_close_loop=rotation_close_loop,
1123
+ rotation_file_prefix=rotation_file_prefix,
1124
+ image_size=image_size,
1125
+ fixed_scene_bounds_real=fixed_scene_bounds_real,
1126
+ # Pass simulation overlay parameters
1127
+ building_sim_mesh=building_sim_mesh,
1128
+ building_value_name=building_value_name,
1129
+ building_colormap=building_colormap,
1130
+ building_vmin=building_vmin,
1131
+ building_vmax=building_vmax,
1132
+ building_nan_color=building_nan_color,
1133
+ building_opacity=building_opacity,
1134
+ render_voxel_buildings=render_voxel_buildings,
1135
+ ground_sim_grid=ground_sim_grid,
1136
+ ground_dem_grid=ground_dem_grid,
1137
+ ground_z_offset=ground_z_offset,
1138
+ ground_view_point_height=ground_view_point_height,
1139
+ ground_colormap=ground_colormap,
1140
+ ground_vmin=ground_vmin,
1141
+ ground_vmax=ground_vmax,
1142
+ )
1143
+
1144
+ raise ValueError("Unknown mode. Use 'interactive' or 'static'.")
1145
+