diffusion-cartogram 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1490 @@
1
+ """
2
+ Visualization and animation tools for VDERM exports.
3
+
4
+ This module provides functions to create animations and visualizations from
5
+ exported VDERM data (grids, surfaces, and meshes).
6
+ """
7
+
8
+ import numpy as np
9
+ import matplotlib.pyplot as plt
10
+ from matplotlib.animation import FuncAnimation, PillowWriter
11
+ from mpl_toolkits.mplot3d import Axes3D
12
+ import glob
13
+ import os
14
+ from tqdm import tqdm
15
+ import warnings
16
+
17
+ from .core import read_xyz, HAS_PYMESHLAB
18
+
19
+
20
+ def animate_grid_deformation(export_folder='vderm_exports',
21
+ subfolder='vderm_grid',
22
+ output_file='grid_animation.gif',
23
+ fps=5,
24
+ subsample=5000,
25
+ cmap='plasma',
26
+ figsize=(15, 5),
27
+ alpha=0.3):
28
+ """
29
+ Create animated GIF of grid deformation showing three orthogonal 2D projections.
30
+
31
+ Creates three side-by-side 2D plots showing XY, XZ, and YZ projections of the
32
+ grid with density coloring. Points are overlaid with transparency to show
33
+ the full 3D structure.
34
+
35
+ Parameters
36
+ ----------
37
+ export_folder : str, default='vderm_exports'
38
+ Base export folder
39
+ subfolder : str, default='vderm_grid'
40
+ Subfolder containing grid exports
41
+ output_file : str, default='grid_animation.gif'
42
+ Output animation filename (.gif or .mp4)
43
+ fps : int, default=5
44
+ Frames per second
45
+ subsample : int or None, default=5000
46
+ Subsample to this many points for faster rendering.
47
+ If None, use all points (may be slow for large grids).
48
+ cmap : str, default='plasma'
49
+ Matplotlib colormap name for density visualization
50
+ figsize : tuple, default=(15, 5)
51
+ Figure size in inches (width, height)
52
+ alpha : float, default=0.3
53
+ Opacity for overlaid points (0=transparent, 1=opaque)
54
+
55
+ Returns
56
+ -------
57
+ None
58
+ Saves animation to output_file
59
+
60
+ Examples
61
+ --------
62
+ >>> # Basic usage
63
+ >>> animate_grid_deformation('my_exports', output_file='deform.gif')
64
+
65
+ >>> # More opaque points
66
+ >>> animate_grid_deformation('my_exports', alpha=0.5)
67
+
68
+ >>> # Higher frame rate
69
+ >>> animate_grid_deformation('my_exports', fps=10, subsample=3000)
70
+ """
71
+
72
+ # Find all grid files
73
+ pattern = os.path.join(export_folder, subfolder, 'grid_iteration_*.xyz')
74
+ files = sorted(glob.glob(pattern))
75
+
76
+ # Also check for final file
77
+ final_pattern = os.path.join(export_folder, subfolder, 'grid_final_*.xyz')
78
+ final_files = glob.glob(final_pattern)
79
+ if final_files:
80
+ files.append(final_files[0])
81
+
82
+ if len(files) == 0:
83
+ raise FileNotFoundError(
84
+ f"No grid files found matching {pattern}\n"
85
+ f"Make sure you ran run_VDERM_with_tracking with export_grid=True"
86
+ )
87
+
88
+ print(f"Found {len(files)} frames to animate")
89
+
90
+ # Load first frame to determine subsample indices
91
+ pos_first, norm_first, dens_first = read_xyz(files[0])
92
+
93
+ # Determine subsample indices
94
+ if subsample and len(pos_first) > subsample:
95
+ subsample_indices = np.random.choice(len(pos_first), subsample, replace=False)
96
+ subsample_indices.sort() # Sort for better cache locality
97
+ print(f"Subsampling {len(pos_first)} points to {subsample} points")
98
+ else:
99
+ subsample_indices = None
100
+ print(f"Using all {len(pos_first)} points (no subsampling)")
101
+
102
+ # Load all data
103
+ all_positions = []
104
+ all_densities = []
105
+
106
+ for f in tqdm(files, desc="Loading data"):
107
+ pos, norm, dens = read_xyz(f)
108
+
109
+ # Subsample if requested
110
+ if subsample_indices is not None:
111
+ pos = pos[subsample_indices]
112
+ dens = dens[subsample_indices]
113
+
114
+ all_positions.append(pos)
115
+ all_densities.append(dens)
116
+
117
+ # Get global bounds for consistent axes
118
+ all_pos = np.vstack(all_positions)
119
+ pos_min = all_pos.min(axis=0)
120
+ pos_max = all_pos.max(axis=0)
121
+
122
+ all_dens = np.hstack(all_densities)
123
+ dens_min = all_dens.min()
124
+ dens_max = all_dens.max()
125
+
126
+ print(f"Position range: [{pos_min[0]:.3f}, {pos_max[0]:.3f}] × "
127
+ f"[{pos_min[1]:.3f}, {pos_max[1]:.3f}] × "
128
+ f"[{pos_min[2]:.3f}, {pos_max[2]:.3f}]")
129
+ print(f"Density range: [{dens_min:.3f}, {dens_max:.3f}]")
130
+
131
+ # Create figure with three 2D subplots
132
+ fig, (ax_xy, ax_xz, ax_yz) = plt.subplots(1, 3, figsize=figsize)
133
+
134
+ # Create colormap normalizer
135
+ from matplotlib.colors import Normalize
136
+ norm = Normalize(vmin=dens_min, vmax=dens_max)
137
+
138
+ def update(frame):
139
+ """Update function for animation"""
140
+ # Clear all axes
141
+ ax_xy.clear()
142
+ ax_xz.clear()
143
+ ax_yz.clear()
144
+
145
+ positions = all_positions[frame]
146
+ densities = all_densities[frame]
147
+
148
+ # XY plane (top view - looking down Z axis)
149
+ ax_xy.scatter(positions[:, 0], positions[:, 1],
150
+ c=densities, cmap=cmap, s=1, alpha=alpha,
151
+ vmin=dens_min, vmax=dens_max)
152
+ ax_xy.set_xlim(pos_min[0], pos_max[0])
153
+ ax_xy.set_ylim(pos_min[1], pos_max[1])
154
+ ax_xy.set_xlabel('X')
155
+ ax_xy.set_ylabel('Y')
156
+ ax_xy.set_title('XY Plane (Top View)')
157
+ ax_xy.set_aspect('equal')
158
+ ax_xy.grid(True, alpha=0.3)
159
+
160
+ # XZ plane (front view - looking along Y axis)
161
+ ax_xz.scatter(positions[:, 0], positions[:, 2],
162
+ c=densities, cmap=cmap, s=1, alpha=alpha,
163
+ vmin=dens_min, vmax=dens_max)
164
+ ax_xz.set_xlim(pos_min[0], pos_max[0])
165
+ ax_xz.set_ylim(pos_min[2], pos_max[2])
166
+ ax_xz.set_xlabel('X')
167
+ ax_xz.set_ylabel('Z')
168
+ ax_xz.set_title('XZ Plane (Front View)')
169
+ ax_xz.set_aspect('equal')
170
+ ax_xz.grid(True, alpha=0.3)
171
+
172
+ # YZ plane (side view - looking along X axis)
173
+ scatter_yz = ax_yz.scatter(positions[:, 1], positions[:, 2],
174
+ c=densities, cmap=cmap, s=1, alpha=alpha,
175
+ vmin=dens_min, vmax=dens_max)
176
+ ax_yz.set_xlim(pos_min[1], pos_max[1])
177
+ ax_yz.set_ylim(pos_min[2], pos_max[2])
178
+ ax_yz.set_xlabel('Y')
179
+ ax_yz.set_ylabel('Z')
180
+ ax_yz.set_title('YZ Plane (Side View)')
181
+ ax_yz.set_aspect('equal')
182
+ ax_yz.grid(True, alpha=0.3)
183
+
184
+ # Extract iteration number and set main title
185
+ filename = os.path.basename(files[frame])
186
+ if 'final' in filename:
187
+ fig.suptitle('Final Grid State (Converged)',
188
+ fontsize=16, fontweight='bold')
189
+ else:
190
+ iter_num = int(filename.split('_')[-1].replace('.xyz', ''))
191
+ fig.suptitle(f'Grid Deformation - Iteration {iter_num}',
192
+ fontsize=16)
193
+
194
+ return scatter_yz, # Return one for blit
195
+
196
+ # Create initial frame to set up colorbar
197
+ positions = all_positions[0]
198
+ densities = all_densities[0]
199
+
200
+ scatter_yz = ax_yz.scatter(positions[:, 1], positions[:, 2],
201
+ c=densities, cmap=cmap, s=1, alpha=alpha,
202
+ vmin=dens_min, vmax=dens_max)
203
+
204
+ # Add colorbar (shared for all three plots)
205
+ cbar = fig.colorbar(scatter_yz, ax=[ax_xy, ax_xz, ax_yz],
206
+ label='Density', shrink=0.8, pad=0.02)
207
+
208
+ # Create animation
209
+ print(f"Creating animation with {fps} fps...")
210
+ anim = FuncAnimation(fig, update, frames=len(files),
211
+ interval=1000//fps, blit=False)
212
+
213
+ # Save
214
+ print(f"Saving to {output_file}...")
215
+ if output_file.endswith('.gif'):
216
+ writer = PillowWriter(fps=fps)
217
+ anim.save(output_file, writer=writer)
218
+ elif output_file.endswith('.mp4'):
219
+ try:
220
+ from matplotlib.animation import FFMpegWriter
221
+ writer = FFMpegWriter(fps=fps, bitrate=1800)
222
+ anim.save(output_file, writer=writer)
223
+ except Exception as e:
224
+ raise RuntimeError(
225
+ f"Failed to save MP4. Make sure ffmpeg is installed.\n"
226
+ f"Error: {e}\n"
227
+ f"Try saving as .gif instead, or install ffmpeg."
228
+ )
229
+ else:
230
+ raise ValueError(
231
+ f"output_file must end with .gif or .mp4, got: {output_file}"
232
+ )
233
+
234
+ plt.close()
235
+ print(f"✓ Animation saved to: {output_file}")
236
+
237
+
238
+ def animate_surface_deformation(export_folder='vderm_exports',
239
+ subfolder='vderm_surface',
240
+ output_file='surface_animation.gif',
241
+ fps=5,
242
+ subsample=5000,
243
+ show_normals=False,
244
+ alpha=0.6,
245
+ figsize=(10, 8)):
246
+ """
247
+ Create animated GIF of surface deformation from exported surface states.
248
+
249
+ Parameters
250
+ ----------
251
+ export_folder : str, default='vderm_exports'
252
+ Base export folder
253
+ subfolder : str, default='vderm_surface'
254
+ Subfolder containing surface exports
255
+ output_file : str, default='surface_animation.gif'
256
+ Output animation filename (.gif or .mp4)
257
+ fps : int, default=5
258
+ Frames per second
259
+ subsample : int or None, default=5000
260
+ Subsample to this many points for faster rendering
261
+ show_normals : bool, default=False
262
+ If True, draw normal vectors (slower, may clutter visualization)
263
+ figsize : tuple, default=(10, 8)
264
+ Figure size in inches
265
+
266
+ Returns
267
+ -------
268
+ None
269
+ Saves animation to output_file
270
+
271
+ Examples
272
+ --------
273
+ >>> # Basic surface animation
274
+ >>> animate_surface_deformation('my_exports')
275
+
276
+ >>> # Show normal vectors
277
+ >>> animate_surface_deformation('my_exports',
278
+ ... show_normals=True,
279
+ ... subsample=1000) # Use fewer points with normals
280
+ """
281
+
282
+ # Find all surface files
283
+ pattern = os.path.join(export_folder, subfolder, 'surface_iteration_*.xyz')
284
+ files = sorted(glob.glob(pattern))
285
+
286
+ # Check for final
287
+ final_pattern = os.path.join(export_folder, subfolder, 'surface_final_*.xyz')
288
+ final_files = glob.glob(final_pattern)
289
+ if final_files:
290
+ files.append(final_files[0])
291
+
292
+ if len(files) == 0:
293
+ raise FileNotFoundError(
294
+ f"No surface files found matching {pattern}\n"
295
+ f"Make sure you ran run_VDERM_with_tracking with export_surface=True"
296
+ )
297
+
298
+ print(f"Found {len(files)} frames to animate")
299
+
300
+ # Load first frame to determine subsample indices
301
+ pos_first, norm_first, dense_first = read_xyz(files[0])
302
+
303
+ min_dens = min(dense_first)
304
+ max_dens = max(dense_first)
305
+
306
+ # Determine subsample indices
307
+ if subsample and len(pos_first) > subsample:
308
+ subsample_indices = np.random.choice(len(pos_first), subsample, replace=False)
309
+ subsample_indices.sort()
310
+ print(f"Subsampling {len(pos_first)} points to {subsample} points")
311
+ else:
312
+ subsample_indices = None
313
+ print(f"Using all {len(pos_first)} points (no subsampling)")
314
+
315
+ # Load all data
316
+ all_points = []
317
+ all_normals = []
318
+ all_dens = []
319
+
320
+ for f in tqdm(files, desc="Loading data"):
321
+ pts, norms, dens = read_xyz(f)
322
+
323
+ # Subsample if requested
324
+ if subsample_indices is not None:
325
+ pts = pts[subsample_indices]
326
+ norms = norms[subsample_indices]
327
+ dens = dens[subsample_indices]
328
+
329
+ all_points.append(pts)
330
+ all_normals.append(norms)
331
+ all_dens.append(dens)
332
+
333
+ # Get global bounds
334
+ all_pts = np.vstack(all_points)
335
+ pts_min = all_pts.min(axis=0)
336
+ pts_max = all_pts.max(axis=0)
337
+
338
+ print(f"Surface bounds: [{pts_min[0]:.3f}, {pts_max[0]:.3f}] × "
339
+ f"[{pts_min[1]:.3f}, {pts_max[1]:.3f}] × "
340
+ f"[{pts_min[2]:.3f}, {pts_max[2]:.3f}]")
341
+
342
+ # Create figure
343
+ fig = plt.figure(figsize=figsize)
344
+ ax = fig.add_subplot(111, projection='3d')
345
+
346
+ def update(frame):
347
+ ax.clear()
348
+
349
+ points = all_points[frame]
350
+ normals = all_normals[frame]
351
+ densities = all_dens[frame]
352
+
353
+ # Plot surface
354
+ box = ax.scatter(points[:, 0], points[:, 1], points[:, 2],
355
+ c=densities,cmap='viridis', s=1, alpha=alpha,
356
+ vmin=min_dens,vmax=max_dens)
357
+
358
+ # Optionally show normals
359
+ if show_normals:
360
+ # Only show every Nth normal for clarity
361
+ step = max(1, len(points) // 100)
362
+ normal_scale = (pts_max - pts_min).mean() * 0.02 # Scale normals to 2% of domain
363
+ ax.quiver(points[::step, 0], points[::step, 1], points[::step, 2],
364
+ normals[::step, 0], normals[::step, 1], normals[::step, 2],
365
+ length=normal_scale, color='red', alpha=0.5,
366
+ arrow_length_ratio=0.3)
367
+
368
+ ax.set_xlim(pts_min[0], pts_max[0])
369
+ ax.set_ylim(pts_min[1], pts_max[1])
370
+ ax.set_zlim(pts_min[2], pts_max[2])
371
+
372
+ ax.set_box_aspect([
373
+ pts_max[0] - pts_min[0],
374
+ pts_max[1] - pts_min[1],
375
+ pts_max[2] - pts_min[2]
376
+ ])
377
+
378
+ ax.set_xlabel('X')
379
+ ax.set_ylabel('Y')
380
+ ax.set_zlabel('Z')
381
+
382
+ # Extract iteration
383
+ filename = os.path.basename(files[frame])
384
+ if 'final' in filename:
385
+ ax.set_title('Surface: Final (Converged)', fontsize=14, fontweight='bold')
386
+ else:
387
+ iter_num = int(filename.split('_')[-1].replace('.xyz', ''))
388
+ ax.set_title(f'Surface: Iteration {iter_num}', fontsize=14)
389
+
390
+ return ax,
391
+
392
+ # initial frame to set up colorbar
393
+
394
+ box = ax.scatter(pos_first[:, 0], pos_first[:, 1], pos_first[:, 2],
395
+ c=dense_first,cmap='viridis', s=1, alpha=alpha,
396
+ vmin=min_dens,vmax=max_dens)
397
+
398
+ cbar = plt.colorbar(box, ax=ax, label='Density', shrink=0.5)
399
+
400
+ # Create animation
401
+ print(f"Creating animation with {fps} fps...")
402
+ anim = FuncAnimation(fig, update, frames=len(files),
403
+ interval=1000//fps, blit=False)
404
+
405
+ # Save
406
+ print(f"Saving to {output_file}...")
407
+ if output_file.endswith('.gif'):
408
+ writer = PillowWriter(fps=fps)
409
+ anim.save(output_file, writer=writer)
410
+ elif output_file.endswith('.mp4'):
411
+ try:
412
+ from matplotlib.animation import FFMpegWriter
413
+ writer = FFMpegWriter(fps=fps, bitrate=1800)
414
+ anim.save(output_file, writer=writer)
415
+ except Exception as e:
416
+ raise RuntimeError(
417
+ f"Failed to save MP4. Make sure ffmpeg is installed.\n"
418
+ f"Error: {e}"
419
+ )
420
+ else:
421
+ raise ValueError("output_file must end with .gif or .mp4")
422
+
423
+ plt.close()
424
+ print(f"✓ Animation saved to: {output_file}")
425
+
426
+
427
+ def interactive_pcd_plot(export_folder, pattern='grid_iteration_*.xyz',
428
+ subsample=True, max_points=10_000):
429
+ """
430
+ Visualize a sequence of exported grid states to see deformation over time.
431
+
432
+ Parameters
433
+ ----------
434
+ export_folder : str
435
+ Folder containing exported grid files
436
+ pattern : str
437
+ Glob pattern to match grid files (default: 'grid_iteration_*.xyz')
438
+ subsample : bool
439
+ Whether to downsample the number of points plotted
440
+ max_points :
441
+ max points to plot if subsampling enabled
442
+
443
+ Examples
444
+ --------
445
+ >>> visualize_grid_sequence('deformation_sequence')
446
+ >>> visualize_grid_sequence('exports', save_animation=True, output_file='deform.gif')
447
+
448
+ Notes
449
+ -----
450
+ ipympl and ipywidgets must be installed, and the jupyter backend must be set
451
+ to %matplotlib widgets for this function to work. Otherwise it will produce a
452
+ static plot of the first frame
453
+ """
454
+
455
+ # Find all matching files
456
+ file_pattern = os.path.join(export_folder, pattern)
457
+ files = sorted(glob.glob(file_pattern))
458
+
459
+ if len(files) == 0:
460
+ raise FileNotFoundError(f"No files matching pattern '{file_pattern}'")
461
+
462
+ print(f"Found {len(files)} files to visualize")
463
+
464
+ # Read all files to get global bounds and density range
465
+ all_positions = []
466
+ all_densities = []
467
+ for f in files:
468
+ pos, norms, dens = read_xyz(f)
469
+ all_positions.append(pos)
470
+ all_densities.append(dens)
471
+
472
+ # Compute global bounds for consistent axes
473
+ all_pos_concat = np.vstack(all_positions)
474
+ all_dens_concat = np.hstack(all_densities)
475
+
476
+ pos_min = all_pos_concat.min(axis=0)
477
+ pos_max = all_pos_concat.max(axis=0)
478
+ dens_min = all_dens_concat.min()
479
+ dens_max = all_dens_concat.max()
480
+
481
+ # Interactive visualization
482
+ from matplotlib.widgets import Slider
483
+
484
+ fig = plt.figure(figsize=(12, 8))
485
+ ax = fig.add_subplot(111, projection='3d')
486
+
487
+ # Initial plot
488
+ positions, densities = all_positions[0], all_densities[0]
489
+
490
+ # Subsample if needed
491
+ if subsample and len(positions) > max_points:
492
+ indices = np.random.choice(len(positions), 10000, replace=False)
493
+ positions_sub = positions[indices]
494
+ densities_sub = densities[indices]
495
+ else:
496
+ positions_sub = positions
497
+ densities_sub = densities
498
+
499
+ scatter = ax.scatter(positions_sub[:, 0], positions_sub[:, 1], positions_sub[:, 2],
500
+ c=densities_sub, cmap='viridis', s=1,
501
+ vmin=dens_min, vmax=dens_max)
502
+
503
+ ax.set_xlim(pos_min[0], pos_max[0])
504
+ ax.set_ylim(pos_min[1], pos_max[1])
505
+ ax.set_zlim(pos_min[2], pos_max[2])
506
+
507
+ ax.set_box_aspect([
508
+ pos_max[0] - pos_min[0],
509
+ pos_max[1] - pos_min[1],
510
+ pos_max[2] - pos_min[2]
511
+ ])
512
+
513
+ ax.set_xlabel('X')
514
+ ax.set_ylabel('Y')
515
+ ax.set_zlabel('Z')
516
+ ax.set_title('Iteration 0')
517
+
518
+ cbar = plt.colorbar(scatter, ax=ax, label='Density', shrink=0.5)
519
+
520
+ # Add slider
521
+ ax_slider = plt.axes([0.2, 0.02, 0.6, 0.03])
522
+ slider = Slider(ax_slider, 'Iteration', 0, len(files)-1,
523
+ valinit=0, valstep=1)
524
+
525
+ def update_plot(val):
526
+ frame = int(slider.val)
527
+ positions, densities = all_positions[frame], all_densities[frame]
528
+
529
+ # Subsample if needed
530
+ if subsample and len(positions) > max_points:
531
+ positions_sub = positions[indices]
532
+ densities_sub = densities[indices]
533
+ else:
534
+ positions_sub = positions
535
+ densities_sub = densities
536
+
537
+ scatter._offsets3d = (positions_sub[:, 0],
538
+ positions_sub[:, 1],
539
+ positions_sub[:, 2])
540
+ scatter.set_array(densities_sub)
541
+
542
+ ax.set_title(f'Iteration {frame * 10}') # Adjust based on export_frequency
543
+ fig.canvas.draw_idle()
544
+
545
+ slider.on_changed(update_plot)
546
+ plt.show()
547
+
548
+
549
+ def create_side_by_side_animation(export_folder='vderm_exports',
550
+ grid_folder='vderm_grid',
551
+ surface_folder='vderm_surface',
552
+ output_file='comparison.gif',
553
+ fps=5,
554
+ subsample=3000,
555
+ alpha_grid=1,
556
+ alpha_surface=0.6,
557
+ figsize=(15, 10)):
558
+ """
559
+ Create side-by-side animation showing grid and surface evolution in 2D projections.
560
+
561
+ Creates a 2×3 grid of subplots:
562
+ - Top row: Grid XY, XZ, YZ projections (with density coloring)
563
+ - Bottom row: Surface XY, XZ, YZ projections (solid color)
564
+
565
+ Grid and surface use their own separate bounds for better visualization.
566
+
567
+ Parameters
568
+ ----------
569
+ export_folder : str, default='vderm_exports'
570
+ Base export folder
571
+ grid_folder : str, default='vderm_grid'
572
+ Subfolder containing grid exports
573
+ surface_folder : str, default='vderm_surface'
574
+ Subfolder containing surface exports
575
+ output_file : str, default='comparison.gif'
576
+ Output animation filename (.gif or .mp4)
577
+ fps : int, default=5
578
+ Frames per second
579
+ subsample : int or None, default=3000
580
+ Subsample each dataset to this many points
581
+ alpha : float, default=0.3
582
+ Opacity for grid points
583
+ figsize : tuple, default=(15, 10)
584
+ Figure size in inches (width, height)
585
+
586
+ Returns
587
+ -------
588
+ None
589
+ Saves animation to output_file
590
+
591
+ Notes
592
+ -----
593
+ Grid and surface exports must have matching iteration numbers.
594
+ If they don't match, only overlapping iterations will be animated.
595
+
596
+ Examples
597
+ --------
598
+ >>> create_side_by_side_animation('my_exports', output_file='both.gif')
599
+
600
+ >>> # More opaque grid, faster playback
601
+ >>> create_side_by_side_animation('my_exports', alpha=0.5, fps=10)
602
+ """
603
+
604
+ # Find files
605
+ grid_pattern = os.path.join(export_folder, grid_folder, 'grid_iteration_*.xyz')
606
+ surface_pattern = os.path.join(export_folder, surface_folder, 'surface_iteration_*.xyz')
607
+
608
+ grid_files = sorted(glob.glob(grid_pattern))
609
+ surface_files = sorted(glob.glob(surface_pattern))
610
+
611
+ if len(grid_files) == 0:
612
+ raise FileNotFoundError(f"No grid files found at {grid_pattern}")
613
+ if len(surface_files) == 0:
614
+ raise FileNotFoundError(f"No surface files found at {surface_pattern}")
615
+
616
+ # Must have matching counts
617
+ if len(grid_files) != len(surface_files):
618
+ print(f"Warning: Found {len(grid_files)} grid files but "
619
+ f"{len(surface_files)} surface files")
620
+ n_frames = min(len(grid_files), len(surface_files))
621
+ grid_files = grid_files[:n_frames]
622
+ surface_files = surface_files[:n_frames]
623
+
624
+ print(f"Creating {len(grid_files)} frame side-by-side animation")
625
+
626
+ # Load first frame to determine subsample indices
627
+ pos_first, norm_first, dense_first = read_xyz(surface_files[0])
628
+
629
+ # Determine subsample indices ONCE (use for all frames)
630
+ if subsample and len(pos_first) > subsample:
631
+ subsample_surface_indices = np.random.choice(len(pos_first), subsample, replace=False)
632
+ subsample_surface_indices.sort() # Sort for better cache locality
633
+ print(f"Subsampling {len(pos_first)} points to {subsample} points")
634
+ else:
635
+ subsample_surface_indices = None
636
+ print(f"Using all {len(pos_first)} points (no subsampling)")
637
+
638
+ # Load first frame to determine subsample indices
639
+ pos_first, norm_first, dense_first = read_xyz(grid_files[0])
640
+
641
+ # Determine subsample indices ONCE (use for all frames)
642
+ if subsample and len(pos_first) > subsample:
643
+ subsample_grid_indices = np.random.choice(len(pos_first), subsample, replace=False)
644
+ subsample_grid_indices.sort() # Sort for better cache locality
645
+ print(f"Subsampling {len(pos_first)} points to {subsample} points")
646
+ else:
647
+ subsample_grid_indices = None
648
+ print(f"Using all {len(pos_first)} points (no subsampling)")
649
+
650
+ # Load all data
651
+ all_grid_pos, all_grid_dens = [], []
652
+ all_surf_pts, all_surf_norms = [], []
653
+
654
+ for gf, sf in tqdm(zip(grid_files, surface_files),
655
+ total=len(grid_files), desc="Loading"):
656
+ gp, gn, gd = read_xyz(gf)
657
+ sp, sn, sd = read_xyz(sf)
658
+
659
+ # Subsample
660
+ if subsample:
661
+ gp, gd = gp[subsample_grid_indices], gd[subsample_grid_indices]
662
+ sp, sn = sp[subsample_surface_indices], sn[subsample_surface_indices]
663
+
664
+ all_grid_pos.append(gp)
665
+ all_grid_dens.append(gd)
666
+ all_surf_pts.append(sp)
667
+ all_surf_norms.append(sn)
668
+
669
+ # Get separate bounds for grid and surface
670
+ all_grid = np.vstack(all_grid_pos)
671
+ grid_min = all_grid.min(axis=0)
672
+ grid_max = all_grid.max(axis=0)
673
+
674
+ all_surf = np.vstack(all_surf_pts)
675
+ surf_min = all_surf.min(axis=0)
676
+ surf_max = all_surf.max(axis=0)
677
+
678
+ all_dens = np.hstack(all_grid_dens)
679
+ dens_min, dens_max = all_dens.min(), all_dens.max()
680
+
681
+ print(f"Grid bounds: [{grid_min[0]:.3f}, {grid_max[0]:.3f}] × "
682
+ f"[{grid_min[1]:.3f}, {grid_max[1]:.3f}] × "
683
+ f"[{grid_min[2]:.3f}, {grid_max[2]:.3f}]")
684
+ print(f"Surface bounds: [{surf_min[0]:.3f}, {surf_max[0]:.3f}] × "
685
+ f"[{surf_min[1]:.3f}, {surf_max[1]:.3f}] × "
686
+ f"[{surf_min[2]:.3f}, {surf_max[2]:.3f}]")
687
+
688
+ # Create figure with 2 rows, 3 columns
689
+ fig, axes = plt.subplots(2, 3, figsize=figsize)
690
+ ax_g_xy, ax_g_xz, ax_g_yz = axes[0] # Top row: grid
691
+ ax_s_xy, ax_s_xz, ax_s_yz = axes[1] # Bottom row: surface
692
+
693
+ def update(frame):
694
+ """Update function for animation"""
695
+ # Clear all axes
696
+ for ax_row in axes:
697
+ for ax in ax_row:
698
+ ax.clear()
699
+
700
+ grid_pos = all_grid_pos[frame]
701
+ grid_dens = all_grid_dens[frame]
702
+ surf_pts = all_surf_pts[frame]
703
+
704
+ # ==========================================
705
+ # Top row: Grid projections (with density)
706
+ # ==========================================
707
+
708
+ # Grid XY
709
+ ax_g_xy.scatter(grid_pos[:, 0], grid_pos[:, 1],
710
+ c=grid_dens, cmap='plasma', s=1, alpha=alpha_grid,
711
+ vmin=dens_min, vmax=dens_max)
712
+ ax_g_xy.set_xlim(grid_min[0], grid_max[0])
713
+ ax_g_xy.set_ylim(grid_min[1], grid_max[1])
714
+ ax_g_xy.set_xlabel('X')
715
+ ax_g_xy.set_ylabel('Y')
716
+ ax_g_xy.set_title('Grid XY (Top View)', fontsize=10)
717
+ ax_g_xy.set_aspect('equal')
718
+ ax_g_xy.grid(True, alpha=0.2)
719
+
720
+ # Grid XZ
721
+ ax_g_xz.scatter(grid_pos[:, 0], grid_pos[:, 2],
722
+ c=grid_dens, cmap='plasma', s=1, alpha=alpha_grid,
723
+ vmin=dens_min, vmax=dens_max)
724
+ ax_g_xz.set_xlim(grid_min[0], grid_max[0])
725
+ ax_g_xz.set_ylim(grid_min[2], grid_max[2])
726
+ ax_g_xz.set_xlabel('X')
727
+ ax_g_xz.set_ylabel('Z')
728
+ ax_g_xz.set_title('Grid XZ (Front View)', fontsize=10)
729
+ ax_g_xz.set_aspect('equal')
730
+ ax_g_xz.grid(True, alpha=0.2)
731
+
732
+ # Grid YZ
733
+ scatter_grid = ax_g_yz.scatter(grid_pos[:, 1], grid_pos[:, 2],
734
+ c=grid_dens, cmap='plasma', s=1, alpha=alpha_grid,
735
+ vmin=dens_min, vmax=dens_max)
736
+ ax_g_yz.set_xlim(grid_min[1], grid_max[1])
737
+ ax_g_yz.set_ylim(grid_min[2], grid_max[2])
738
+ ax_g_yz.set_xlabel('Y')
739
+ ax_g_yz.set_ylabel('Z')
740
+ ax_g_yz.set_title('Grid YZ (Side View)', fontsize=10)
741
+ ax_g_yz.set_aspect('equal')
742
+ ax_g_yz.grid(True, alpha=0.2)
743
+
744
+ # ==========================================
745
+ # Bottom row: Surface projections
746
+ # ==========================================
747
+
748
+ # Surface XY
749
+ ax_s_xy.scatter(surf_pts[:, 0], surf_pts[:, 1],
750
+ c='dodgerblue', s=1, alpha=alpha_surface)
751
+ ax_s_xy.set_xlim(surf_min[0], surf_max[0])
752
+ ax_s_xy.set_ylim(surf_min[1], surf_max[1])
753
+ ax_s_xy.set_xlabel('X')
754
+ ax_s_xy.set_ylabel('Y')
755
+ ax_s_xy.set_title('Surface XY (Top View)', fontsize=10)
756
+ ax_s_xy.set_aspect('equal')
757
+ ax_s_xy.grid(True, alpha=0.2)
758
+
759
+ # Surface XZ
760
+ ax_s_xz.scatter(surf_pts[:, 0], surf_pts[:, 2],
761
+ c='dodgerblue', s=1, alpha=alpha_surface)
762
+ ax_s_xz.set_xlim(surf_min[0], surf_max[0])
763
+ ax_s_xz.set_ylim(surf_min[2], surf_max[2])
764
+ ax_s_xz.set_xlabel('X')
765
+ ax_s_xz.set_ylabel('Z')
766
+ ax_s_xz.set_title('Surface XZ (Front View)', fontsize=10)
767
+ ax_s_xz.set_aspect('equal')
768
+ ax_s_xz.grid(True, alpha=0.2)
769
+
770
+ # Surface YZ
771
+ ax_s_yz.scatter(surf_pts[:, 1], surf_pts[:, 2],
772
+ c='dodgerblue', s=1, alpha=alpha_surface)
773
+ ax_s_yz.set_xlim(surf_min[1], surf_max[1])
774
+ ax_s_yz.set_ylim(surf_min[2], surf_max[2])
775
+ ax_s_yz.set_xlabel('Y')
776
+ ax_s_yz.set_ylabel('Z')
777
+ ax_s_yz.set_title('Surface YZ (Side View)', fontsize=10)
778
+ ax_s_yz.set_aspect('equal')
779
+ ax_s_yz.grid(True, alpha=0.2)
780
+
781
+ # Extract iteration and set main title
782
+ filename = os.path.basename(grid_files[frame])
783
+ if 'final' in filename:
784
+ fig.suptitle('Grid vs Surface: Final (Converged)',
785
+ fontsize=14, fontweight='bold')
786
+ else:
787
+ iter_num = int(filename.split('_')[-1].replace('.xyz', ''))
788
+ fig.suptitle(f'Grid vs Surface: Iteration {iter_num}',
789
+ fontsize=14)
790
+
791
+ return scatter_grid,
792
+
793
+ # Create initial frame for colorbar
794
+ grid_pos = all_grid_pos[0]
795
+ grid_dens = all_grid_dens[0]
796
+ scatter_grid = ax_g_yz.scatter(grid_pos[:, 1], grid_pos[:, 2],
797
+ c=grid_dens, cmap='plasma', s=1, alpha=alpha_grid,
798
+ vmin=dens_min, vmax=dens_max)
799
+
800
+ # Add colorbar for grid density (spans top row)
801
+ cbar = fig.colorbar(scatter_grid, ax=axes[0, :],
802
+ label='Grid Density', shrink=0.8, pad=0.02,
803
+ orientation='vertical', location='right')
804
+
805
+ print(f"Creating animation with {fps} fps...")
806
+ anim = FuncAnimation(fig, update, frames=len(grid_files),
807
+ interval=1000//fps, blit=False)
808
+
809
+ print(f"Saving to {output_file}...")
810
+ writer = PillowWriter(fps=fps)
811
+ anim.save(output_file, writer=writer)
812
+ plt.close()
813
+ print(f"✓ Animation saved to: {output_file}")
814
+
815
+
816
+ def plot_density_evolution(export_folder='vderm_exports',
817
+ grid_folder='vderm_grid',
818
+ output_file='density_evolution.png'):
819
+ """
820
+ Plot how mean and max density evolve over iterations.
821
+
822
+ Parameters
823
+ ----------
824
+ export_folder : str
825
+ Base export folder
826
+ grid_folder : str
827
+ Subfolder containing grid exports
828
+ output_file : str
829
+ Output plot filename
830
+
831
+ Examples
832
+ --------
833
+ >>> plot_density_evolution('my_exports')
834
+ """
835
+
836
+ pattern = os.path.join(export_folder, grid_folder, 'grid_iteration_*.xyz')
837
+ files = sorted(glob.glob(pattern))
838
+
839
+ if len(files) == 0:
840
+ raise FileNotFoundError(f"No grid files found at {pattern}")
841
+
842
+ iterations = []
843
+ mean_densities = []
844
+ max_densities = []
845
+ min_densities = []
846
+ std_densities = []
847
+
848
+ for f in tqdm(files, desc="Analyzing density"):
849
+ pos, norm, densities = read_xyz(f)
850
+
851
+ # Extract iteration number
852
+ filename = os.path.basename(f)
853
+ iter_num = int(filename.split('_')[-1].replace('.xyz', ''))
854
+
855
+ iterations.append(iter_num)
856
+ mean_densities.append(densities.mean())
857
+ max_densities.append(densities.max())
858
+ min_densities.append(densities.min())
859
+ std_densities.append(densities.std())
860
+
861
+ # Create plot
862
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
863
+
864
+ # Top: Mean, min, max
865
+ ax1.plot(iterations, mean_densities, 'b-', label='Mean', linewidth=2)
866
+ ax1.plot(iterations, max_densities, 'r--', label='Max', linewidth=1.5)
867
+ ax1.plot(iterations, min_densities, 'g--', label='Min', linewidth=1.5)
868
+ ax1.fill_between(iterations, min_densities, max_densities, alpha=0.2)
869
+ ax1.set_xlabel('Iteration')
870
+ ax1.set_ylabel('Density')
871
+ ax1.set_title('Density Statistics Over Time')
872
+ ax1.legend()
873
+ ax1.grid(True, alpha=0.3)
874
+
875
+ # Bottom: Standard deviation
876
+ ax2.plot(iterations, std_densities, 'purple', linewidth=2)
877
+ ax2.set_xlabel('Iteration')
878
+ ax2.set_ylabel('Density Std Dev')
879
+ ax2.set_title('Density Variation Over Time')
880
+ ax2.grid(True, alpha=0.3)
881
+
882
+ plt.tight_layout()
883
+ plt.savefig(output_file, dpi=150, bbox_inches='tight')
884
+ plt.close()
885
+
886
+ print(f"✓ Density evolution plot saved to: {output_file}")
887
+
888
+ def export_grid_to_paraview(export_folder='vderm_exports',
889
+ subfolder='vderm_grid',
890
+ output_folder='paraview_grid'):
891
+ """
892
+ Export grid data to VTK format for visualization in ParaView.
893
+
894
+ Exports grid node positions with all available attributes:
895
+ velocity vectors and density values (if present).
896
+
897
+ Grid files are expected to have format: x y z v_x v_y v_z rho
898
+
899
+ Parameters
900
+ ----------
901
+ export_folder : str, default='vderm_exports'
902
+ Base export folder
903
+ subfolder : str, default='vderm_grid'
904
+ Subfolder containing grid exports
905
+ output_folder : str, default='paraview_grid'
906
+ Folder to save VTK files
907
+
908
+ Returns
909
+ -------
910
+ None
911
+ VTK files saved to output_folder
912
+
913
+ Examples
914
+ --------
915
+ >>> export_grid_to_paraview('my_exports')
916
+ >>> # Then in ParaView: File → Open → select .vtk files
917
+ >>> # Color by 'density' to see density field evolution
918
+ >>> # Apply 'Glyph' filter with 'velocity' to visualize flow field
919
+ """
920
+
921
+ pattern = os.path.join(export_folder, subfolder, '*.xyz')
922
+ files = sorted(glob.glob(pattern))
923
+
924
+ if len(files) == 0:
925
+ raise FileNotFoundError(
926
+ f"No grid files found at {pattern}\n"
927
+ f"Make sure you ran run_VDERM_with_tracking with export_grid=True"
928
+ )
929
+
930
+ os.makedirs(output_folder, exist_ok=True)
931
+
932
+ for f in tqdm(files, desc="Converting grid to VTK"):
933
+ # Read grid data (should have positions + velocities + densities)
934
+ positions, velocities, densities = read_xyz(f)
935
+
936
+ # Check what data is available
937
+ has_velocities = velocities is not None
938
+ has_densities = densities is not None
939
+
940
+ if not has_velocities:
941
+ warnings.warn(
942
+ f"File {f} does not contain velocity vectors. "
943
+ "Grid exports should have 7 columns (x y z v_x v_y v_z rho)."
944
+ )
945
+
946
+ if not has_densities:
947
+ warnings.warn(
948
+ f"File {f} does not contain density data. "
949
+ "Grid exports should have 7 columns (x y z v_x v_y v_z rho)."
950
+ )
951
+
952
+ # Create output filename
953
+ basename = os.path.basename(f).replace('.xyz', '.vtk')
954
+ output_path = os.path.join(output_folder, basename)
955
+
956
+ # Write VTK file with all available attributes
957
+ with open(output_path, 'w') as vtk:
958
+ vtk.write('# vtk DataFile Version 3.0\n')
959
+ vtk.write('VDERM Grid Data\n')
960
+ vtk.write('ASCII\n')
961
+ vtk.write('DATASET POLYDATA\n')
962
+
963
+ # Write vertices
964
+ vtk.write(f'POINTS {len(positions)} float\n')
965
+ for pos in positions:
966
+ vtk.write(f'{pos[0]:.6e} {pos[1]:.6e} {pos[2]:.6e}\n')
967
+
968
+ # Write point data (only if we have velocities or densities)
969
+ if has_velocities or has_densities:
970
+ vtk.write(f'\nPOINT_DATA {len(positions)}\n')
971
+
972
+ # Add velocities as vector data
973
+ if has_velocities:
974
+ vtk.write('VECTORS velocity float\n')
975
+ for vel in velocities:
976
+ vtk.write(f'{vel[0]:.6e} {vel[1]:.6e} {vel[2]:.6e}\n')
977
+
978
+ # Add density as scalar data
979
+ if has_densities:
980
+ vtk.write('\nSCALARS density float 1\n')
981
+ vtk.write('LOOKUP_TABLE default\n')
982
+ for dens in densities:
983
+ vtk.write(f'{dens:.6e}\n')
984
+
985
+ # Print helpful summary
986
+ print(f"✓ Exported {len(files)} grid files to {output_folder}/")
987
+ print(f" Open ParaView and load the .vtk files to visualize")
988
+
989
+ if has_densities:
990
+ print(f" Tip: Color by 'density' to see density field evolution")
991
+ if has_velocities:
992
+ print(f" Tip: Apply 'Glyph' filter with 'velocity' to visualize flow field")
993
+ print(f" Or use 'Stream Tracer' to show particle trajectories")
994
+
995
+ def export_surface_to_paraview(export_folder='vderm_exports',
996
+ subfolder='vderm_surface',
997
+ output_folder='paraview_surface'):
998
+ """
999
+ Export surface point clouds to VTK format for ParaView.
1000
+
1001
+ Exports surface point clouds with all available attributes:
1002
+ normal vectors and density values (if present).
1003
+
1004
+ Surface files are expected to have format: x y z n_x n_y n_z rho
1005
+
1006
+ Parameters
1007
+ ----------
1008
+ export_folder : str, default='vderm_exports'
1009
+ Base export folder
1010
+ subfolder : str, default='vderm_surface'
1011
+ Subfolder containing surface exports
1012
+ output_folder : str, default='paraview_surface'
1013
+ Folder to save VTK files
1014
+
1015
+ Returns
1016
+ -------
1017
+ None
1018
+ VTK files saved to output_folder
1019
+
1020
+ Examples
1021
+ --------
1022
+ >>> export_surface_to_paraview('my_exports')
1023
+ >>> # In ParaView: Load .vtk files
1024
+ >>> # Color by 'density' to see density distribution on surface
1025
+ >>> # Apply 'Glyph' filter to show normal vectors
1026
+ """
1027
+
1028
+ pattern = os.path.join(export_folder, subfolder, '*.xyz')
1029
+ files = sorted(glob.glob(pattern))
1030
+
1031
+ if len(files) == 0:
1032
+ raise FileNotFoundError(
1033
+ f"No surface files found at {pattern}\n"
1034
+ f"Make sure you ran run_VDERM_with_tracking with export_surface=True"
1035
+ )
1036
+
1037
+ os.makedirs(output_folder, exist_ok=True)
1038
+
1039
+ for f in tqdm(files, desc="Converting surface to VTK"):
1040
+ # Read surface data (should have positions + normals + densities)
1041
+ positions, normals, densities = read_xyz(f)
1042
+
1043
+ # Check what data is available
1044
+ has_normals = normals is not None
1045
+ has_densities = densities is not None
1046
+
1047
+ if not has_normals:
1048
+ warnings.warn(
1049
+ f"File {f} does not contain normal vectors. "
1050
+ "Surface exports should have 7 columns (x y z n_x n_y n_z rho)."
1051
+ )
1052
+
1053
+ if not has_densities:
1054
+ warnings.warn(
1055
+ f"File {f} does not contain density data. "
1056
+ "Surface exports should have 7 columns (x y z n_x n_y n_z rho)."
1057
+ )
1058
+
1059
+ # Create output filename
1060
+ basename = os.path.basename(f).replace('.xyz', '.vtk')
1061
+ output_path = os.path.join(output_folder, basename)
1062
+
1063
+ # Write VTK file with all available attributes
1064
+ with open(output_path, 'w') as vtk:
1065
+ vtk.write('# vtk DataFile Version 3.0\n')
1066
+ vtk.write('VDERM Surface Point Cloud\n')
1067
+ vtk.write('ASCII\n')
1068
+ vtk.write('DATASET POLYDATA\n')
1069
+
1070
+ # Write vertices
1071
+ vtk.write(f'POINTS {len(positions)} float\n')
1072
+ for pos in positions:
1073
+ vtk.write(f'{pos[0]:.6e} {pos[1]:.6e} {pos[2]:.6e}\n')
1074
+
1075
+ # Write point data (only if we have normals or densities)
1076
+ if has_normals or has_densities:
1077
+ vtk.write(f'\nPOINT_DATA {len(positions)}\n')
1078
+
1079
+ # Add normals as vector data
1080
+ if has_normals:
1081
+ vtk.write('NORMALS normals float\n')
1082
+ for norm in normals:
1083
+ vtk.write(f'{norm[0]:.6e} {norm[1]:.6e} {norm[2]:.6e}\n')
1084
+
1085
+ # Add density as scalar data
1086
+ if has_densities:
1087
+ vtk.write('\nSCALARS density float 1\n')
1088
+ vtk.write('LOOKUP_TABLE default\n')
1089
+ for dens in densities:
1090
+ vtk.write(f'{dens:.6e}\n')
1091
+
1092
+ # Print summary
1093
+ print(f"✓ Exported {len(files)} surface files to {output_folder}/")
1094
+ print(f" Open ParaView and load the .vtk files to visualize")
1095
+
1096
+ if has_densities:
1097
+ print(f" Tip: Color by 'density' to see density distribution on surface")
1098
+ if has_normals:
1099
+ print(f" Tip: Apply 'Glyph' filter with 'normals' to visualize normal vectors")
1100
+
1101
+
1102
+ def export_meshes_to_paraview(export_folder='vderm_exports',
1103
+ subfolder='vderm_mesh',
1104
+ output_folder='paraview_meshes'):
1105
+ """
1106
+ Convert STL meshes to VTK format for ParaView.
1107
+
1108
+ **Note:** This function converts existing STL files to VTK but cannot include
1109
+ density data. For ParaView visualization with density vertex attributes,
1110
+ use run_VDERM_with_tracking() with mesh_format='vtk' instead.
1111
+
1112
+ Exports reconstructed surface meshes with vertex normals. ParaView can render
1113
+ these with better quality than matplotlib and allows interactive exploration.
1114
+
1115
+ Parameters
1116
+ ----------
1117
+ export_folder : str, default='vderm_exports'
1118
+ Base export folder
1119
+ subfolder : str, default='vderm_mesh'
1120
+ Subfolder containing mesh exports (.stl files)
1121
+ output_folder : str, default='paraview_meshes'
1122
+ Folder to save VTK mesh files
1123
+
1124
+ Returns
1125
+ -------
1126
+ None
1127
+ VTK files saved to output_folder
1128
+
1129
+ See Also
1130
+ --------
1131
+ run_VDERM_with_tracking : Use mesh_format='vtk' for meshes with density data
1132
+
1133
+ Examples
1134
+ --------
1135
+ >>> # Convert existing STL meshes to VTK (without density)
1136
+ >>> export_meshes_to_paraview('my_exports')
1137
+ >>> # In ParaView: File → Open → select .vtk files → Apply
1138
+
1139
+ >>> # Preferred: Generate VTK meshes with density directly
1140
+ >>> run_VDERM_with_tracking(grid, surface_pts, normals,
1141
+ ... export_mesh=True,
1142
+ ... mesh_format='vtk') # Better for ParaView!
1143
+ """
1144
+
1145
+ # Print recommendation
1146
+ print("\n" + "="*70)
1147
+ print("NOTE: For ParaView visualization with density vertex attributes,")
1148
+ print("the recommended approach is to use run_VDERM_with_tracking()")
1149
+ print("with mesh_format='vtk' instead of converting STL files.")
1150
+ print()
1151
+ print("Example:")
1152
+ print(" run_VDERM_with_tracking(grid, surface_pts, normals,")
1153
+ print(" export_mesh=True,")
1154
+ print(" mesh_format='vtk')")
1155
+ print()
1156
+ print("This function converts existing STL → VTK but cannot include density.")
1157
+ print("="*70 + "\n")
1158
+
1159
+ if not HAS_PYMESHLAB:
1160
+ raise ImportError(
1161
+ "This function requires pymeshlab for mesh conversion.\n"
1162
+ "Install with: pip install pymeshlab"
1163
+ )
1164
+ import pymeshlab as ml
1165
+
1166
+ pattern = os.path.join(export_folder, subfolder, '*.stl')
1167
+ files = sorted(glob.glob(pattern))
1168
+
1169
+ if len(files) == 0:
1170
+ raise FileNotFoundError(
1171
+ f"No mesh files found at {pattern}\n"
1172
+ f"Make sure you ran run_VDERM_with_tracking with export_mesh=True"
1173
+ )
1174
+
1175
+ os.makedirs(output_folder, exist_ok=True)
1176
+
1177
+ for f in tqdm(files, desc="Converting STL to VTK"):
1178
+ # Load mesh with pymeshlab
1179
+ ms = ml.MeshSet()
1180
+ ms.load_new_mesh(f)
1181
+
1182
+ # Get mesh data
1183
+ mesh = ms.current_mesh()
1184
+ vertices = mesh.vertex_matrix()
1185
+ faces = mesh.face_matrix()
1186
+
1187
+ # Compute normals
1188
+ ms.compute_normal_for_point_clouds()
1189
+ normals = mesh.vertex_normal_matrix()
1190
+
1191
+ # Create output filename
1192
+ basename = os.path.basename(f).replace('.stl', '.vtk')
1193
+ output_path = os.path.join(output_folder, basename)
1194
+
1195
+ # Write VTK POLYDATA file (geometry + normals only, no density)
1196
+ with open(output_path, 'w') as vtk:
1197
+ vtk.write('# vtk DataFile Version 3.0\n')
1198
+ vtk.write('VDERM Mesh (converted from STL, no density data)\n')
1199
+ vtk.write('ASCII\n')
1200
+ vtk.write('DATASET POLYDATA\n')
1201
+
1202
+ # Write vertices
1203
+ vtk.write(f'POINTS {len(vertices)} float\n')
1204
+ for v in vertices:
1205
+ vtk.write(f'{v[0]:.6e} {v[1]:.6e} {v[2]:.6e}\n')
1206
+
1207
+ # Write triangular faces
1208
+ vtk.write(f'\nPOLYGONS {len(faces)} {len(faces) * 4}\n')
1209
+ for face in faces:
1210
+ vtk.write(f'3 {face[0]} {face[1]} {face[2]}\n')
1211
+
1212
+ # Write normals as vector data
1213
+ vtk.write(f'\nPOINT_DATA {len(vertices)}\n')
1214
+ vtk.write('NORMALS normals float\n')
1215
+ for n in normals:
1216
+ vtk.write(f'{n[0]:.6e} {n[1]:.6e} {n[2]:.6e}\n')
1217
+
1218
+ print(f"\n✓ Exported {len(files)} mesh files to {output_folder}/")
1219
+ print(f" Open ParaView and load the .vtk files to visualize")
1220
+ print(f" Tip: In ParaView, use 'Surface With Edges' for best visualization")
1221
+ print(f"\n⚠ These meshes do NOT contain density data.")
1222
+ print(f" To get density coloring in ParaView, use mesh_format='vtk'")
1223
+ print(f" in run_VDERM_with_tracking() instead.\n")
1224
+
1225
+ def export_all_to_paraview(export_folder='vderm_exports',
1226
+ output_base='paraview_exports'):
1227
+ """
1228
+ Export all available data (grid, surface, meshes) to ParaView format.
1229
+
1230
+ Convenience function that exports everything that's available.
1231
+
1232
+ **Note:** If meshes were exported as .stl files, they will be converted
1233
+ to VTK but will not contain density data. For meshes with density coloring
1234
+ in ParaView, use run_VDERM_with_tracking() with mesh_format='vtk'.
1235
+
1236
+ Parameters
1237
+ ----------
1238
+ export_folder : str, default='vderm_exports'
1239
+ Base export folder from run_VDERM_with_tracking
1240
+ output_base : str, default='paraview_exports'
1241
+ Base folder for ParaView exports
1242
+
1243
+ Examples
1244
+ --------
1245
+ >>> export_all_to_paraview('my_deformation')
1246
+ >>> # Creates paraview_exports/grid/, surface/, and meshes/
1247
+ """
1248
+
1249
+ if not HAS_PYMESHLAB:
1250
+ raise ImportError(
1251
+ "This function requires pymeshlab for mesh conversion.\n"
1252
+ "Install with: pip install pymeshlab"
1253
+ )
1254
+ import pymeshlab as ml
1255
+
1256
+ exported = []
1257
+
1258
+ # Check what's available and export
1259
+ grid_path = os.path.join(export_folder, 'vderm_grid')
1260
+ if os.path.exists(grid_path) and len(glob.glob(os.path.join(grid_path, '*.xyz'))) > 0:
1261
+ output = os.path.join(output_base, 'grid')
1262
+ export_grid_to_paraview(export_folder, 'vderm_grid', output)
1263
+ exported.append('grid')
1264
+
1265
+ surface_path = os.path.join(export_folder, 'vderm_surface')
1266
+ if os.path.exists(surface_path) and len(glob.glob(os.path.join(surface_path, '*.xyz'))) > 0:
1267
+ output = os.path.join(output_base, 'surface')
1268
+ export_surface_to_paraview(export_folder, 'vderm_surface', output)
1269
+ exported.append('surface')
1270
+
1271
+ # Check for VTK meshes first, then STL
1272
+ mesh_path = os.path.join(export_folder, 'vderm_mesh')
1273
+ if os.path.exists(mesh_path):
1274
+ vtk_meshes = glob.glob(os.path.join(mesh_path, '*.vtk'))
1275
+ stl_meshes = glob.glob(os.path.join(mesh_path, '*.stl'))
1276
+
1277
+ if len(vtk_meshes) > 0:
1278
+ # VTK meshes already exist - just copy them
1279
+ output = os.path.join(output_base, 'meshes')
1280
+ os.makedirs(output, exist_ok=True)
1281
+ import shutil
1282
+ for vtk_file in vtk_meshes:
1283
+ shutil.copy(vtk_file, output)
1284
+ print(f"✓ Copied {len(vtk_meshes)} VTK mesh files (with density data)")
1285
+ exported.append('meshes (VTK with density)')
1286
+
1287
+ elif len(stl_meshes) > 0:
1288
+ # Convert STL to VTK (without density)
1289
+ output = os.path.join(output_base, 'meshes')
1290
+ export_meshes_to_paraview(export_folder, 'vderm_mesh', output)
1291
+ exported.append('meshes (converted from STL, no density)')
1292
+
1293
+ if not exported:
1294
+ print("No VDERM exports found to convert!")
1295
+ else:
1296
+ print(f"\n✓ Exported {', '.join(exported)} to {output_base}/")
1297
+ print("\nTo visualize in ParaView:")
1298
+ print(" 1. Open ParaView")
1299
+ print(" 2. File → Open → Navigate to paraview_exports/")
1300
+ print(" 3. Select all .vtk files in a folder")
1301
+ print(" 4. Click 'Apply' in the Properties panel")
1302
+ print(" 5. Use the time slider to animate through iterations")
1303
+
1304
+ if 'meshes (converted from STL, no density)' in exported:
1305
+ print("\n⚠ Meshes converted from STL do not contain density data.")
1306
+ print(" For density coloring, re-run with mesh_format='vtk'")
1307
+
1308
+
1309
+ def plot_pcd(positions, densities=None, normals=None,
1310
+ title='Point Cloud',
1311
+ cmap='plasma',
1312
+ figsize=(12, 9),
1313
+ show_normals=False,
1314
+ normal_scale=0.05,
1315
+ point_size=1,
1316
+ alpha=0.6,
1317
+ view='3d',
1318
+ save_file=None):
1319
+ """
1320
+ Plot a single point cloud with optional density coloring and normal vectors.
1321
+
1322
+ Can display as 3D view or three 2D orthogonal projections.
1323
+
1324
+ Parameters
1325
+ ----------
1326
+ positions : ndarray, shape (n_points, 3)
1327
+ Point positions [x, y, z]
1328
+ densities : ndarray, shape (n_points,), optional
1329
+ Density values for color coding. If None, uses uniform color.
1330
+ normals : ndarray, shape (n_points, 3), optional
1331
+ Normal or velocity vectors to display
1332
+ title : str, default='Point Cloud'
1333
+ Plot title
1334
+ cmap : str, default='plasma'
1335
+ Colormap for density visualization
1336
+ figsize : tuple, default=(12, 9)
1337
+ Figure size in inches
1338
+ show_normals : bool, default=False
1339
+ If True and normals provided, draw vector arrows
1340
+ normal_scale : float, default=0.05
1341
+ Scale factor for normal vector arrows (as fraction of domain size)
1342
+ point_size : float, default=1
1343
+ Size of points in scatter plot
1344
+ alpha : float, default=0.6
1345
+ Point transparency (0=transparent, 1=opaque)
1346
+ view : str, default='3d'
1347
+ View type: '3d' for 3D plot, '2d' for three orthogonal projections
1348
+ save_file : str, optional
1349
+ If provided, save plot to this file instead of showing
1350
+
1351
+ Returns
1352
+ -------
1353
+ fig : matplotlib Figure
1354
+ The figure object
1355
+
1356
+ Examples
1357
+ --------
1358
+ >>> # Simple 3D plot
1359
+ >>> positions = np.random.rand(1000, 3)
1360
+ >>> plot_pcd(positions)
1361
+
1362
+ >>> # With density coloring
1363
+ >>> densities = np.random.rand(1000)
1364
+ >>> plot_pcd(positions, densities=densities, cmap='viridis')
1365
+
1366
+ >>> # 2D projections with density
1367
+ >>> plot_pcd(positions, densities=densities, view='2d')
1368
+
1369
+ >>> # Show normal vectors
1370
+ >>> normals = np.random.randn(1000, 3)
1371
+ >>> plot_pcd(positions, normals=normals, show_normals=True)
1372
+
1373
+ >>> # Save to file
1374
+ >>> plot_pcd(positions, densities=densities, save_file='myplot.png')
1375
+ """
1376
+
1377
+ # Get bounds
1378
+ pos_min = positions.min(axis=0)
1379
+ pos_max = positions.max(axis=0)
1380
+ ranges = pos_max - pos_min
1381
+
1382
+ # Compute normal scale if showing normals
1383
+ if show_normals and normals is not None:
1384
+ arrow_length = normal_scale * ranges.mean()
1385
+
1386
+ if view == '3d':
1387
+ # Single 3D plot
1388
+ fig = plt.figure(figsize=figsize)
1389
+ ax = fig.add_subplot(111, projection='3d')
1390
+
1391
+ # Plot points
1392
+ if densities is not None:
1393
+ scatter = ax.scatter(positions[:, 0], positions[:, 1], positions[:, 2],
1394
+ c=densities, cmap=cmap, s=point_size, alpha=alpha)
1395
+ cbar = plt.colorbar(scatter, ax=ax, label='Density', shrink=0.6)
1396
+ else:
1397
+ ax.scatter(positions[:, 0], positions[:, 1], positions[:, 2],
1398
+ c='dodgerblue', s=point_size, alpha=alpha)
1399
+
1400
+ # Show normals if requested
1401
+ if show_normals and normals is not None:
1402
+ # Subsample for clarity
1403
+ step = max(1, len(positions) // 100)
1404
+ ax.quiver(positions[::step, 0], positions[::step, 1], positions[::step, 2],
1405
+ normals[::step, 0], normals[::step, 1], normals[::step, 2],
1406
+ length=arrow_length, color='red', alpha=0.5, arrow_length_ratio=0.3)
1407
+
1408
+ # Set limits and aspect
1409
+ ax.set_xlim(pos_min[0], pos_max[0])
1410
+ ax.set_ylim(pos_min[1], pos_max[1])
1411
+ ax.set_zlim(pos_min[2], pos_max[2])
1412
+ ax.set_box_aspect(ranges)
1413
+
1414
+ ax.set_xlabel('X')
1415
+ ax.set_ylabel('Y')
1416
+ ax.set_zlabel('Z')
1417
+ ax.set_title(title, fontsize=14, fontweight='bold')
1418
+
1419
+ elif view == '2d':
1420
+ # Three orthogonal projections
1421
+ fig, (ax_xy, ax_xz, ax_yz) = plt.subplots(1, 3, figsize=figsize)
1422
+
1423
+ # XY plane
1424
+ if densities is not None:
1425
+ scatter = ax_xy.scatter(positions[:, 0], positions[:, 1],
1426
+ c=densities, cmap=cmap, s=point_size, alpha=alpha)
1427
+ else:
1428
+ ax_xy.scatter(positions[:, 0], positions[:, 1],
1429
+ c='dodgerblue', s=point_size, alpha=alpha)
1430
+
1431
+ ax_xy.set_xlim(pos_min[0], pos_max[0])
1432
+ ax_xy.set_ylim(pos_min[1], pos_max[1])
1433
+ ax_xy.set_xlabel('X')
1434
+ ax_xy.set_ylabel('Y')
1435
+ ax_xy.set_title('XY Plane (Top View)')
1436
+ ax_xy.set_aspect('equal')
1437
+ ax_xy.grid(True, alpha=0.3)
1438
+
1439
+ # XZ plane
1440
+ if densities is not None:
1441
+ ax_xz.scatter(positions[:, 0], positions[:, 2],
1442
+ c=densities, cmap=cmap, s=point_size, alpha=alpha)
1443
+ else:
1444
+ ax_xz.scatter(positions[:, 0], positions[:, 2],
1445
+ c='dodgerblue', s=point_size, alpha=alpha)
1446
+
1447
+ ax_xz.set_xlim(pos_min[0], pos_max[0])
1448
+ ax_xz.set_ylim(pos_min[2], pos_max[2])
1449
+ ax_xz.set_xlabel('X')
1450
+ ax_xz.set_ylabel('Z')
1451
+ ax_xz.set_title('XZ Plane (Front View)')
1452
+ ax_xz.set_aspect('equal')
1453
+ ax_xz.grid(True, alpha=0.3)
1454
+
1455
+ # YZ plane
1456
+ if densities is not None:
1457
+ scatter = ax_yz.scatter(positions[:, 1], positions[:, 2],
1458
+ c=densities, cmap=cmap, s=point_size, alpha=alpha)
1459
+ else:
1460
+ ax_yz.scatter(positions[:, 1], positions[:, 2],
1461
+ c='dodgerblue', s=point_size, alpha=alpha)
1462
+
1463
+ ax_yz.set_xlim(pos_min[1], pos_max[1])
1464
+ ax_yz.set_ylim(pos_min[2], pos_max[2])
1465
+ ax_yz.set_xlabel('Y')
1466
+ ax_yz.set_ylabel('Z')
1467
+ ax_yz.set_title('YZ Plane (Side View)')
1468
+ ax_yz.set_aspect('equal')
1469
+ ax_yz.grid(True, alpha=0.3)
1470
+
1471
+ # Add colorbar if using densities
1472
+ if densities is not None:
1473
+ cbar = fig.colorbar(scatter, ax=[ax_xy, ax_xz, ax_yz],
1474
+ label='Density', shrink=0.8, pad=0.02)
1475
+
1476
+ fig.suptitle(title, fontsize=16, fontweight='bold')
1477
+ # plt.tight_layout()
1478
+
1479
+ else:
1480
+ raise ValueError(f"view must be '3d' or '2d', got '{view}'")
1481
+
1482
+ # Save or show
1483
+ if save_file:
1484
+ plt.savefig(save_file, dpi=150, bbox_inches='tight')
1485
+ plt.close()
1486
+ print(f"✓ Plot saved to: {save_file}")
1487
+ else:
1488
+ plt.show()
1489
+
1490
+ return fig