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.
- diffusion_cartogram/__init__.py +202 -0
- diffusion_cartogram/core.py +1329 -0
- diffusion_cartogram/core_2d.py +831 -0
- diffusion_cartogram/visualization.py +1490 -0
- diffusion_cartogram/visualization_2d.py +534 -0
- diffusion_cartogram-0.2.0.dist-info/METADATA +287 -0
- diffusion_cartogram-0.2.0.dist-info/RECORD +10 -0
- diffusion_cartogram-0.2.0.dist-info/WHEEL +5 -0
- diffusion_cartogram-0.2.0.dist-info/licenses/LICENSE +21 -0
- diffusion_cartogram-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|