voxcity 0.7.0__py3-none-any.whl → 1.0.13__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 +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -1,928 +1,1146 @@
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
-
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
+ # Max over pooling dims (1, 3, 5) - must do all at once or adjust indices after each reduction
177
+ a = a.max(axis=(1, 3, 5))
178
+ return a
179
+ occluder = _bool_max_pool_3d((voxel_array != 0), stride)
180
+ else:
181
+ occluder = (vox != 0)
182
+
183
+ def exposed_face_masks(occ, occ_any):
184
+ p = np.pad(occ_any, ((0,1),(0,0),(0,0)), constant_values=False)
185
+ posx = occ & (~p[1:,:,:])
186
+ p = np.pad(occ_any, ((1,0),(0,0),(0,0)), constant_values=False)
187
+ negx = occ & (~p[:-1,:,:])
188
+ p = np.pad(occ_any, ((0,0),(0,1),(0,0)), constant_values=False)
189
+ posy = occ & (~p[:,1:,:])
190
+ p = np.pad(occ_any, ((0,0),(1,0),(0,0)), constant_values=False)
191
+ negy = occ & (~p[:,:-1,:])
192
+ p = np.pad(occ_any, ((0,0),(0,0),(0,1)), constant_values=False)
193
+ posz = occ & (~p[:,:,1:])
194
+ p = np.pad(occ_any, ((0,0),(0,0),(1,0)), constant_values=False)
195
+ negz = occ & (~p[:,:,:-1])
196
+ return posx, negx, posy, negy, posz, negz
197
+
198
+ fig = go.Figure()
199
+
200
+ def add_faces(mask, plane, color_rgb):
201
+ idx = np.argwhere(mask)
202
+ if idx.size == 0:
203
+ return
204
+ xi, yi, zi = idx[:,0], idx[:,1], idx[:,2]
205
+ xc = x[xi]; yc = y[yi]; zc = z[zi]
206
+ x0, x1 = xc - dx/2.0, xc + dx/2.0
207
+ y0, y1 = yc - dy/2.0, yc + dy/2.0
208
+ z0, z1 = zc - dz/2.0, zc + dz/2.0
209
+
210
+ if plane == '+x':
211
+ vx = np.stack([x1, x1, x1, x1], axis=1)
212
+ vy = np.stack([y0, y1, y1, y0], axis=1)
213
+ vz = np.stack([z0, z0, z1, z1], axis=1)
214
+ elif plane == '-x':
215
+ vx = np.stack([x0, x0, x0, x0], axis=1)
216
+ vy = np.stack([y0, y1, y1, y0], axis=1)
217
+ vz = np.stack([z1, z1, z0, z0], axis=1)
218
+ elif plane == '+y':
219
+ vx = np.stack([x0, x1, x1, x0], axis=1)
220
+ vy = np.stack([y1, y1, y1, y1], axis=1)
221
+ vz = np.stack([z0, z0, z1, z1], axis=1)
222
+ elif plane == '-y':
223
+ vx = np.stack([x0, x1, x1, x0], axis=1)
224
+ vy = np.stack([y0, y0, y0, y0], axis=1)
225
+ vz = np.stack([z1, z1, z0, z0], axis=1)
226
+ elif plane == '+z':
227
+ vx = np.stack([x0, x1, x1, x0], axis=1)
228
+ vy = np.stack([y0, y0, y1, y1], axis=1)
229
+ vz = np.stack([z1, z1, z1, z1], axis=1)
230
+ elif plane == '-z':
231
+ vx = np.stack([x0, x1, x1, x0], axis=1)
232
+ vy = np.stack([y1, y1, y0, y0], axis=1)
233
+ vz = np.stack([z0, z0, z0, z0], axis=1)
234
+ else:
235
+ return
236
+
237
+ V = np.column_stack([vx.reshape(-1), vy.reshape(-1), vz.reshape(-1)])
238
+ n = idx.shape[0]
239
+ starts = np.arange(0, 4*n, 4, dtype=np.int32)
240
+ tris = np.vstack([
241
+ np.stack([starts, starts+1, starts+2], axis=1),
242
+ np.stack([starts, starts+2, starts+3], axis=1)
243
+ ])
244
+
245
+ lighting = dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
246
+ cx = (x.min() + x.max()) * 0.5 if len(x) > 0 else 0.0
247
+ cy = (y.min() + y.max()) * 0.5 if len(y) > 0 else 0.0
248
+ cz = (z.min() + z.max()) * 0.5 if len(z) > 0 else 0.0
249
+ lx = cx + (x.max() - x.min() + dx) * 0.9
250
+ ly = cy + (y.max() - y.min() + dy) * 0.6
251
+ lz = cz + (z.max() - z.min() + dz) * 1.4
252
+
253
+ fig.add_trace(
254
+ go.Mesh3d(
255
+ x=V[:,0], y=V[:,1], z=V[:,2],
256
+ i=tris[:,0], j=tris[:,1], k=tris[:,2],
257
+ color=_rgb_tuple_to_plotly_color(color_rgb),
258
+ opacity=float(opacity),
259
+ flatshading=False,
260
+ lighting=lighting,
261
+ lightposition=dict(x=lx, y=ly, z=lx),
262
+ name=f"{plane}"
263
+ )
264
+ )
265
+
266
+ # Draw voxel faces
267
+ if vox is not None and classes_to_draw:
268
+ for cls in classes_to_draw:
269
+ if not np.any(vox == cls):
270
+ continue
271
+ occ = (vox == cls)
272
+ p = np.pad(occluder, ((0,1),(0,0),(0,0)), constant_values=False); posx = occ & (~p[1:,:,:])
273
+ p = np.pad(occluder, ((1,0),(0,0),(0,0)), constant_values=False); negx = occ & (~p[:-1,:,:])
274
+ p = np.pad(occluder, ((0,0),(0,1),(0,0)), constant_values=False); posy = occ & (~p[:,1:,:])
275
+ p = np.pad(occluder, ((0,0),(1,0),(0,0)), constant_values=False); negy = occ & (~p[:,:-1,:])
276
+ p = np.pad(occluder, ((0,0),(0,0),(0,1)), constant_values=False); posz = occ & (~p[:,:,1:])
277
+ p = np.pad(occluder, ((0,0),(0,0),(1,0)), constant_values=False); negz = occ & (~p[:,:,:-1])
278
+ color_rgb = vox_dict.get(int(cls), [128,128,128])
279
+ add_faces(posx, '+x', color_rgb)
280
+ add_faces(negx, '-x', color_rgb)
281
+ add_faces(posy, '+y', color_rgb)
282
+ add_faces(negy, '-y', color_rgb)
283
+ add_faces(posz, '+z', color_rgb)
284
+ add_faces(negz, '-z', color_rgb)
285
+
286
+ # Building overlay
287
+ if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
288
+ Vb = np.asarray(building_sim_mesh.vertices)
289
+ Fb = np.asarray(building_sim_mesh.faces)
290
+ values = None
291
+ if hasattr(building_sim_mesh, 'metadata') and isinstance(building_sim_mesh.metadata, dict):
292
+ values = building_sim_mesh.metadata.get(building_value_name)
293
+ if values is not None:
294
+ values = np.asarray(values)
295
+
296
+ face_vals = None
297
+ if values is not None and len(values) == len(Fb):
298
+ face_vals = values.astype(float)
299
+ elif values is not None and len(values) == len(Vb):
300
+ vals_v = values.astype(float)
301
+ face_vals = np.nanmean(vals_v[Fb], axis=1)
302
+
303
+ facecolor = None
304
+ if face_vals is not None:
305
+ finite = np.isfinite(face_vals)
306
+ vmin_b = building_vmin if building_vmin is not None else (float(np.nanmin(face_vals[finite])) if np.any(finite) else 0.0)
307
+ vmax_b = building_vmax if building_vmax is not None else (float(np.nanmax(face_vals[finite])) if np.any(finite) else 1.0)
308
+ norm_b = mcolors.Normalize(vmin=vmin_b, vmax=vmax_b)
309
+ cmap_b = cm.get_cmap(building_colormap)
310
+ colors_rgba = np.zeros((len(Fb), 4), dtype=float)
311
+ colors_rgba[finite] = cmap_b(norm_b(face_vals[finite]))
312
+ nan_rgba = np.array(mcolors.to_rgba(building_nan_color))
313
+ colors_rgba[~finite] = nan_rgba
314
+ facecolor = [f"rgb({int(255*c[0])},{int(255*c[1])},{int(255*c[2])})" for c in colors_rgba]
315
+
316
+ lighting_b = (dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
317
+ if building_shaded else dict(ambient=1.0, diffuse=0.0, specular=0.0, roughness=0.0, fresnel=0.0))
318
+
319
+ cx = float((Vb[:,0].min() + Vb[:,0].max()) * 0.5)
320
+ cy = float((Vb[:,1].min() + Vb[:,1].max()) * 0.5)
321
+ lx = cx + (Vb[:,0].max() - Vb[:,0].min() + meshsize) * 0.9
322
+ ly = cy + (Vb[:,1].max() - Vb[:,1].min() + meshsize) * 0.6
323
+ lz = float((Vb[:,2].min() + Vb[:,2].max()) * 0.5) + (Vb[:,2].max() - Vb[:,2].min() + meshsize) * 1.4
324
+
325
+ fig.add_trace(
326
+ go.Mesh3d(
327
+ x=Vb[:,0], y=Vb[:,1], z=Vb[:,2],
328
+ i=Fb[:,0], j=Fb[:,1], k=Fb[:,2],
329
+ facecolor=facecolor if facecolor is not None else None,
330
+ color=None if facecolor is not None else 'rgb(200,200,200)',
331
+ opacity=float(building_opacity),
332
+ flatshading=False,
333
+ lighting=lighting_b,
334
+ lightposition=dict(x=lx, y=ly, z=lz),
335
+ name=building_value_name if facecolor is not None else 'building_mesh'
336
+ )
337
+ )
338
+
339
+ if face_vals is not None:
340
+ colorscale_b = _mpl_cmap_to_plotly_colorscale(building_colormap)
341
+ fig.add_trace(
342
+ go.Scatter3d(
343
+ x=[None], y=[None], z=[None],
344
+ mode='markers',
345
+ marker=dict(size=0.1, color=[vmin_b, vmax_b], colorscale=colorscale_b, cmin=vmin_b, cmax=vmax_b,
346
+ colorbar=dict(title=building_value_name, len=0.5, y=0.8), showscale=True),
347
+ showlegend=False, hoverinfo='skip')
348
+ )
349
+
350
+ # Ground simulation surface overlay
351
+ if ground_sim_grid is not None and ground_dem_grid is not None:
352
+ sim_vals = np.asarray(ground_sim_grid, dtype=float)
353
+ finite = np.isfinite(sim_vals)
354
+ vmin_g = ground_vmin if ground_vmin is not None else (float(np.nanmin(sim_vals[finite])) if np.any(finite) else 0.0)
355
+ vmax_g = ground_vmax if ground_vmax is not None else (float(np.nanmax(sim_vals[finite])) if np.any(finite) else 1.0)
356
+ z_off = ground_z_offset if ground_z_offset is not None else ground_view_point_height
357
+ try:
358
+ z_off = float(z_off) if z_off is not None else 1.5
359
+ except Exception:
360
+ z_off = 1.5
361
+ try:
362
+ ms = float(meshsize)
363
+ z_off = (z_off // ms + 1.0) * ms
364
+ except Exception:
365
+ pass
366
+ try:
367
+ dem_norm = np.asarray(ground_dem_grid, dtype=float)
368
+ dem_norm = dem_norm - np.nanmin(dem_norm)
369
+ except Exception:
370
+ dem_norm = ground_dem_grid
371
+
372
+ sim_mesh = create_sim_surface_mesh(
373
+ ground_sim_grid,
374
+ dem_norm,
375
+ meshsize=meshsize,
376
+ z_offset=z_off,
377
+ cmap_name=ground_colormap,
378
+ vmin=vmin_g,
379
+ vmax=vmax_g,
380
+ )
381
+
382
+ if sim_mesh is not None and getattr(sim_mesh, 'vertices', None) is not None:
383
+ V = np.asarray(sim_mesh.vertices)
384
+ F = np.asarray(sim_mesh.faces)
385
+ facecolor = None
386
+ try:
387
+ colors_rgba = np.asarray(sim_mesh.visual.face_colors)
388
+ if colors_rgba.ndim == 2 and colors_rgba.shape[0] == len(F):
389
+ facecolor = [f"rgb({int(c[0])},{int(c[1])},{int(c[2])})" for c in colors_rgba]
390
+ except Exception:
391
+ facecolor = None
392
+
393
+ lighting = (dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
394
+ if ground_shaded else dict(ambient=1.0, diffuse=0.0, specular=0.0, roughness=0.0, fresnel=0.0))
395
+
396
+ cx = float((V[:,0].min() + V[:,0].max()) * 0.5)
397
+ cy = float((V[:,1].min() + V[:,1].max()) * 0.5)
398
+ lx = cx + (V[:,0].max() - V[:,0].min() + meshsize) * 0.9
399
+ ly = cy + (V[:,1].max() - V[:,1].min() + meshsize) * 0.6
400
+ lz = float((V[:,2].min() + V[:,2].max()) * 0.5) + (V[:,2].max() - V[:,2].min() + meshsize) * 1.4
401
+
402
+ fig.add_trace(
403
+ go.Mesh3d(
404
+ x=V[:,0], y=V[:,1], z=V[:,2],
405
+ i=F[:,0], j=F[:,1], k=F[:,2],
406
+ facecolor=facecolor,
407
+ color=None if facecolor is not None else 'rgb(200,200,200)',
408
+ opacity=float(sim_surface_opacity),
409
+ flatshading=False,
410
+ lighting=lighting,
411
+ lightposition=dict(x=lx, y=ly, z=lz),
412
+ name='sim_surface'
413
+ )
414
+ )
415
+
416
+ colorscale_g = _mpl_cmap_to_plotly_colorscale(ground_colormap)
417
+ fig.add_trace(
418
+ go.Scatter3d(
419
+ x=[None], y=[None], z=[None],
420
+ mode='markers',
421
+ marker=dict(size=0.1, color=[vmin_g, vmax_g], colorscale=colorscale_g, cmin=vmin_g, cmax=vmax_g,
422
+ colorbar=dict(title='ground', len=0.5, y=0.2), showscale=True),
423
+ showlegend=False, hoverinfo='skip')
424
+ )
425
+
426
+ fig.update_layout(
427
+ title=title,
428
+ width=width,
429
+ height=height,
430
+ scene=dict(
431
+ xaxis_title="X (m)",
432
+ yaxis_title="Y (m)",
433
+ zaxis_title="Z (m)",
434
+ aspectmode="data",
435
+ camera=dict(eye=dict(x=1.6, y=1.6, z=1.0)),
436
+ )
437
+ )
438
+
439
+ if show:
440
+ fig.show()
441
+ if return_fig:
442
+ return fig
443
+ return None
444
+
445
+
446
+ def create_multi_view_scene(meshes, output_directory="output", projection_type="perspective", distance_factor=1.0,
447
+ image_size: "tuple[int, int] | None" = None, fixed_bounds: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None):
448
+ """
449
+ Creates multiple rendered views of 3D city meshes from different camera angles.
450
+ """
451
+ if pv is None:
452
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
453
+ # NOTE: image_size is now supported via Plotter.window_size when invoked from renderer
454
+ pv_meshes = {}
455
+ for class_id, mesh in meshes.items():
456
+ if mesh is None or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
457
+ continue
458
+ faces = np.hstack([[3, *face] for face in mesh.faces])
459
+ pv_mesh = pv.PolyData(mesh.vertices, faces)
460
+ colors = getattr(mesh.visual, 'face_colors', None)
461
+ if colors is not None:
462
+ colors = np.asarray(colors)
463
+ if colors.size and colors.max() > 1:
464
+ colors = colors / 255.0
465
+ pv_mesh.cell_data['colors'] = colors
466
+ pv_meshes[class_id] = pv_mesh
467
+
468
+ if fixed_bounds is not None:
469
+ try:
470
+ fb = np.asarray(fixed_bounds, dtype=float)
471
+ if fb.shape == (2, 3):
472
+ bbox = fb
473
+ else:
474
+ raise ValueError
475
+ except Exception:
476
+ # Fallback to computed bounds if provided value is invalid
477
+ fixed_bounds = None
478
+
479
+ if fixed_bounds is None:
480
+ min_xyz = np.array([np.inf, np.inf, np.inf], dtype=float)
481
+ max_xyz = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
482
+ for mesh in meshes.values():
483
+ if mesh is None or len(mesh.vertices) == 0:
484
+ continue
485
+ v = mesh.vertices
486
+ min_xyz = np.minimum(min_xyz, v.min(axis=0))
487
+ max_xyz = np.maximum(max_xyz, v.max(axis=0))
488
+ bbox = np.vstack([min_xyz, max_xyz])
489
+
490
+ center = (bbox[1] + bbox[0]) / 2
491
+ diagonal = np.linalg.norm(bbox[1] - bbox[0])
492
+
493
+ if projection_type.lower() == "orthographic":
494
+ distance = diagonal * 5
495
+ else:
496
+ distance = diagonal * 1.8 * distance_factor
497
+
498
+ iso_angles = {
499
+ 'iso_front_right': (1, 1, 0.7),
500
+ 'iso_front_left': (-1, 1, 0.7),
501
+ 'iso_back_right': (1, -1, 0.7),
502
+ 'iso_back_left': (-1, -1, 0.7)
503
+ }
504
+
505
+ camera_positions = {}
506
+ for name, direction in iso_angles.items():
507
+ direction = np.array(direction)
508
+ direction = direction / np.linalg.norm(direction)
509
+ camera_pos = center + direction * distance
510
+ camera_positions[name] = [camera_pos, center, (0, 0, 1)]
511
+
512
+ ortho_views = {
513
+ 'xy_top': [center + np.array([0, 0, distance]), center, (-1, 0, 0)],
514
+ 'yz_right': [center + np.array([distance, 0, 0]), center, (0, 0, 1)],
515
+ 'xz_front': [center + np.array([0, distance, 0]), center, (0, 0, 1)],
516
+ 'yz_left': [center + np.array([-distance, 0, 0]), center, (0, 0, 1)],
517
+ 'xz_back': [center + np.array([0, -distance, 0]), center, (0, 0, 1)]
518
+ }
519
+ camera_positions.update(ortho_views)
520
+
521
+ images = []
522
+ for view_name, camera_pos in camera_positions.items():
523
+ plotter = pv.Plotter(off_screen=True, window_size=image_size if image_size is not None else None)
524
+ if projection_type.lower() == "orthographic":
525
+ plotter.enable_parallel_projection()
526
+ plotter.camera.parallel_scale = diagonal * 0.4 * distance_factor
527
+ elif projection_type.lower() != "perspective":
528
+ print(f"Warning: Unknown projection_type '{projection_type}'. Using perspective projection.")
529
+ for class_id, pv_mesh in pv_meshes.items():
530
+ has_colors = 'colors' in pv_mesh.cell_data
531
+ plotter.add_mesh(pv_mesh, rgb=True, scalars='colors' if has_colors else None)
532
+ plotter.camera_position = camera_pos
533
+ filename = f'{output_directory}/city_view_{view_name}.png'
534
+ plotter.screenshot(filename)
535
+ images.append((view_name, filename))
536
+ plotter.close()
537
+ return images
538
+
539
+
540
+ def create_rotation_view_scene(
541
+ meshes,
542
+ output_directory: str = "output",
543
+ projection_type: str = "perspective",
544
+ distance_factor: float = 1.0,
545
+ frames_per_segment: int = 60,
546
+ close_loop: bool = False,
547
+ file_prefix: str = "city_rotation",
548
+ image_size: "tuple[int, int] | None" = None,
549
+ fixed_bounds: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None,
550
+ ):
551
+ """
552
+ Creates a sequence of rendered frames forming a smooth isometric rotation that
553
+ passes through: iso_front_right -> iso_front_left -> iso_back_left -> iso_back_right.
554
+
555
+ Parameters
556
+ ----------
557
+ meshes : dict[Any, trimesh.Trimesh]
558
+ Dictionary of trimesh meshes keyed by class/label.
559
+ output_directory : str
560
+ Directory to save frames.
561
+ projection_type : str
562
+ "perspective" or "orthographic".
563
+ distance_factor : float
564
+ Camera distance multiplier.
565
+ frames_per_segment : int
566
+ Number of frames between each consecutive isometric anchor.
567
+ close_loop : bool
568
+ If True, also generates frames to return from iso_back_right to iso_front_right.
569
+ file_prefix : str
570
+ Prefix for saved frame filenames.
571
+
572
+ Returns
573
+ -------
574
+ list[str]
575
+ List of saved frame file paths in order.
576
+ """
577
+ if pv is None:
578
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
579
+
580
+ os.makedirs(output_directory, exist_ok=True)
581
+
582
+ # Prepare PyVista meshes
583
+ pv_meshes = {}
584
+ for class_id, mesh in meshes.items():
585
+ if mesh is None or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
586
+ continue
587
+ faces = np.hstack([[3, *face] for face in mesh.faces])
588
+ pv_mesh = pv.PolyData(mesh.vertices, faces)
589
+ colors = getattr(mesh.visual, 'face_colors', None)
590
+ if colors is not None:
591
+ colors = np.asarray(colors)
592
+ if colors.size and colors.max() > 1:
593
+ colors = colors / 255.0
594
+ pv_mesh.cell_data['colors'] = colors
595
+ pv_meshes[class_id] = pv_mesh
596
+
597
+ # Compute scene bounds
598
+ if fixed_bounds is not None:
599
+ try:
600
+ fb = np.asarray(fixed_bounds, dtype=float)
601
+ if fb.shape == (2, 3):
602
+ bbox = fb
603
+ else:
604
+ raise ValueError
605
+ except Exception:
606
+ fixed_bounds = None
607
+
608
+ if fixed_bounds is None:
609
+ min_xyz = np.array([np.inf, np.inf, np.inf], dtype=float)
610
+ max_xyz = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
611
+ for mesh in meshes.values():
612
+ if mesh is None or len(mesh.vertices) == 0:
613
+ continue
614
+ v = mesh.vertices
615
+ min_xyz = np.minimum(min_xyz, v.min(axis=0))
616
+ max_xyz = np.maximum(max_xyz, v.max(axis=0))
617
+ bbox = np.vstack([min_xyz, max_xyz])
618
+
619
+ center = (bbox[1] + bbox[0]) / 2
620
+ diagonal = np.linalg.norm(bbox[1] - bbox[0])
621
+
622
+ # Camera distance
623
+ if projection_type.lower() == "orthographic":
624
+ distance = diagonal * 5
625
+ else:
626
+ distance = diagonal * 1.8 * distance_factor
627
+
628
+ # Define isometric anchor directions and derive constant elevation
629
+ # Anchors correspond to azimuths: 45°, 135°, 225°, 315°
630
+ anchor_azimuths = [np.pi / 4, 3 * np.pi / 4, 5 * np.pi / 4, 7 * np.pi / 4]
631
+ if close_loop:
632
+ anchor_azimuths.append(anchor_azimuths[0] + 2 * np.pi)
633
+
634
+ # Use the canonical iso direction (1,1,0.7) to compute elevation angle
635
+ iso_dir = np.array([1.0, 1.0, 0.7], dtype=float)
636
+ iso_dir = iso_dir / np.linalg.norm(iso_dir)
637
+ horiz_len = np.sqrt(iso_dir[0] ** 2 + iso_dir[1] ** 2)
638
+ elevation = np.arctan2(iso_dir[2], horiz_len) # radians
639
+ cos_elev = np.cos(elevation)
640
+ sin_elev = np.sin(elevation)
641
+
642
+ # Generate frames along segments between anchors
643
+ filenames = []
644
+ frame_idx = 0
645
+ num_segments = len(anchor_azimuths) - 1
646
+ for i in range(num_segments):
647
+ a0 = anchor_azimuths[i]
648
+ a1 = anchor_azimuths[i + 1]
649
+ for k in range(frames_per_segment):
650
+ t = k / float(frames_per_segment)
651
+ az = (1.0 - t) * a0 + t * a1
652
+ direction = np.array([
653
+ cos_elev * np.cos(az),
654
+ cos_elev * np.sin(az),
655
+ sin_elev
656
+ ], dtype=float)
657
+ direction = direction / np.linalg.norm(direction)
658
+ camera_pos = center + direction * distance
659
+ camera_tuple = [camera_pos, center, (0, 0, 1)]
660
+
661
+ plotter = pv.Plotter(off_screen=True, window_size=image_size if image_size is not None else None)
662
+ if projection_type.lower() == "orthographic":
663
+ plotter.enable_parallel_projection()
664
+ plotter.camera.parallel_scale = diagonal * 0.4 * distance_factor
665
+ elif projection_type.lower() != "perspective":
666
+ print(f"Warning: Unknown projection_type '{projection_type}'. Using perspective projection.")
667
+
668
+ for _, pv_mesh in pv_meshes.items():
669
+ has_colors = 'colors' in pv_mesh.cell_data
670
+ plotter.add_mesh(pv_mesh, rgb=True, scalars='colors' if has_colors else None)
671
+
672
+ plotter.camera_position = camera_tuple
673
+ filename = os.path.join(output_directory, f"{file_prefix}_{frame_idx:04d}.png")
674
+ plotter.screenshot(filename)
675
+ filenames.append(filename)
676
+ plotter.close()
677
+ frame_idx += 1
678
+
679
+ return filenames
680
+
681
+ class PyVistaRenderer:
682
+ """Renderer that uses PyVista to produce multi-view images from meshes or VoxCity."""
683
+
684
+ def render_city(self, city: VoxCity, projection_type: str = "perspective", distance_factor: float = 1.0,
685
+ output_directory: str = "output", voxel_color_map: "str|dict" = "default",
686
+ *, # static rendering specific toggles
687
+ rotation: bool = False,
688
+ rotation_frames_per_segment: int = 60,
689
+ rotation_close_loop: bool = False,
690
+ rotation_file_prefix: str = "city_rotation",
691
+ image_size: "tuple[int, int] | None" = None,
692
+ fixed_scene_bounds_real: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None,
693
+ building_sim_mesh=None, building_value_name: str = 'svf_values',
694
+ building_colormap: str = 'viridis', building_vmin=None, building_vmax=None,
695
+ building_nan_color: str = 'gray', building_opacity: float = 1.0,
696
+ render_voxel_buildings: bool = False,
697
+ ground_sim_grid=None, ground_dem_grid=None,
698
+ ground_z_offset: float | None = None, ground_view_point_height: float | None = None,
699
+ ground_colormap: str = 'viridis', ground_vmin=None, ground_vmax=None):
700
+ """
701
+ Render city to static images with optional simulation overlays.
702
+
703
+ Parameters
704
+ ----------
705
+ city : VoxCity
706
+ VoxCity object to render
707
+ projection_type : str
708
+ "perspective" or "orthographic"
709
+ distance_factor : float
710
+ Camera distance multiplier
711
+ output_directory : str
712
+ Directory to save rendered images
713
+ voxel_color_map : str or dict
714
+ Color mapping for voxel classes
715
+ rotation : bool
716
+ If True, generate rotating isometric frames instead of multi-view snapshots.
717
+ rotation_frames_per_segment : int
718
+ Number of frames between each isometric anchor when rotation=True.
719
+ rotation_close_loop : bool
720
+ If True, returns smoothly to the starting anchor when rotation=True.
721
+ rotation_file_prefix : str
722
+ Filename prefix for rotation frames when rotation=True.
723
+ image_size : (int, int) or None
724
+ Static rendering output image size (width, height). If None, uses default.
725
+ building_sim_mesh : trimesh.Trimesh, optional
726
+ Building mesh with simulation results
727
+ building_value_name : str
728
+ Metadata key for building values
729
+ building_colormap : str
730
+ Colormap for building values
731
+ building_vmin, building_vmax : float, optional
732
+ Color scale limits for buildings
733
+ building_nan_color : str
734
+ Color for NaN values
735
+ building_opacity : float
736
+ Building mesh opacity
737
+ render_voxel_buildings : bool
738
+ Whether to render voxel buildings when building_sim_mesh is provided
739
+ ground_sim_grid : np.ndarray, optional
740
+ Ground-level simulation grid
741
+ ground_dem_grid : np.ndarray, optional
742
+ DEM grid for ground surface positioning
743
+ ground_z_offset : float, optional
744
+ Height offset for ground surface
745
+ ground_view_point_height : float, optional
746
+ Alternative height parameter
747
+ ground_colormap : str
748
+ Colormap for ground values
749
+ ground_vmin, ground_vmax : float, optional
750
+ Color scale limits for ground
751
+ """
752
+ if pv is None:
753
+ raise ImportError("PyVista is required for static rendering. Install with: pip install pyvista")
754
+
755
+ meshsize = city.voxels.meta.meshsize
756
+ trimesh_dict = {}
757
+
758
+ # Build voxel meshes (always generate to show ground, trees, etc.)
759
+ collection = MeshBuilder.from_voxel_grid(city.voxels, meshsize=meshsize, voxel_color_map=voxel_color_map)
760
+ for key, mm in collection.items.items():
761
+ if mm.vertices.size == 0 or mm.faces.size == 0:
762
+ continue
763
+ # Skip building voxels if we have building_sim_mesh and don't want to render both
764
+ if not render_voxel_buildings and building_sim_mesh is not None and int(key) == -3:
765
+ continue
766
+ tri = trimesh.Trimesh(vertices=mm.vertices, faces=mm.faces, process=False)
767
+ if mm.colors is not None:
768
+ tri.visual.face_colors = mm.colors
769
+ trimesh_dict[key] = tri
770
+
771
+ # Add building simulation mesh overlay
772
+ if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
773
+ Vb = np.asarray(building_sim_mesh.vertices)
774
+ Fb = np.asarray(building_sim_mesh.faces)
775
+
776
+ # Get simulation values from metadata
777
+ values = None
778
+ if hasattr(building_sim_mesh, 'metadata') and isinstance(building_sim_mesh.metadata, dict):
779
+ values = building_sim_mesh.metadata.get(building_value_name)
780
+
781
+ if values is not None:
782
+ values = np.asarray(values)
783
+
784
+ # Determine if values are per-face or per-vertex
785
+ face_vals = None
786
+ if len(values) == len(Fb):
787
+ face_vals = values.astype(float)
788
+ elif len(values) == len(Vb):
789
+ vals_v = values.astype(float)
790
+ face_vals = np.nanmean(vals_v[Fb], axis=1)
791
+
792
+ if face_vals is not None:
793
+ # Apply colormap
794
+ finite = np.isfinite(face_vals)
795
+ vmin_b = building_vmin if building_vmin is not None else (float(np.nanmin(face_vals[finite])) if np.any(finite) else 0.0)
796
+ vmax_b = building_vmax if building_vmax is not None else (float(np.nanmax(face_vals[finite])) if np.any(finite) else 1.0)
797
+ norm_b = mcolors.Normalize(vmin=vmin_b, vmax=vmax_b)
798
+ cmap_b = cm.get_cmap(building_colormap)
799
+
800
+ colors_rgba = np.zeros((len(Fb), 4), dtype=np.uint8)
801
+ if np.any(finite):
802
+ colors_float = cmap_b(norm_b(face_vals[finite]))
803
+ colors_rgba[finite] = (colors_float * 255).astype(np.uint8)
804
+
805
+ # Handle NaN values
806
+ nan_rgba = np.array(mcolors.to_rgba(building_nan_color))
807
+ colors_rgba[~finite] = (nan_rgba * 255).astype(np.uint8)
808
+
809
+ # Create trimesh with colors
810
+ building_tri = trimesh.Trimesh(vertices=Vb, faces=Fb, process=False)
811
+ building_tri.visual.face_colors = colors_rgba
812
+ trimesh_dict['building_sim'] = building_tri
813
+ else:
814
+ # No values, just add the mesh with default color
815
+ building_tri = trimesh.Trimesh(vertices=Vb, faces=Fb, process=False)
816
+ trimesh_dict['building_sim'] = building_tri
817
+
818
+ # Add ground simulation surface overlay
819
+ if ground_sim_grid is not None and ground_dem_grid is not None:
820
+ z_off = ground_z_offset if ground_z_offset is not None else ground_view_point_height
821
+ try:
822
+ z_off = float(z_off) if z_off is not None else 1.5
823
+ except Exception:
824
+ z_off = 1.5
825
+
826
+ # Snap to grid
827
+ try:
828
+ z_off = (z_off // meshsize + 1.0) * meshsize
829
+ except Exception:
830
+ pass
831
+
832
+ # Normalize DEM
833
+ try:
834
+ dem_norm = np.asarray(ground_dem_grid, dtype=float)
835
+ dem_norm = dem_norm - np.nanmin(dem_norm)
836
+ except Exception:
837
+ dem_norm = ground_dem_grid
838
+
839
+ # Determine color range
840
+ sim_vals = np.asarray(ground_sim_grid, dtype=float)
841
+ finite = np.isfinite(sim_vals)
842
+ vmin_g = ground_vmin if ground_vmin is not None else (float(np.nanmin(sim_vals[finite])) if np.any(finite) else 0.0)
843
+ vmax_g = ground_vmax if ground_vmax is not None else (float(np.nanmax(sim_vals[finite])) if np.any(finite) else 1.0)
844
+
845
+ # Create ground simulation mesh
846
+ sim_mesh = create_sim_surface_mesh(
847
+ ground_sim_grid,
848
+ dem_norm,
849
+ meshsize=meshsize,
850
+ z_offset=z_off,
851
+ cmap_name=ground_colormap,
852
+ vmin=vmin_g,
853
+ vmax=vmax_g,
854
+ )
855
+
856
+ if sim_mesh is not None and getattr(sim_mesh, 'vertices', None) is not None:
857
+ trimesh_dict['ground_sim'] = sim_mesh
858
+
859
+ os.makedirs(output_directory, exist_ok=True)
860
+ if rotation:
861
+ return create_rotation_view_scene(
862
+ trimesh_dict,
863
+ output_directory=output_directory,
864
+ projection_type=projection_type,
865
+ distance_factor=distance_factor,
866
+ frames_per_segment=rotation_frames_per_segment,
867
+ close_loop=rotation_close_loop,
868
+ file_prefix=rotation_file_prefix,
869
+ image_size=image_size,
870
+ fixed_bounds=fixed_scene_bounds_real,
871
+ )
872
+ else:
873
+ return create_multi_view_scene(
874
+ trimesh_dict,
875
+ output_directory=output_directory,
876
+ projection_type=projection_type,
877
+ distance_factor=distance_factor,
878
+ image_size=image_size,
879
+ fixed_bounds=fixed_scene_bounds_real,
880
+ )
881
+
882
+
883
+
884
+ def visualize_voxcity(
885
+ city: VoxCity,
886
+ mode: str = "interactive",
887
+ *,
888
+ # Common options
889
+ voxel_color_map: "str|dict" = "default",
890
+ classes=None,
891
+ title: str | None = None,
892
+ # Interactive (Plotly) options
893
+ opacity: float = 1.0,
894
+ max_dimension: int = 160,
895
+ downsample: int | None = None,
896
+ show: bool = True,
897
+ return_fig: bool = False,
898
+ # Static (PyVista) options
899
+ output_directory: str = "output",
900
+ projection_type: str = "perspective",
901
+ distance_factor: float = 1.0,
902
+ rotation: bool = False,
903
+ rotation_frames_per_segment: int = 60,
904
+ rotation_close_loop: bool = False,
905
+ rotation_file_prefix: str = "city_rotation",
906
+ image_size: "tuple[int, int] | None" = None,
907
+ fixed_scene_bounds_real: "tuple[tuple[float,float,float], tuple[float,float,float]] | None" = None,
908
+ # Building simulation overlay options
909
+ building_sim_mesh=None,
910
+ building_value_name: str = 'svf_values',
911
+ building_colormap: str = 'viridis',
912
+ building_vmin: float | None = None,
913
+ building_vmax: float | None = None,
914
+ building_nan_color: str = 'gray',
915
+ building_opacity: float = 1.0,
916
+ building_shaded: bool = False,
917
+ render_voxel_buildings: bool = False,
918
+ # Ground simulation surface overlay options
919
+ ground_sim_grid=None,
920
+ ground_dem_grid=None,
921
+ ground_z_offset: float | None = None,
922
+ ground_view_point_height: float | None = None,
923
+ ground_colormap: str = 'viridis',
924
+ ground_vmin: float | None = None,
925
+ ground_vmax: float | None = None,
926
+ sim_surface_opacity: float = 0.95,
927
+ ground_shaded: bool = False,
928
+ ):
929
+ """
930
+ Visualize a VoxCity object with optional simulation result overlays.
931
+
932
+ Parameters
933
+ ----------
934
+ city : VoxCity
935
+ VoxCity object to visualize
936
+ mode : str, default="interactive"
937
+ Visualization mode: "interactive" (Plotly) or "static" (PyVista)
938
+
939
+ Common Options
940
+ --------------
941
+ voxel_color_map : str or dict, default="default"
942
+ Color mapping for voxel classes
943
+ classes : list, optional
944
+ Specific voxel classes to render
945
+ title : str, optional
946
+ Plot title
947
+ image_size : (int, int) or None, default=None
948
+ Unified image size (width, height) applied across modes.
949
+ - Interactive: overrides width/height below when provided.
950
+ - Static (including rotation): sets PyVista window size for screenshots.
951
+
952
+ Interactive Mode Options (Plotly)
953
+ ----------------------------------
954
+ opacity : float, default=1.0
955
+ Voxel opacity (0-1)
956
+ max_dimension : int, default=160
957
+ Maximum grid dimension before downsampling
958
+ downsample : int, optional
959
+ Manual downsampling stride
960
+ show : bool, default=True
961
+ Whether to display the plot
962
+ return_fig : bool, default=False
963
+ Whether to return the figure object
964
+
965
+ Static Mode Options (PyVista)
966
+ ------------------------------
967
+ output_directory : str, default="output"
968
+ Directory for saving rendered images
969
+ projection_type : str, default="perspective"
970
+ Camera projection: "perspective" or "orthographic"
971
+ distance_factor : float, default=1.0
972
+ Camera distance multiplier
973
+ rotation : bool, default=False
974
+ If True, generate rotating isometric frames instead of multi-view snapshots
975
+ rotation_frames_per_segment : int, default=60
976
+ Frames between each isometric anchor when rotation=True
977
+ rotation_close_loop : bool, default=False
978
+ If True, continue frames to return to start when rotation=True
979
+ rotation_file_prefix : str, default="city_rotation"
980
+ Filename prefix for rotation frames when rotation=True
981
+ image_size : (int, int) or None, default=None
982
+ Static rendering output image size (width, height). If None, uses default.
983
+
984
+ Building Simulation Overlay Options
985
+ ------------------------------------
986
+ building_sim_mesh : trimesh.Trimesh, optional
987
+ Building mesh with simulation results in metadata.
988
+ Typically created by get_surface_view_factor() or get_building_solar_irradiance().
989
+ building_value_name : str, default='svf_values'
990
+ Metadata key to use for coloring (e.g., 'svf_values', 'global', 'direct', 'diffuse')
991
+ building_colormap : str, default='viridis'
992
+ Matplotlib colormap for building values
993
+ building_vmin : float, optional
994
+ Minimum value for color scale
995
+ building_vmax : float, optional
996
+ Maximum value for color scale
997
+ building_nan_color : str, default='gray'
998
+ Color for NaN/invalid values
999
+ building_opacity : float, default=1.0
1000
+ Building mesh opacity (0-1)
1001
+ building_shaded : bool, default=False
1002
+ Whether to apply shading to building mesh
1003
+ render_voxel_buildings : bool, default=False
1004
+ Whether to render voxel buildings when building_sim_mesh is provided
1005
+
1006
+ Ground Simulation Surface Overlay Options
1007
+ ------------------------------------------
1008
+ ground_sim_grid : np.ndarray, optional
1009
+ 2D array of ground-level simulation values (e.g., Green View Index, solar radiation).
1010
+ Should have the same shape as the city's 2D grids.
1011
+ ground_dem_grid : np.ndarray, optional
1012
+ 2D DEM array for positioning the ground simulation surface.
1013
+ If None, uses city.dem.elevation when ground_sim_grid is provided.
1014
+ ground_z_offset : float, optional
1015
+ Height offset for ground simulation surface above DEM
1016
+ ground_view_point_height : float, optional
1017
+ Alternative parameter for ground surface height (used if ground_z_offset is None)
1018
+ ground_colormap : str, default='viridis'
1019
+ Matplotlib colormap for ground values
1020
+ ground_vmin : float, optional
1021
+ Minimum value for color scale
1022
+ ground_vmax : float, optional
1023
+ Maximum value for color scale
1024
+ sim_surface_opacity : float, default=0.95
1025
+ Ground simulation surface opacity (0-1)
1026
+ ground_shaded : bool, default=False
1027
+ Whether to apply shading to ground surface
1028
+
1029
+ Returns
1030
+ -------
1031
+ For mode="interactive":
1032
+ plotly.graph_objects.Figure or None
1033
+ Returns Figure if return_fig=True, otherwise None
1034
+
1035
+ For mode="static":
1036
+ list of (view_name, filepath) tuples
1037
+ List of rendered view names and their file paths
1038
+
1039
+ Examples
1040
+ --------
1041
+ Basic visualization:
1042
+ >>> visualize_voxcity(city, mode="interactive")
1043
+
1044
+ With building solar irradiance results:
1045
+ >>> building_mesh = get_building_solar_irradiance(city, ...)
1046
+ >>> visualize_voxcity(city, mode="interactive",
1047
+ ... building_sim_mesh=building_mesh,
1048
+ ... building_value_name='global')
1049
+
1050
+ With ground-level Green View Index:
1051
+ >>> visualize_voxcity(city, mode="interactive",
1052
+ ... ground_sim_grid=gvi_array,
1053
+ ... ground_colormap='YlGn')
1054
+
1055
+ Static rendering with simulation overlays:
1056
+ >>> visualize_voxcity(city, mode="static",
1057
+ ... building_sim_mesh=svf_mesh,
1058
+ ... output_directory="renders")
1059
+ """
1060
+ if not isinstance(mode, str):
1061
+ raise ValueError("mode must be a string: 'interactive' or 'static'")
1062
+
1063
+ mode_l = mode.lower().strip()
1064
+ meshsize = getattr(city.voxels.meta, "meshsize", None)
1065
+
1066
+ # Auto-fill ground_dem_grid from city if ground_sim_grid is provided but ground_dem_grid is not
1067
+ if ground_sim_grid is not None and ground_dem_grid is None:
1068
+ ground_dem_grid = getattr(city.dem, "elevation", None)
1069
+
1070
+ if mode_l == "interactive":
1071
+ voxel_array = getattr(city.voxels, "classes", None)
1072
+ # Build kwargs to optionally pass width/height when image_size is provided
1073
+ size_kwargs = {}
1074
+ if image_size is not None:
1075
+ try:
1076
+ size_kwargs = {"width": int(image_size[0]), "height": int(image_size[1])}
1077
+ except Exception:
1078
+ size_kwargs = {}
1079
+ return visualize_voxcity_plotly(
1080
+ voxel_array=voxel_array,
1081
+ meshsize=meshsize,
1082
+ classes=classes,
1083
+ voxel_color_map=voxel_color_map,
1084
+ opacity=opacity,
1085
+ max_dimension=max_dimension,
1086
+ downsample=downsample,
1087
+ title=title,
1088
+ show=show,
1089
+ return_fig=return_fig,
1090
+ **size_kwargs,
1091
+ # Building simulation overlay
1092
+ building_sim_mesh=building_sim_mesh,
1093
+ building_value_name=building_value_name,
1094
+ building_colormap=building_colormap,
1095
+ building_vmin=building_vmin,
1096
+ building_vmax=building_vmax,
1097
+ building_nan_color=building_nan_color,
1098
+ building_opacity=building_opacity,
1099
+ building_shaded=building_shaded,
1100
+ render_voxel_buildings=render_voxel_buildings,
1101
+ # Ground simulation surface overlay
1102
+ ground_sim_grid=ground_sim_grid,
1103
+ ground_dem_grid=ground_dem_grid,
1104
+ ground_z_offset=ground_z_offset,
1105
+ ground_view_point_height=ground_view_point_height,
1106
+ ground_colormap=ground_colormap,
1107
+ ground_vmin=ground_vmin,
1108
+ ground_vmax=ground_vmax,
1109
+ sim_surface_opacity=sim_surface_opacity,
1110
+ ground_shaded=ground_shaded,
1111
+ )
1112
+
1113
+ if mode_l == "static":
1114
+ renderer = PyVistaRenderer()
1115
+ return renderer.render_city(
1116
+ city,
1117
+ projection_type=projection_type,
1118
+ distance_factor=distance_factor,
1119
+ output_directory=output_directory,
1120
+ voxel_color_map=voxel_color_map,
1121
+ rotation=rotation,
1122
+ rotation_frames_per_segment=rotation_frames_per_segment,
1123
+ rotation_close_loop=rotation_close_loop,
1124
+ rotation_file_prefix=rotation_file_prefix,
1125
+ image_size=image_size,
1126
+ fixed_scene_bounds_real=fixed_scene_bounds_real,
1127
+ # Pass simulation overlay parameters
1128
+ building_sim_mesh=building_sim_mesh,
1129
+ building_value_name=building_value_name,
1130
+ building_colormap=building_colormap,
1131
+ building_vmin=building_vmin,
1132
+ building_vmax=building_vmax,
1133
+ building_nan_color=building_nan_color,
1134
+ building_opacity=building_opacity,
1135
+ render_voxel_buildings=render_voxel_buildings,
1136
+ ground_sim_grid=ground_sim_grid,
1137
+ ground_dem_grid=ground_dem_grid,
1138
+ ground_z_offset=ground_z_offset,
1139
+ ground_view_point_height=ground_view_point_height,
1140
+ ground_colormap=ground_colormap,
1141
+ ground_vmin=ground_vmin,
1142
+ ground_vmax=ground_vmax,
1143
+ )
1144
+
1145
+ raise ValueError("Unknown mode. Use 'interactive' or 'static'.")
1146
+