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,1329 @@
1
+ import numpy as np
2
+ from scipy import interpolate
3
+ from tqdm import tqdm
4
+ import os
5
+ from scipy.interpolate import RegularGridInterpolator, NearestNDInterpolator
6
+ try:
7
+ import pymeshlab as ml
8
+ HAS_PYMESHLAB = True
9
+ except ImportError:
10
+ HAS_PYMESHLAB = False
11
+ ml = None
12
+
13
+ def _require_pymeshlab(function_name):
14
+ """Raise error if pymeshlab not available."""
15
+ if not HAS_PYMESHLAB:
16
+ raise ImportError(
17
+ f"{function_name} requires pymeshlab for mesh operations.\n"
18
+ f"Install with: pip install pymeshlab\n"
19
+ f"Or install with 3-D mesh support: pip install diffusion-cartogram[3D]"
20
+ )
21
+
22
+ def create_pcd(mesh_path, n_pts=25_000, sampling_method='poisson'):
23
+ """
24
+ Parameters
25
+ ----------
26
+ mesh_path : str
27
+ mesh file path
28
+ n_pts : int
29
+ number of points to sample
30
+ sampling_method : str, 'poisson' or 'uniform'
31
+ pymeshlab sampling method
32
+
33
+ Returns
34
+ -------
35
+ outs : ndarray, shape (n_points, 3)
36
+ Point positions [x, y, z]
37
+ normals : ndarray, shape (n_points, 3)
38
+ Normal vectors [n_x, n_y, n_z]
39
+
40
+ """
41
+ _require_pymeshlab('create_pcd')
42
+
43
+ ms = ml.MeshSet()
44
+ ms.load_new_mesh(mesh_path)
45
+
46
+ if sampling_method == 'poisson':
47
+ ms.generate_sampling_poisson_disk(samplenum=n_pts)
48
+ else: # uniform
49
+ ms.generate_sampling_montecarlo(samplenum=n_pts)
50
+
51
+ current_mesh = ms.current_mesh()
52
+ out = current_mesh.vertex_matrix()
53
+ norms = current_mesh.vertex_normal_matrix()
54
+
55
+ return out, norms
56
+
57
+
58
+ def write_xyz(filepath, positions, normals=None, densities=None):
59
+ """
60
+ Write grid positions (and optionally normal vectors and densities) to space-delimited .xyz file.
61
+
62
+ Parameters
63
+ ----------
64
+ filepath : str
65
+ Output file path
66
+ positions : ndarray, shape (n_points, 3)
67
+ Point positions [x, y, z]
68
+ normals : ndarray, shape (n_points, 3), optional
69
+ Normal vectors [n_x, n_y, n_z]
70
+ densities : ndarray, shape (n_points,), optional
71
+ Density values at each grid node. If None, only positions are written.
72
+
73
+ Examples
74
+ --------
75
+ >>> positions = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]])
76
+ >>> normals = np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]])
77
+ >>> densities = np.array([1.0, 1.5, 2.0])
78
+ >>> write_xyz('grid.xyz', positions, normals, densities)
79
+
80
+ Output file format:
81
+ x y z n_x n_y n_z rho
82
+ 0.0 0.0 0.0 0.0 0.0 1.0 1.0
83
+ 1.0 0.0 0.0 0.0 1.0 0.0 1.5
84
+ 0.0 1.0 0.0 1.0 0.0 0.0 2.0
85
+ """
86
+ # Validate inputs
87
+ n_points = len(positions)
88
+
89
+ if normals is not None and len(normals) != n_points:
90
+ raise ValueError(
91
+ f"Normals length ({len(normals)}) must match positions ({n_points})"
92
+ )
93
+
94
+ if densities is not None and len(densities) != n_points:
95
+ raise ValueError(
96
+ f"Densities length ({len(densities)}) must match positions ({n_points})"
97
+ )
98
+
99
+ # Build data array based on what's provided
100
+ data_columns = [positions]
101
+
102
+ if normals is not None:
103
+ data_columns.append(normals)
104
+
105
+ if densities is not None:
106
+ # Reshape densities to column vector if needed
107
+ if densities.ndim == 1:
108
+ densities = densities.reshape(-1, 1)
109
+ data_columns.append(densities)
110
+
111
+ # Stack all columns
112
+ data = np.hstack(data_columns)
113
+
114
+ # Save with numpy
115
+ np.savetxt(filepath, data, fmt='%.6e', delimiter=' ')
116
+
117
+
118
+ def read_xyz(filepath):
119
+ """
120
+ Read point data from space-delimited .xyz file.
121
+
122
+ Automatically detects file format based on number of columns:
123
+ - 3 columns: x y z (positions only)
124
+ - 4 columns: x y z rho (positions + densities)
125
+ - 6 columns: x y z n_x n_y n_z (positions + normals)
126
+ - 7 columns: x y z n_x n_y n_z rho (positions + normals + densities)
127
+
128
+ Parameters
129
+ ----------
130
+ filepath : str
131
+ Input file path
132
+
133
+ Returns
134
+ -------
135
+ positions : ndarray, shape (n_points, 3)
136
+ Point positions [x, y, z]
137
+ normals : ndarray, shape (n_points, 3) or None
138
+ Normal vectors [n_x, n_y, n_z], or None if not in file
139
+ densities : ndarray, shape (n_points,) or None
140
+ Density values, or None if not in file
141
+
142
+ Raises
143
+ ------
144
+ ValueError
145
+ If file doesn't have a recognized format (3, 4, 6, or 7 columns)
146
+
147
+ Examples
148
+ --------
149
+ >>> # File with positions only
150
+ >>> positions, normals, densities = read_xyz('points.xyz')
151
+ >>> # normals is None, densities is None
152
+
153
+ >>> # File with positions + normals
154
+ >>> positions, normals, densities = read_xyz('surface.xyz')
155
+ >>> # normals is array, densities is None
156
+
157
+ >>> # File with positions + densities
158
+ >>> positions, normals, densities = read_xyz('grid.xyz')
159
+ >>> # normals is None, densities is array
160
+
161
+ >>> # File with everything
162
+ >>> positions, normals, densities = read_xyz('complete.xyz')
163
+ >>> # Both normals and densities are arrays
164
+ """
165
+ # Load data
166
+ data = np.loadtxt(filepath)
167
+
168
+ # Handle single point case
169
+ if data.ndim == 1:
170
+ data = data.reshape(1, -1)
171
+
172
+ n_cols = data.shape[1]
173
+
174
+ # Extract data based on number of columns
175
+ if n_cols == 3:
176
+ # x y z
177
+ positions = data[:, 0:3]
178
+ normals = None
179
+ densities = None
180
+
181
+ elif n_cols == 4:
182
+ # x y z rho
183
+ positions = data[:, 0:3]
184
+ normals = None
185
+ densities = data[:, 3]
186
+
187
+ elif n_cols == 6:
188
+ # x y z n_x n_y n_z
189
+ positions = data[:, 0:3]
190
+ normals = data[:, 3:6]
191
+ densities = None
192
+
193
+ elif n_cols == 7:
194
+ # x y z n_x n_y n_z rho
195
+ positions = data[:, 0:3]
196
+ normals = data[:, 3:6]
197
+ densities = data[:, 6]
198
+
199
+ else:
200
+ raise ValueError(
201
+ f"Unrecognized file format: {n_cols} columns.\n"
202
+ f"Expected 3 (xyz), 4 (xyz+rho), 6 (xyz+normals), or 7 (xyz+normals+rho).\n"
203
+ f"File: {filepath}"
204
+ )
205
+
206
+ return positions, normals, densities
207
+
208
+
209
+ def compute_grid_dimensions(box_dims, max_points=32768):
210
+ """
211
+ Compute grid dimensions (L, M, N) and spacing (h) for a given box size.
212
+
213
+ Creates a grid with approximately max_points that maintains uniform
214
+ spacing and respects the box aspect ratio.
215
+
216
+ Parameters
217
+ ----------
218
+ box_dims : array-like, shape (3,)
219
+ Desired box dimensions [x_size, y_size, z_size]
220
+ max_points : int, default=32768
221
+ Target number of grid points
222
+
223
+ Returns
224
+ -------
225
+ shape : tuple (L, M, N)
226
+ Grid dimensions
227
+ h : float
228
+ Grid spacing (uniform in all directions)
229
+
230
+ Examples
231
+ --------
232
+ >>> # For a 2×3×4 box with ~10k points
233
+ >>> shape, h = compute_grid_dimensions([2, 3, 4], max_points=10000)
234
+ >>> print(f"Grid: {shape}, spacing: {h:.4f}")
235
+ """
236
+ # Compute aspect ratios
237
+ aspect_ratios = box_dims / box_dims.min()
238
+
239
+ # Find grid dimensions that give approximately max_points
240
+ volume_factor = np.prod(aspect_ratios)
241
+ base_dim = (max_points / volume_factor) ** (1/3)
242
+
243
+ # Compute dimensions based on aspect ratios
244
+ dimensions = aspect_ratios * base_dim
245
+
246
+ # Round to integers (at least 3 points per dimension)
247
+ L, M, N = np.maximum(np.round(dimensions).astype(int), 3)
248
+
249
+ # Compute uniform grid spacing
250
+ h = box_dims[0] / (L - 1)
251
+
252
+ # Adjust other dimensions to maintain uniform spacing
253
+ M = max(3, int(np.round(box_dims[1] / h)) + 1)
254
+ N = max(3, int(np.round(box_dims[2] / h)) + 1)
255
+
256
+ return (L, M, N), h
257
+
258
+ def make_initial_grid(pcd, max_points=32768, padding=(2, 2, 2)):
259
+ """
260
+ Generate the parameters for a computational grid automatically sized to fit a point cloud object.
261
+
262
+ The grid will:
263
+ - Have the same aspect ratio as the object's bounding box
264
+ - Be padded by specified ratios in each dimension
265
+ - Have approximately max_points total grid points
266
+ - Be centered on the object
267
+
268
+ Parameters
269
+ ----------
270
+ pcd : ndarray, shape (n_points, 3)
271
+ Point cloud defining the object (from create_pcd)
272
+ max_points : int, default=32768
273
+ Target maximum number of grid points. The function will create
274
+ a grid with approximately this many points while maintaining
275
+ the aspect ratio. Default is 32^3 = 32768.
276
+ padding : tuple of floats (x_ratio, y_ratio, z_ratio), default=(2, 2, 2)
277
+ Padding ratios for each axis. Grid size along each axis will be
278
+ ratio * object_size for that axis. Default (2, 2, 2) means grid
279
+ is 2x object size in each dimension (object uses central 1/3).
280
+ Examples:
281
+ - (2, 2, 2): Uniform 2x padding (default)
282
+ - (3, 3, 3): More accomodating 3x padding
283
+ - (5, 3, 3): More padding in X direction
284
+
285
+ Returns
286
+ -------
287
+ grid_params : dict
288
+ Dictionary containing:
289
+ - 'shape': tuple (L, M, N) - grid dimensions
290
+ - 'h': float - grid spacing
291
+ - 'min_bounds': ndarray - lower corner [x_min, y_min, z_min]
292
+ - 'max_bounds': ndarray - upper corner [x_max, y_max, z_max]
293
+ - 'object_bounds': dict with 'min' and 'max' - original object bounds
294
+ - 'padding': tuple - the padding ratios used
295
+
296
+ Examples
297
+ --------
298
+ >>> # Default 2x padding
299
+ >>> pcd, normals = create_pcd('mesh.stl', n_pts=25000)
300
+ >>> grid, params = make_initial_grid(pcd, max_points=32768)
301
+
302
+ >>> # Tighter fit with 1.5x padding
303
+ >>> grid, params = make_initial_grid(pcd, max_points=32768, padding=(1.5, 1.5, 1.5))
304
+
305
+ >>> # Asymmetric padding (more space in Z direction)
306
+ >>> grid, params = make_initial_grid(pcd, max_points=32768, padding=(3, 3, 5))
307
+ """
308
+
309
+ # Convert padding to array
310
+ padding = np.array(padding)
311
+
312
+ # Get object bounding box
313
+ obj_min = pcd.min(axis=0)
314
+ obj_max = pcd.max(axis=0)
315
+ obj_size = obj_max - obj_min
316
+ obj_center = (obj_min + obj_max) / 2
317
+
318
+ # Compute box size
319
+ padding = np.array(padding)
320
+ box_dims = padding * obj_size
321
+
322
+ # Use helper to get grid dimensions
323
+ shape, h = compute_grid_dimensions(box_dims, max_points)
324
+
325
+ # Round to integers (at least 3 points per dimension for derivatives)
326
+ L, M, N = shape
327
+
328
+ # Compute grid bounds (centered on object)
329
+ grid_half_size = h * np.array([L-1, M-1, N-1]) / 2
330
+ grid_min = obj_center - grid_half_size
331
+ grid_max = obj_center + grid_half_size
332
+
333
+ # Package parameters
334
+ grid_params = {
335
+ 'shape': (L, M, N),
336
+ 'h': h,
337
+ 'min_bounds': grid_min,
338
+ 'max_bounds': grid_max,
339
+ 'object_bounds': {
340
+ 'min': obj_min,
341
+ 'max': obj_max,
342
+ 'size': obj_size,
343
+ 'center': obj_center
344
+ },
345
+ 'padding': tuple(padding),
346
+ 'actual_points': L * M * N
347
+ }
348
+
349
+ return grid_params
350
+
351
+
352
+ def print_grid_info(grid_params):
353
+ """
354
+ Print information about the generated grid.
355
+
356
+ Parameters
357
+ ----------
358
+ grid_params : dict
359
+ Dictionary returned by make_initial_grid
360
+ """
361
+ L, M, N = grid_params['shape']
362
+ h = grid_params['h']
363
+ obj_bounds = grid_params['object_bounds']
364
+
365
+ print("=" * 70)
366
+ print("GRID INFORMATION")
367
+ print("=" * 70)
368
+ print(f"\nGrid dimensions: {L} × {M} × {N} = {grid_params['actual_points']:,} points")
369
+ print(f"Grid spacing (h): {h:.6f}")
370
+
371
+ grid_size = grid_params['max_bounds'] - grid_params['min_bounds']
372
+ obj_size = obj_bounds['size']
373
+
374
+ print(f"\nGrid size: [{grid_size[0]:.4f}, {grid_size[1]:.4f}, {grid_size[2]:.4f}]")
375
+ print(f"Object size: [{obj_size[0]:.4f}, {obj_size[1]:.4f}, {obj_size[2]:.4f}]")
376
+ print(f"Ratio: [{grid_size[0]/obj_size[0]:.2f}x, "
377
+ f"{grid_size[1]/obj_size[1]:.2f}x, {grid_size[2]/obj_size[2]:.2f}x]")
378
+
379
+ print(f"\nGrid bounds:")
380
+ print(f" x: [{grid_params['min_bounds'][0]:.4f}, {grid_params['max_bounds'][0]:.4f}]")
381
+ print(f" y: [{grid_params['min_bounds'][1]:.4f}, {grid_params['max_bounds'][1]:.4f}]")
382
+ print(f" z: [{grid_params['min_bounds'][2]:.4f}, {grid_params['max_bounds'][2]:.4f}]")
383
+
384
+ print(f"\nObject bounds:")
385
+ print(f" x: [{obj_bounds['min'][0]:.4f}, {obj_bounds['max'][0]:.4f}]")
386
+ print(f" y: [{obj_bounds['min'][1]:.4f}, {obj_bounds['max'][1]:.4f}]")
387
+ print(f" z: [{obj_bounds['min'][2]:.4f}, {obj_bounds['max'][2]:.4f}]")
388
+
389
+ # Check margins
390
+ margins_min = obj_bounds['min'] - grid_params['min_bounds']
391
+ margins_max = grid_params['max_bounds'] - obj_bounds['max']
392
+
393
+ print(f"\nObject margins (distance from grid boundary):")
394
+ print(f" x: min={margins_min[0]:.4f}, max={margins_max[0]:.4f}")
395
+ print(f" y: min={margins_min[1]:.4f}, max={margins_max[1]:.4f}")
396
+ print(f" z: min={margins_min[2]:.4f}, max={margins_max[2]:.4f}")
397
+
398
+ # Verify padding relationship
399
+ print(f"\nVerification for padding ratios:")
400
+ actual_ratios = grid_size / obj_size
401
+ print(f" x: {actual_ratios[0]:.3f}x")
402
+ print(f" y: {actual_ratios[1]:.3f}x")
403
+ print(f" z: {actual_ratios[2]:.3f}x")
404
+
405
+ print("=" * 70)
406
+
407
+ class VDERMGrid:
408
+ """
409
+ Combined Lagrangian-Eulerian grid for VDERM deformation.
410
+ Grid nodes are both computational points (Eulerian) and material points (Lagrangian).
411
+ """
412
+
413
+ def __init__(self, shape, h, min_bounds):
414
+ """
415
+ Parameters
416
+ ----------
417
+ shape : tuple (L, M, N)
418
+ Grid dimensions
419
+ h : float
420
+ Grid spacing
421
+ min_bounds : array-like [x_min, y_min, z_min]
422
+ Lower corner of grid
423
+ """
424
+ self.L, self.M, self.N = shape
425
+ self.h = h
426
+ self.min_bounds = np.array(min_bounds)
427
+
428
+ # Density field (Eulerian)
429
+ self.rho = np.ones(shape)
430
+
431
+ # Node positions (Lagrangian) - flattened for easier iteration
432
+ self.positions = self._initialize_positions()
433
+ self.velocities = np.zeros_like(self.positions)
434
+
435
+ # Store initial positions for computing displacement field
436
+ self.initial_positions = self.positions.copy()
437
+
438
+ def _initialize_positions(self):
439
+ """Create initial grid node positions"""
440
+ positions = []
441
+ for i in range(self.L):
442
+ for j in range(self.M):
443
+ for k in range(self.N):
444
+ pos = self.min_bounds + self.h * np.array([i, j, k])
445
+ positions.append(pos)
446
+ return np.array(positions)
447
+
448
+ def _index_to_flat(self, i, j, k):
449
+ """Convert 3D grid index to flat array index"""
450
+ return i * (self.M * self.N) + j * self.N + k
451
+
452
+ def _flat_to_index(self, flat_idx):
453
+ """Convert flat array index to 3D grid index"""
454
+ i = flat_idx // (self.M * self.N)
455
+ remainder = flat_idx % (self.M * self.N)
456
+ j = remainder // self.N
457
+ k = remainder % self.N
458
+ return i, j, k
459
+
460
+ def set_density(self, density_func):
461
+ """
462
+ Set density field using a function or array.
463
+
464
+ Parameters
465
+ ----------
466
+ density_func : callable or array
467
+ If callable: density_func(x, y, z) -> density
468
+ If array: shape must match (L, M, N)
469
+ """
470
+ if callable(density_func):
471
+ for idx, pos in enumerate(self.positions):
472
+ i, j, k = self._flat_to_index(idx)
473
+ self.rho[i, j, k] = density_func(*pos)
474
+ else:
475
+ self.rho = np.array(density_func)
476
+
477
+ def update_density(self, dt):
478
+ """Diffuse density field using heat equation"""
479
+ rho_new = np.zeros_like(self.rho)
480
+
481
+ for i in range(self.L):
482
+ for j in range(self.M):
483
+ for k in range(self.N):
484
+ # Get neighbor indices with boundary conditions
485
+ i_p, i_m = self._get_neighbors(i, self.L)
486
+ j_p, j_m = self._get_neighbors(j, self.M)
487
+ k_p, k_m = self._get_neighbors(k, self.N)
488
+
489
+ # Laplacian
490
+ laplacian = (
491
+ self.rho[i_p, j, k] + self.rho[i_m, j, k] +
492
+ self.rho[i, j_p, k] + self.rho[i, j_m, k] +
493
+ self.rho[i, j, k_p] + self.rho[i, j, k_m] -
494
+ 6 * self.rho[i, j, k]
495
+ )
496
+
497
+ rho_new[i, j, k] = self.rho[i, j, k] + (dt / self.h**2) * laplacian
498
+
499
+ # Convergence metric
500
+ self.epsilon = np.linalg.norm(rho_new - self.rho) / np.mean(self.rho)
501
+ self.rho = rho_new
502
+
503
+ def _get_neighbors(self, idx, max_idx):
504
+ """Get neighbor indices with boundary conditions"""
505
+ if idx == max_idx - 1:
506
+ idx_plus = idx
507
+ idx_minus = idx - 1
508
+ elif idx == 0:
509
+ idx_plus = idx + 1
510
+ idx_minus = idx
511
+ else:
512
+ idx_plus = idx + 1
513
+ idx_minus = idx - 1
514
+ return idx_plus, idx_minus
515
+
516
+ def update_velocities(self):
517
+ """Compute velocity for each node from density gradient"""
518
+ for idx in range(len(self.positions)):
519
+ i, j, k = self._flat_to_index(idx)
520
+
521
+ # Get neighbors
522
+ i_p, i_m = self._get_neighbors(i, self.L)
523
+ j_p, j_m = self._get_neighbors(j, self.M)
524
+ k_p, k_m = self._get_neighbors(k, self.N)
525
+
526
+ # Density gradient (centered difference)
527
+ grad_rho = np.array([
528
+ self.rho[i_p, j, k] - self.rho[i_m, j, k],
529
+ self.rho[i, j_p, k] - self.rho[i, j_m, k],
530
+ self.rho[i, j, k_p] - self.rho[i, j, k_m]
531
+ ])
532
+
533
+ # Velocity from gradient
534
+ rho_at_node = self.rho[i, j, k]
535
+ self.velocities[idx] = -grad_rho / (2 * self.h * rho_at_node)
536
+
537
+ def update_positions(self, dt):
538
+ """Move grid nodes based on velocities"""
539
+ self.positions += dt * self.velocities
540
+
541
+ def get_displacement_field(self):
542
+ """
543
+ Compute displacement vectors for each grid node.
544
+
545
+ Returns
546
+ -------
547
+ displacements : ndarray, shape (n_nodes, 3)
548
+ Displacement vectors: final_position - initial_position
549
+ """
550
+ return self.positions - self.initial_positions
551
+
552
+ def compute_timestep(self):
553
+ """Compute stable timestep based on velocities"""
554
+ # Compute both stability limits
555
+ max_speed = np.max(np.abs(self.velocities).sum(axis=1))
556
+
557
+ if max_speed > 1e-10:
558
+ dt_advection = 2 * self.h / (3 * max_speed)
559
+ else:
560
+ dt_advection = np.inf
561
+
562
+ # Diffusion stability (most restrictive for fine grids)
563
+ dt_diffusion = self.h**2 / 6
564
+
565
+ # Take minimum with safety factor of 0.9
566
+ dt = min(dt_advection, dt_diffusion) * 0.9
567
+ dt = min(dt, 0.01) # Cap at 0.01
568
+ return min(dt, 0.01)
569
+
570
+ def run_VDERM(grid, n_max=100, max_eps=0.01, dt=None):
571
+ """
572
+ Run VDERM deformation algorithm on a computational grid.
573
+
574
+ Iteratively deforms the grid by diffusing the density field and advecting
575
+ grid nodes according to the resulting velocity field. Continues until either
576
+ convergence (epsilon ≤ max_eps) or maximum iterations reached.
577
+
578
+ Parameters
579
+ ----------
580
+ grid : VDERMGrid
581
+ Grid object with density field already set via grid.set_density()
582
+ n_max : int, default=100
583
+ Maximum number of iterations to perform
584
+ max_eps : float, default=0.01
585
+ Convergence threshold. Algorithm stops when the relative change in
586
+ density field (epsilon) falls below this value.
587
+ dt : float, optional
588
+ Manual timestep override. If None, timestep is computed automatically
589
+ based on CFL and diffusion stability conditions.
590
+
591
+ **Note:** For density fields with strong, sustained gradients (e.g.,
592
+ linear gradients), automatic timestep selection may be
593
+ insufficient and numerical instability can occur. Symptoms include
594
+ epsilon becoming larger over time, or negative.
595
+
596
+ If instability occurs, manually set a smaller timestep:
597
+ - Typical values: dt=0.001 to 0.01
598
+ - Strong gradients: dt=0.0001 to 0.001
599
+
600
+ Returns
601
+ -------
602
+ grid : VDERMGrid
603
+ The deformed grid object. Grid positions have been updated to reflect
604
+ the deformation. Original positions are preserved in grid.initial_positions.
605
+
606
+ Raises
607
+ ------
608
+ RuntimeError
609
+ If numerical instability is detected (epsilon > 1e10 or epsilon < 0)
610
+
611
+ Notes
612
+ -----
613
+ The algorithm alternates between:
614
+ 1. Diffusing the density field (heat equation)
615
+ 2. Computing velocities from density gradients
616
+ 3. Advecting grid nodes according to velocities
617
+
618
+ Convergence is measured by epsilon, the relative L2 norm of density change:
619
+ epsilon = ||ρ^(n+1) - ρ^n|| / mean(ρ^n)
620
+
621
+ Examples
622
+ --------
623
+ >>> # Basic usage with automatic timestep
624
+ >>> grid = vderm.VDERMGrid(shape=(32, 32, 32), h=0.1, min_bounds=[0, 0, 0])
625
+ >>> grid.set_density(my_density_function)
626
+ >>> deformed_grid = vderm.run_VDERM(grid, n_max=100, max_eps=0.02)
627
+ >>> print(f"Converged with epsilon={deformed_grid.epsilon:.3e}")
628
+
629
+ >>> # Manual timestep for strong gradients
630
+ >>> grid.set_density(lambda x, y, z: 1.0 + 5.0 * x) # Strong linear gradient
631
+ >>> deformed_grid = vderm.run_VDERM(grid, dt=0.001) # Smaller dt for stability
632
+
633
+ >>> # Relaxed convergence for faster results
634
+ >>> deformed_grid = vderm.run_VDERM(grid, n_max=50, max_eps=0.05)
635
+
636
+ See Also
637
+ --------
638
+ run_VDERM_with_tracking : Extended version with export capabilities
639
+ VDERMGrid.set_density : Set the density field before running
640
+ """
641
+
642
+ # Initial velocities
643
+ grid.update_velocities()
644
+
645
+ # Compute timestep if not provided
646
+ if dt is None:
647
+ dt = grid.compute_timestep()
648
+
649
+ # Warn if timestep is very small
650
+ if dt < 0.005:
651
+ print(f" ⚠ Warning: Very small timestep ({dt:.6f})")
652
+ print(f" This may indicate strong density gradients.")
653
+ print(f" Expect longer computation time.")
654
+
655
+ grid.epsilon = None
656
+
657
+ pbar = tqdm(range(n_max), desc='Deforming')
658
+
659
+ for iteration in pbar:
660
+ grid.update_density(dt)
661
+
662
+ if iteration > 0:
663
+ grid.update_velocities()
664
+
665
+ grid.update_positions(dt)
666
+
667
+ # Early instability detection (check every iteration)
668
+ if grid.epsilon is not None:
669
+ if grid.epsilon > 1e6 or grid.epsilon < -1e-6 or np.isnan(grid.epsilon):
670
+ pbar.close()
671
+ print(f"\n❌ INSTABILITY at iteration {iteration}!")
672
+ print(f" Epsilon: {grid.epsilon:.3e}")
673
+ print(f" Current dt: {dt:.6f}")
674
+ print(f" Grid spacing h: {grid.h:.6f}")
675
+ print(f"\n Solution: Manually set smaller timestep:")
676
+ print(f" vderm.run_VDERM(grid, dt={dt/10:.6f})")
677
+ raise RuntimeError("Numerical instability detected. Please manually set a smaller timestep and rerun")
678
+
679
+ if grid.epsilon is not None:
680
+ pbar.set_postfix({'ε': f'{grid.epsilon:.3e}', 'target': f'{max_eps:.3e}'})
681
+
682
+ if grid.epsilon is not None and grid.epsilon <= max_eps:
683
+ pbar.set_description('Converged')
684
+ pbar.close()
685
+ print(f'\nConverged at iteration {iteration}')
686
+ break
687
+
688
+ return grid
689
+
690
+
691
+ def run_VDERM_with_tracking(grid, surface_points,
692
+ n_max=100, max_eps=0.01, dt=None,
693
+ export_grid=False, export_grid_frequency=10,
694
+ export_surface=False, export_surface_frequency=10,
695
+ export_mesh=False, export_mesh_frequency=20,
696
+ mesh_depth=8,
697
+ mesh_format='stl',
698
+ base_folder='vderm_exports',
699
+ grid_folder='vderm_grid',
700
+ surface_folder='vderm_surface',
701
+ mesh_folder='vderm_mesh'):
702
+ """
703
+ Run VDERM deformation with optional tracking of grid, surface, and mesh states.
704
+
705
+ This function extends run_VDERM() by allowing intermediate exports of:
706
+ - Grid states (positions + densities)
707
+ - Interpolated surface point clouds (positions + normals + densities)
708
+ - Reconstructed meshes (STL or VTK format)
709
+
710
+ Each export type has independent frequency control and output folders.
711
+ All exports include complete data.
712
+
713
+ Parameters
714
+ ----------
715
+ grid : VDERMGrid
716
+ Grid object with initial density field set
717
+ surface_points : ndarray, shape (n_points, 3)
718
+ Original surface point cloud positions
719
+ n_max : int, default=100
720
+ Maximum VDERM iterations
721
+ max_eps : float, default=0.01
722
+ Convergence threshold for epsilon
723
+ dt : float, optional
724
+ timestep can be manually assigned if the auto assigned timestep is not sufficient
725
+ export_grid : bool, default=False
726
+ If True, export grid positions and densities
727
+ export_grid_frequency : int, default=10
728
+ Export grid every N iterations
729
+
730
+ export_surface : bool, default=False
731
+ If True, export interpolated surface point clouds (with normals and densities)
732
+ export_surface_frequency : int, default=10
733
+ Export surface every N iterations
734
+
735
+ export_mesh : bool, default=False
736
+ If True, export reconstructed meshes (slowest option)
737
+ export_mesh_frequency : int, default=20
738
+ Export mesh every N iterations
739
+
740
+ mesh_depth : int, default=8
741
+ Poisson reconstruction depth for mesh exports
742
+
743
+ mesh_format : str, default='stl'
744
+ Mesh export format: 'stl' or 'vtk'
745
+ - 'stl': Standard triangle mesh format (geometry only)
746
+ - 'vtk': VTK format with vertex normals and density data (for ParaView)
747
+ Both formats also save accompanying .xyz file with complete vertex data.
748
+
749
+ base_folder : str, default='vderm_exports'
750
+ Base directory for all exports
751
+ grid_folder : str, default='vderm_grid'
752
+ Subfolder name for grid exports
753
+ surface_folder : str, default='vderm_surface'
754
+ Subfolder name for surface exports
755
+ mesh_folder : str, default='vderm_mesh'
756
+ Subfolder name for mesh exports
757
+
758
+ Returns
759
+ -------
760
+ grid : VDERMGrid
761
+ Final deformed grid
762
+
763
+ Notes
764
+ -----
765
+ Export file formats:
766
+ - Grid: .xyz with "x y z rho" (4 columns)
767
+ - Surface: .xyz with "x y z n_x n_y n_z rho" (7 columns)
768
+ - Mesh: .stl or .vtk file + .xyz with "x y z n_x n_y n_z rho" (7 columns)
769
+
770
+ Performance considerations:
771
+ - Grid exports: Fast (~0.1s for 32k points)
772
+ - Surface exports: Medium (~0.5-2s depending on grid size)
773
+ - Mesh exports: Slow (~5-30s depending on complexity)
774
+
775
+ Examples
776
+ --------
777
+ >>> # Standard STL meshes
778
+ >>> final_grid, final_surface = run_VDERM_with_tracking(
779
+ ... grid, surface_pts, normals,
780
+ ... export_mesh=True,
781
+ ... mesh_format='stl'
782
+ ... )
783
+
784
+ >>> # VTK meshes for ParaView with density coloring
785
+ >>> final_grid, final_surface = run_VDERM_with_tracking(
786
+ ... grid, surface_pts, normals,
787
+ ... export_mesh=True,
788
+ ... mesh_format='vtk'
789
+ ... )
790
+ """
791
+
792
+ # Check for pymeshlab if mesh export requested
793
+ if export_mesh and not HAS_PYMESHLAB:
794
+ raise ImportError(
795
+ "Mesh export requires pymeshlab.\n"
796
+ "Install with: pip install pymeshlab\n"
797
+ "Or install with 3-D mesh support: pip install diffusion-cartogram[3D]"
798
+ )
799
+
800
+ # Validate mesh format
801
+ if mesh_format not in ['stl', 'vtk']:
802
+ raise ValueError(f"mesh_format must be 'stl' or 'vtk', got '{mesh_format}'")
803
+
804
+ # Create export directories if any exports are enabled
805
+ any_exports = export_grid or export_surface or export_mesh
806
+
807
+ if any_exports:
808
+ os.makedirs(base_folder, exist_ok=True)
809
+
810
+ if export_grid:
811
+ grid_path = os.path.join(base_folder, grid_folder)
812
+ os.makedirs(grid_path, exist_ok=True)
813
+
814
+ if export_surface:
815
+ surface_path = os.path.join(base_folder, surface_folder)
816
+ os.makedirs(surface_path, exist_ok=True)
817
+
818
+ if export_mesh:
819
+ mesh_path = os.path.join(base_folder, mesh_folder)
820
+ os.makedirs(mesh_path, exist_ok=True)
821
+
822
+ # Initial velocities and timestep
823
+ grid.update_velocities()
824
+ # Compute timestep if not provided
825
+ if dt is None:
826
+ dt = grid.compute_timestep()
827
+
828
+ # Warn if timestep is very small
829
+ if dt < 0.005:
830
+ print(f" ⚠ Warning: Very small timestep ({dt:.6f})")
831
+ print(f" This may indicate strong density gradients.")
832
+ print(f" Expect longer computation time.")
833
+ grid.epsilon = None
834
+
835
+ # Export initial states (iteration 0)
836
+ if any_exports:
837
+ if export_grid:
838
+ # Grid: positions + densities
839
+ densities = grid.rho.ravel()
840
+ velocities = grid.velocities
841
+ filepath = os.path.join(base_folder, grid_folder, 'grid_iteration_0000.xyz')
842
+ write_xyz(filepath, grid.positions, normals=velocities, densities=densities)
843
+
844
+ if export_surface:
845
+ # Surface: positions + normals + densities
846
+ initial_surface_densities = interpolate_densities(surface_points, grid)
847
+ params = {'shape': (grid.L, grid.M, grid.N),'h': grid.h,'min_bounds': grid.min_bounds}
848
+ initial_surface_velocities = interpolate_velocities(surface_points, params, grid.velocities)
849
+ filepath = os.path.join(base_folder, surface_folder, 'surface_iteration_0000.xyz')
850
+ write_xyz(filepath, surface_points,
851
+ normals=initial_surface_velocities,
852
+ densities=initial_surface_densities)
853
+
854
+ if export_mesh:
855
+ # Mesh: save in requested format + xyz
856
+ initial_mesh_densities = interpolate_densities(surface_points, grid)
857
+
858
+ mesh_filepath = os.path.join(base_folder, mesh_folder,
859
+ f'mesh_iteration_0000.{mesh_format}')
860
+
861
+ if mesh_format == 'stl':
862
+ export_mesh_file(mesh_filepath, surface_points, depth=mesh_depth)
863
+ else: # vtk
864
+ export_mesh_vtk(mesh_filepath, surface_points, initial_mesh_densities, depth=mesh_depth)
865
+
866
+ # Main iteration loop with progress bar
867
+ pbar = tqdm(range(n_max), desc='Deforming with tracking')
868
+
869
+ for iteration in pbar:
870
+ # Diffuse density
871
+ grid.update_density(dt)
872
+
873
+ # Update velocities from new density field
874
+ if iteration > 0:
875
+ grid.update_velocities()
876
+
877
+ # Move grid nodes
878
+ grid.update_positions(dt)
879
+
880
+ # Handle exports
881
+ should_export = (iteration + 1) % min(
882
+ export_grid_frequency if export_grid else float('inf'),
883
+ export_surface_frequency if export_surface else float('inf'),
884
+ export_mesh_frequency if export_mesh else float('inf')
885
+ ) == 0
886
+
887
+ if any_exports and should_export:
888
+
889
+ # Check if we need to interpolate surface
890
+ need_interpolation = (
891
+ (export_surface and (iteration + 1) % export_surface_frequency == 0) or
892
+ (export_mesh and (iteration + 1) % export_mesh_frequency == 0)
893
+ )
894
+
895
+ if need_interpolation:
896
+ # Interpolate surface positions
897
+ params = {
898
+ 'shape': (grid.L, grid.M, grid.N),
899
+ 'h': grid.h,
900
+ 'min_bounds': grid.min_bounds
901
+ }
902
+ displacement_field = grid.get_displacement_field()
903
+ current_surface = interpolate_to_surface(surface_points, params, displacement_field)
904
+
905
+ # Interpolate densities to surface points
906
+ current_surface_densities = interpolate_densities(surface_points, grid)
907
+ current_surface_velocities = interpolate_velocities(surface_points, params, grid.velocities)
908
+
909
+ # Export grid if requested
910
+ if export_grid and (iteration + 1) % export_grid_frequency == 0:
911
+ densities = grid.rho.ravel()
912
+ velocities = grid.velocities
913
+ filepath = os.path.join(base_folder, grid_folder,
914
+ f'grid_iteration_{iteration+1:04d}.xyz')
915
+ write_xyz(filepath, grid.positions, normals=velocities, densities=densities)
916
+
917
+ # Export surface if requested
918
+ if export_surface and (iteration + 1) % export_surface_frequency == 0:
919
+ filepath = os.path.join(base_folder, surface_folder,
920
+ f'surface_iteration_{iteration+1:04d}.xyz')
921
+ write_xyz(filepath, current_surface,
922
+ normals=current_surface_velocities,
923
+ densities=current_surface_densities)
924
+
925
+ # Export mesh if requested
926
+ if export_mesh and (iteration + 1) % export_mesh_frequency == 0:
927
+ mesh_filepath = os.path.join(base_folder, mesh_folder,
928
+ f'mesh_iteration_{iteration+1:04d}.{mesh_format}')
929
+
930
+ if mesh_format == 'stl':
931
+ export_mesh_file(mesh_filepath, current_surface, depth=mesh_depth)
932
+ else: # vtk
933
+ export_mesh_vtk(mesh_filepath, current_surface, current_surface_densities, depth=mesh_depth)
934
+
935
+
936
+ # Early instability detection (check every iteration)
937
+ if grid.epsilon is not None:
938
+ if grid.epsilon > 1e6 or grid.epsilon < -1e-6 or np.isnan(grid.epsilon):
939
+ pbar.close()
940
+ print(f"\n❌ INSTABILITY at iteration {iteration}!")
941
+ print(f" Epsilon: {grid.epsilon:.3e}")
942
+ print(f" Current dt: {dt:.6f}")
943
+ print(f" Grid spacing h: {grid.h:.6f}")
944
+ print(f"\n Solution: Manually set smaller timestep:")
945
+ print(f" vderm.run_VDERM(grid, dt={dt/10:.6f})")
946
+ raise RuntimeError("Numerical instability detected. Please manually set a smaller timestep and rerun")
947
+
948
+ # Update progress bar
949
+ if grid.epsilon is not None:
950
+ pbar.set_postfix({'ε': f'{grid.epsilon:.3e}', 'target': f'{max_eps:.3e}'})
951
+
952
+ # Check convergence
953
+ if grid.epsilon is not None and grid.epsilon <= max_eps:
954
+ pbar.set_description('Converged')
955
+ pbar.close()
956
+ print(f'\nConverged at iteration {iteration + 1}')
957
+
958
+ # Export final states
959
+ if any_exports:
960
+ # Interpolate final surface and densities
961
+ params = {
962
+ 'shape': (grid.L, grid.M, grid.N),
963
+ 'h': grid.h,
964
+ 'min_bounds': grid.min_bounds
965
+ }
966
+ displacement_field = grid.get_displacement_field()
967
+
968
+ if export_grid:
969
+ densities = grid.rho.ravel()
970
+ velocities = grid.velocities
971
+ filepath = os.path.join(base_folder, grid_folder,
972
+ f'grid_final_iteration_{iteration+1:04d}.xyz')
973
+ write_xyz(filepath, grid.positions, normals=velocities, densities=densities)
974
+
975
+ if export_surface or export_mesh:
976
+
977
+ final_surface = interpolate_to_surface(
978
+ surface_points, params, displacement_field
979
+ )
980
+ final_surface_densities = interpolate_densities(surface_points, grid)
981
+ final_surface_velocities = interpolate_velocities(surface_points, params, grid.velocities)
982
+
983
+ if export_surface:
984
+ filepath = os.path.join(base_folder, surface_folder,
985
+ f'surface_final_iteration_{iteration+1:04d}.xyz')
986
+ write_xyz(filepath, final_surface,
987
+ normals=final_surface_velocities,
988
+ densities=final_surface_densities)
989
+
990
+ if export_mesh:
991
+ mesh_filepath = os.path.join(base_folder, mesh_folder,
992
+ f'mesh_final_iteration_{iteration+1:04d}.{mesh_format}')
993
+
994
+ if mesh_format == 'stl':
995
+ export_mesh_file(mesh_filepath, final_surface, depth=mesh_depth)
996
+ else: # vtk
997
+ export_mesh_vtk(mesh_filepath, final_surface, final_surface_densities, depth=mesh_depth)
998
+
999
+ break
1000
+
1001
+ # Final interpolation for return value
1002
+ params = {
1003
+ 'shape': (grid.L, grid.M, grid.N),
1004
+ 'h': grid.h,
1005
+ 'min_bounds': grid.min_bounds
1006
+ }
1007
+ displacement_field = grid.get_displacement_field()
1008
+
1009
+ # Print export summary
1010
+ if any_exports:
1011
+ print(f"\nExports saved to: {base_folder}/")
1012
+ if export_grid:
1013
+ print(f" - Grid states (x y z rho): {grid_folder}/")
1014
+ if export_surface:
1015
+ print(f" - Surface point clouds (x y z n_x n_y n_z rho): {surface_folder}/")
1016
+ if export_mesh:
1017
+ print(f" - Meshes (.{mesh_format} + .xyz with x y z n_x n_y n_z rho): {mesh_folder}/")
1018
+
1019
+ return grid
1020
+
1021
+
1022
+ def interpolate_densities(surface_points, grid):
1023
+ """
1024
+ Interpolate density values from grid to surface points.
1025
+
1026
+ Parameters
1027
+ ----------
1028
+ surface_points : ndarray, shape (n_points, 3)
1029
+ Surface point positions (in initial/reference coordinates)
1030
+ grid : VDERMGrid
1031
+ VDERM grid object with density field
1032
+
1033
+ Returns
1034
+ -------
1035
+ surface_densities : ndarray, shape (n_points,)
1036
+ Interpolated density values at each surface point
1037
+
1038
+ Notes
1039
+ -----
1040
+ Surface points outside the grid bounds will have density set to 0.
1041
+ """
1042
+
1043
+ # Create coordinate arrays for each axis
1044
+ x = grid.min_bounds[0] + np.arange(grid.L) * grid.h
1045
+ y = grid.min_bounds[1] + np.arange(grid.M) * grid.h
1046
+ z = grid.min_bounds[2] + np.arange(grid.N) * grid.h
1047
+
1048
+ # Create interpolator for density field
1049
+ # density field is already in (L, M, N) shape
1050
+ interp_rho = RegularGridInterpolator(
1051
+ (x, y, z),
1052
+ grid.rho,
1053
+ bounds_error=False,
1054
+ fill_value=0 # Points outside grid get density 0
1055
+ )
1056
+
1057
+ # Interpolate densities
1058
+ surface_densities = interp_rho(surface_points)
1059
+
1060
+ return surface_densities
1061
+
1062
+ def interpolate_velocities(surface_points, grid_params, velocity_field):
1063
+ """
1064
+ Interpolate velocities from the regular grid to the surface
1065
+
1066
+ Parameters
1067
+ ----------
1068
+ surface_points : ndarray, shape (n_points, 3)
1069
+ Surface point cloud positions
1070
+ grid_params : dict
1071
+ Dictionary with 'shape', 'h', 'min_bounds'
1072
+ velocity_field : ndarray, shape (L*M*N, 3)
1073
+ Velocity vectors at each grid node
1074
+
1075
+ Returns
1076
+ -------
1077
+ interpolated_velocities : ndarray, shape (n_points, 3)
1078
+ Surface velocities
1079
+ """
1080
+ L, M, N = grid_params['shape']
1081
+ h = grid_params['h']
1082
+ min_bounds = grid_params['min_bounds']
1083
+
1084
+ # Create coordinate arrays for each axis
1085
+ x = min_bounds[0] + np.arange(L) * h
1086
+ y = min_bounds[1] + np.arange(M) * h
1087
+ z = min_bounds[2] + np.arange(N) * h
1088
+
1089
+ # Reshape velocity field to 3D grid
1090
+ velo_grid = velocity_field.reshape(L, M, N, 3)
1091
+
1092
+ # Create interpolators for each component
1093
+ interp_u = RegularGridInterpolator((x, y, z), velo_grid[:, :, :, 0],
1094
+ bounds_error=False, fill_value=0)
1095
+ interp_v = RegularGridInterpolator((x, y, z), velo_grid[:, :, :, 1],
1096
+ bounds_error=False, fill_value=0)
1097
+ interp_w = RegularGridInterpolator((x, y, z), velo_grid[:, :, :, 2],
1098
+ bounds_error=False, fill_value=0)
1099
+
1100
+ # Interpolate
1101
+ u = interp_u(surface_points)
1102
+ v = interp_v(surface_points)
1103
+ w = interp_w(surface_points)
1104
+
1105
+ interpolated_velocities = np.column_stack([u, v, w])
1106
+
1107
+ return interpolated_velocities
1108
+
1109
+ def interpolate_to_surface(surface_points, grid_params, displacement_field):
1110
+ """
1111
+ Interpolate the grid based vector field to the surface point cloud
1112
+ and return the deformed surface
1113
+
1114
+ Parameters
1115
+ ----------
1116
+ surface_points : ndarray, shape (n_points, 3)
1117
+ Surface point cloud positions
1118
+ grid_params : dict
1119
+ Dictionary with 'shape', 'h', 'min_bounds'
1120
+ displacement_field : ndarray, shape (L*M*N, 3)
1121
+ Displacement vectors at each grid node
1122
+
1123
+ Returns
1124
+ -------
1125
+ deformed_surface : ndarray, shape (n_points, 3)
1126
+ Surface points after applying interpolated displacements
1127
+
1128
+ Examples
1129
+ --------
1130
+ >>> # After running VDERM
1131
+ >>> deformed_surface = interpolate_to_surface(original_surface, grid_params, vderm_grid.get_displacement_field())
1132
+ >>> surface_densities = interpolate_densities(deformed_surface, vderm_grid)
1133
+ >>> surface_velocities = interpolate_velocities(deformed_surface, grid_params, vderm_grid.velocities)
1134
+ >>> # Save surface with densities and velocities
1135
+ >>> write_xyz('surface_with_density.xyz', surface_pts,
1136
+ ... normals=surface_velocities, densities=surface_densities)
1137
+ """
1138
+ L, M, N = grid_params['shape']
1139
+ h = grid_params['h']
1140
+ min_bounds = grid_params['min_bounds']
1141
+
1142
+ # Create coordinate arrays for each axis
1143
+ x = min_bounds[0] + np.arange(L) * h
1144
+ y = min_bounds[1] + np.arange(M) * h
1145
+ z = min_bounds[2] + np.arange(N) * h
1146
+
1147
+ # Reshape displacement field to 3D grid
1148
+ disp_grid = displacement_field.reshape(L, M, N, 3)
1149
+
1150
+ # Create interpolators for each component
1151
+ interp_u = RegularGridInterpolator((x, y, z), disp_grid[:, :, :, 0],
1152
+ bounds_error=False, fill_value=0)
1153
+ interp_v = RegularGridInterpolator((x, y, z), disp_grid[:, :, :, 1],
1154
+ bounds_error=False, fill_value=0)
1155
+ interp_w = RegularGridInterpolator((x, y, z), disp_grid[:, :, :, 2],
1156
+ bounds_error=False, fill_value=0)
1157
+
1158
+ # Interpolate
1159
+ u = interp_u(surface_points)
1160
+ v = interp_v(surface_points)
1161
+ w = interp_w(surface_points)
1162
+
1163
+ interpolated_displacement = np.column_stack([u, v, w])
1164
+
1165
+ return surface_points + interpolated_displacement
1166
+
1167
+ def export_mesh_file(filename, deformed_pcd, depth=8, fulldepth=5, scale=1.1):
1168
+ """
1169
+ Creates and exports a Poisson mesh from a deformed point cloud.
1170
+
1171
+ Normals are automatically estimated from the deformed point cloud geometry
1172
+ using local neighborhood analysis, which is more accurate for deformed surfaces
1173
+ than using original normals.
1174
+
1175
+ Parameters
1176
+ ----------
1177
+ filename : str
1178
+ Output file path (.ply, .stl, .obj, .off, or .gltf/.glb)
1179
+ deformed_pcd : ndarray, shape (n_points, 3)
1180
+ Deformed point cloud to remesh
1181
+ depth : int, default=8
1182
+ Poisson reconstruction octree depth (higher = more detail)
1183
+ fulldepth : int, default=5
1184
+ Depth below which octree will be complete
1185
+ scale : float, default=1.1
1186
+ Ratio between reconstruction cube diameter and samples' bounding cube diameter
1187
+
1188
+ Returns
1189
+ -------
1190
+ result_mesh : pymeshlab Mesh
1191
+ The reconstructed mesh object
1192
+
1193
+ Notes
1194
+ -----
1195
+ Poisson reconstruction default values from:
1196
+ https://www.cs.jhu.edu/~misha/Code/PoissonRecon/Version8.0/
1197
+
1198
+ Normal estimation uses k=20 nearest neighbors with 2 smoothing iterations.
1199
+ Adjust these in the code if needed for your specific geometry.
1200
+
1201
+ macOS + conda users may encounter an OpenMP conflict (OMP: Error #15) when
1202
+ this function is called, due to pymeshlab's bundled libomp conflicting with
1203
+ conda-forge's numpy/scipy. See the Known Issues section of the README for
1204
+ the fix.
1205
+
1206
+ Examples
1207
+ --------
1208
+ >>> # Basic usage
1209
+ >>> mesh = export_mesh_file('output.stl', deformed_points)
1210
+
1211
+ >>> # Higher quality reconstruction
1212
+ >>> mesh = export_mesh_file('output.ply', deformed_points, depth=10)
1213
+ """
1214
+ _require_pymeshlab('create_pcd')
1215
+
1216
+ # Create MeshSet and add point cloud
1217
+ ms = ml.MeshSet()
1218
+ point_cloud_mesh = ml.Mesh(vertex_matrix=deformed_pcd)
1219
+ ms.add_mesh(point_cloud_mesh)
1220
+
1221
+ # Estimate normals from local geometry of deformed point cloud
1222
+ ms.compute_normal_for_point_clouds(k=20, smoothiter=2)
1223
+
1224
+ # Perform Poisson surface reconstruction using estimated normals
1225
+ ms.generate_surface_reconstruction_screened_poisson(
1226
+ depth=depth,
1227
+ fulldepth=fulldepth,
1228
+ scale=scale
1229
+ )
1230
+
1231
+ # Compute normals for the reconstructed mesh (for smooth rendering)
1232
+ ms.compute_normal_for_point_clouds()
1233
+
1234
+ # Save the mesh to file
1235
+ ms.save_current_mesh(filename)
1236
+
1237
+ # Return the mesh object
1238
+ result_mesh = ms.current_mesh()
1239
+
1240
+ return result_mesh
1241
+
1242
+ def export_mesh_vtk(filepath, deformed_pcd, densities, depth=8):
1243
+ """
1244
+ Create and export a Poisson mesh in VTK format with density vertex attribute.
1245
+
1246
+ VTK format includes mesh geometry, vertex normals, and density scalar field.
1247
+ This is ideal for ParaView visualization with density coloring.
1248
+
1249
+ Parameters
1250
+ ----------
1251
+ filepath : str
1252
+ Output file path (should end with .vtk)
1253
+ deformed_pcd : ndarray, shape (n_points, 3)
1254
+ Deformed point cloud to remesh
1255
+ densities : ndarray, shape (n_points,)
1256
+ Density values at each point
1257
+ depth : int, default=8
1258
+ Poisson reconstruction depth
1259
+
1260
+ Returns
1261
+ -------
1262
+ mesh : pymeshlab Mesh object
1263
+ The reconstructed mesh
1264
+
1265
+ Note:
1266
+ macOS + conda users may encounter an OpenMP conflict (OMP: Error #15) when
1267
+ this function is called, due to pymeshlab's bundled libomp conflicting with
1268
+ conda-forge's numpy/scipy. See the Known Issues section of the README for
1269
+ the fix.
1270
+ """
1271
+ _require_pymeshlab('create_pcd')
1272
+
1273
+ # Reconstruct mesh using Poisson
1274
+ ms = ml.MeshSet()
1275
+ point_cloud_mesh = ml.Mesh(vertex_matrix=deformed_pcd)
1276
+ ms.add_mesh(point_cloud_mesh)
1277
+ ms.compute_normal_for_point_clouds(k=20, smoothiter=2)
1278
+
1279
+ ms.generate_surface_reconstruction_screened_poisson(
1280
+ depth=depth,
1281
+ fulldepth=5,
1282
+ scale=1.1
1283
+ )
1284
+
1285
+ ms.compute_normal_for_point_clouds()
1286
+
1287
+ # Get mesh data
1288
+ mesh = ms.current_mesh()
1289
+ vertices = mesh.vertex_matrix()
1290
+ faces = mesh.face_matrix()
1291
+ vertex_normals = mesh.vertex_normal_matrix()
1292
+
1293
+ # Interpolate densities from point cloud to mesh vertices
1294
+ # (Poisson reconstruction creates new vertices, so we need to interpolate)
1295
+ interp = NearestNDInterpolator(deformed_pcd, densities)
1296
+ mesh_densities = interp(vertices)
1297
+
1298
+ # Write VTK POLYDATA file with density attribute
1299
+ with open(filepath, 'w') as vtk:
1300
+ vtk.write('# vtk DataFile Version 3.0\n')
1301
+ vtk.write('VDERM Mesh with Density\n')
1302
+ vtk.write('ASCII\n')
1303
+ vtk.write('DATASET POLYDATA\n')
1304
+
1305
+ # Write vertices
1306
+ vtk.write(f'POINTS {len(vertices)} float\n')
1307
+ for v in vertices:
1308
+ vtk.write(f'{v[0]:.6e} {v[1]:.6e} {v[2]:.6e}\n')
1309
+
1310
+ # Write triangular faces
1311
+ vtk.write(f'\nPOLYGONS {len(faces)} {len(faces) * 4}\n')
1312
+ for face in faces:
1313
+ vtk.write(f'3 {face[0]} {face[1]} {face[2]}\n')
1314
+
1315
+ # Write vertex data
1316
+ vtk.write(f'\nPOINT_DATA {len(vertices)}\n')
1317
+
1318
+ # Normals as vector field
1319
+ vtk.write('NORMALS normals float\n')
1320
+ for n in vertex_normals:
1321
+ vtk.write(f'{n[0]:.6e} {n[1]:.6e} {n[2]:.6e}\n')
1322
+
1323
+ # Density as scalar field
1324
+ vtk.write('\nSCALARS density float 1\n')
1325
+ vtk.write('LOOKUP_TABLE default\n')
1326
+ for d in mesh_densities:
1327
+ vtk.write(f'{d:.6e}\n')
1328
+
1329
+ return mesh