gri-plot 0.2.0.post1__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,603 @@
1
+ """Core mesh utilities for 3D surface visualization.
2
+
3
+ This module provides the fundamental functions for creating Plotly Mesh3d traces
4
+ from vertices and faces, and for extracting mesh geometry from scalar fields.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ import numpy as np
10
+ import plotly.graph_objects as go
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+ from numpy.typing import NDArray
16
+
17
+ # Default mesh resolution for field_to_mesh and related functions
18
+ DEFAULT_MESH_RESOLUTION = 100
19
+
20
+
21
+ def vertices_to_mesh3d( # noqa: PLR0913 - mesh rendering needs many options
22
+ vertices: NDArray[np.floating],
23
+ faces: NDArray[np.integer],
24
+ *,
25
+ intensity_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]] | None = None,
26
+ color: str | None = None,
27
+ opacity: float = 0.7,
28
+ name: str | None = None,
29
+ colorscale: str | list = "Viridis",
30
+ colorbar_title: str | None = None,
31
+ showscale: bool = False,
32
+ **kwargs,
33
+ ) -> go.Mesh3d:
34
+ """Create a Plotly Mesh3d trace from vertices and faces.
35
+
36
+ This is the core rendering function. All shape and surface classes should
37
+ use this to generate their Plotly traces.
38
+
39
+ Args:
40
+ vertices: Vertex positions, shape (N, 3).
41
+ faces: Triangle indices, shape (M, 3).
42
+ intensity_fn: Optional function to compute vertex intensities for coloring.
43
+ Takes vertices (N, 3) and returns intensities (N,). If None and color
44
+ is None, defaults to distance from mesh centroid (works well in any
45
+ coordinate frame).
46
+ color: Uniform surface color. Mutually exclusive with intensity_fn.
47
+ opacity: Surface opacity (0-1).
48
+ name: Trace name for legend.
49
+ colorscale: Plotly colorscale name or custom colorscale list
50
+ (used with intensity_fn).
51
+ colorbar_title: Title for colorbar (used with intensity_fn).
52
+ showscale: Whether to show the colorbar.
53
+ **kwargs: Additional arguments for go.Mesh3d.
54
+
55
+ Returns:
56
+ Plotly Mesh3d trace.
57
+ """
58
+ vertices = np.asarray(vertices)
59
+ faces = np.asarray(faces)
60
+
61
+ trace_kwargs: dict = {
62
+ "x": vertices[:, 0],
63
+ "y": vertices[:, 1],
64
+ "z": vertices[:, 2],
65
+ "i": faces[:, 0],
66
+ "j": faces[:, 1],
67
+ "k": faces[:, 2],
68
+ "opacity": opacity,
69
+ }
70
+
71
+ if color is not None:
72
+ # Uniform color mode
73
+ trace_kwargs["color"] = color
74
+ else:
75
+ # Intensity-based coloring
76
+ if intensity_fn is not None:
77
+ intensity = intensity_fn(vertices)
78
+ else:
79
+ # Default to distance from mesh centroid - works in any coordinate frame
80
+ centroid = vertices.mean(axis=0)
81
+ intensity = np.linalg.norm(vertices - centroid, axis=-1)
82
+
83
+ trace_kwargs["intensity"] = intensity
84
+ trace_kwargs["colorscale"] = colorscale
85
+ trace_kwargs["showscale"] = showscale
86
+ if colorbar_title is not None:
87
+ trace_kwargs["colorbar"] = {"title": colorbar_title}
88
+
89
+ if name is not None:
90
+ trace_kwargs["name"] = name
91
+ trace_kwargs["showlegend"] = True
92
+
93
+ trace_kwargs.update(kwargs)
94
+ return go.Mesh3d(**trace_kwargs)
95
+
96
+
97
+ def _remove_degenerate_faces(
98
+ vertices: NDArray[np.floating],
99
+ faces: NDArray[np.integer],
100
+ min_area_ratio: float = 0.001,
101
+ max_dihedral_angle: float = 1.0, # ~57 degrees
102
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
103
+ """Remove degenerate triangles from mesh.
104
+
105
+ Degenerate faces can appear after projecting vertices onto an isosurface,
106
+ especially for thin tube-like surfaces where opposite sides can fold
107
+ onto each other.
108
+
109
+ Args:
110
+ vertices: Vertex positions, shape (N, 3).
111
+ faces: Triangle indices, shape (M, 3).
112
+ min_area_ratio: Remove faces with area less than this fraction of
113
+ median face area.
114
+ max_dihedral_angle: Remove faces that have an edge with dihedral
115
+ angle exceeding this value (radians). Default ~57 degrees.
116
+
117
+ Returns:
118
+ Tuple of (vertices, filtered_faces). Vertices are unchanged.
119
+ """
120
+ if len(faces) == 0:
121
+ return vertices, faces
122
+
123
+ # Compute face areas
124
+ v0 = vertices[faces[:, 0]]
125
+ v1 = vertices[faces[:, 1]]
126
+ v2 = vertices[faces[:, 2]]
127
+ edge1 = v1 - v0
128
+ edge2 = v2 - v0
129
+ areas = 0.5 * np.linalg.norm(np.cross(edge1, edge2), axis=1)
130
+
131
+ # Find faces to keep based on area
132
+ median_area = np.median(areas)
133
+ min_area = median_area * min_area_ratio
134
+ area_ok = areas >= min_area
135
+
136
+ # Find edges with extreme dihedral angles
137
+ dihedral_angles = compute_edge_dihedral_angles(vertices, faces)
138
+ bad_edges = {
139
+ edge for edge, angle in dihedral_angles.items() if angle > max_dihedral_angle
140
+ }
141
+
142
+ # Mark faces that contain bad edges
143
+ angle_ok = np.ones(len(faces), dtype=bool)
144
+ if bad_edges:
145
+ for face_idx, face in enumerate(faces):
146
+ for i in range(3):
147
+ v_a, v_b = int(face[i]), int(face[(i + 1) % 3])
148
+ edge = (min(v_a, v_b), max(v_a, v_b))
149
+ if edge in bad_edges:
150
+ angle_ok[face_idx] = False
151
+ break
152
+
153
+ # Keep faces that pass both criteria
154
+ keep_mask = area_ok & angle_ok
155
+ filtered_faces = faces[keep_mask]
156
+
157
+ return vertices, filtered_faces
158
+
159
+
160
+ def field_to_mesh( # noqa: PLR0913 - marching cubes has many parameters
161
+ residual_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]],
162
+ bounds: tuple[NDArray[np.floating], NDArray[np.floating]],
163
+ resolution: int = DEFAULT_MESH_RESOLUTION,
164
+ isovalue: float = 0.0,
165
+ *,
166
+ refine: bool = True,
167
+ refine_angle_threshold: float = 0.15,
168
+ refine_iterations: int = 5,
169
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
170
+ """Extract mesh geometry from a scalar field using marching cubes.
171
+
172
+ The surface is extracted where residual_fn(xyz) = isovalue.
173
+
174
+ Args:
175
+ residual_fn: Function (xyz) -> scalar where xyz has shape (..., 3)
176
+ and output has shape (...).
177
+ bounds: Tuple of (min_corner, max_corner), each shape (3,).
178
+ resolution: Number of samples per dimension. Default 100.
179
+ isovalue: Value at which to extract the surface.
180
+ refine: If True (default), apply adaptive mesh refinement to subdivide
181
+ high-curvature regions after marching cubes.
182
+ refine_angle_threshold: Dihedral angle threshold in radians for
183
+ refinement. Edges exceeding this angle are subdivided.
184
+ Default 0.15 (~8.5 degrees).
185
+ refine_iterations: Number of refinement passes. Default 5.
186
+
187
+ Returns:
188
+ Tuple of (vertices, faces) where:
189
+ - vertices: shape (N, 3) in world coordinates
190
+ - faces: shape (M, 3) triangle indices
191
+
192
+ Raises:
193
+ ImportError: If scikit-image is not available.
194
+ RuntimeError: If marching cubes fails or finds no surface.
195
+ """
196
+ try:
197
+ from skimage.measure import marching_cubes # noqa: PLC0415
198
+ except ImportError as e:
199
+ raise ImportError(
200
+ "scikit-image is required for field_to_mesh. "
201
+ "Install with: pip install scikit-image",
202
+ ) from e
203
+
204
+ min_corner = np.asarray(bounds[0], dtype=np.float64)
205
+ max_corner = np.asarray(bounds[1], dtype=np.float64)
206
+
207
+ # Create 3D grid
208
+ x = np.linspace(min_corner[0], max_corner[0], resolution)
209
+ y = np.linspace(min_corner[1], max_corner[1], resolution)
210
+ z = np.linspace(min_corner[2], max_corner[2], resolution)
211
+
212
+ spacing = (
213
+ (max_corner[0] - min_corner[0]) / (resolution - 1),
214
+ (max_corner[1] - min_corner[1]) / (resolution - 1),
215
+ (max_corner[2] - min_corner[2]) / (resolution - 1),
216
+ )
217
+
218
+ # Create meshgrid and evaluate field
219
+ x_grid, y_grid, z_grid = np.meshgrid(x, y, z, indexing="ij")
220
+ xyz = np.stack([x_grid, y_grid, z_grid], axis=-1)
221
+ values = residual_fn(xyz)
222
+
223
+ # Extract surface
224
+ try:
225
+ verts, faces, _, _ = marching_cubes(values, level=isovalue, spacing=spacing)
226
+ except Exception as e:
227
+ raise RuntimeError(f"Marching cubes failed: {e}") from e
228
+
229
+ if len(verts) == 0:
230
+ raise RuntimeError("No surface found at specified isovalue")
231
+
232
+ # Convert to world coordinates
233
+ verts_world = verts + min_corner
234
+
235
+ # Project marching cubes vertices onto the true isosurface
236
+ # Marching cubes uses linear interpolation which leaves vertices off-surface
237
+ verts_world = _project_to_surface(verts_world, residual_fn)
238
+
239
+ # Apply adaptive refinement if requested
240
+ if refine:
241
+ verts_world, faces = refine_mesh(
242
+ verts_world,
243
+ faces,
244
+ residual_fn=residual_fn,
245
+ angle_threshold=refine_angle_threshold,
246
+ max_iterations=refine_iterations,
247
+ )
248
+
249
+ return verts_world, faces
250
+
251
+
252
+ def grid_to_mesh(
253
+ x: NDArray[np.floating],
254
+ y: NDArray[np.floating],
255
+ z: NDArray[np.floating],
256
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
257
+ """Convert a 2D grid of 3D points to vertices and triangular faces.
258
+
259
+ Used for parametric surfaces where x, y, z are 2D arrays defining
260
+ a surface patch.
261
+
262
+ Args:
263
+ x: X coordinates, 2D array of shape (M, N).
264
+ y: Y coordinates, 2D array of shape (M, N).
265
+ z: Z coordinates, 2D array of shape (M, N).
266
+
267
+ Returns:
268
+ Tuple of (vertices, faces) where:
269
+ - vertices: shape (M*N, 3)
270
+ - faces: shape ((M-1)*(N-1)*2, 3)
271
+ """
272
+ m, n = x.shape
273
+ vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
274
+
275
+ # Create triangular faces from the grid
276
+ faces = []
277
+ for i in range(m - 1):
278
+ for j in range(n - 1):
279
+ # Indices of the four corners of this grid cell
280
+ idx00 = i * n + j
281
+ idx01 = i * n + (j + 1)
282
+ idx10 = (i + 1) * n + j
283
+ idx11 = (i + 1) * n + (j + 1)
284
+
285
+ # Two triangles per cell
286
+ faces.append([idx00, idx10, idx11])
287
+ faces.append([idx00, idx11, idx01])
288
+
289
+ return vertices, np.array(faces, dtype=np.int64)
290
+
291
+
292
+ def compute_face_normals(
293
+ vertices: NDArray[np.floating],
294
+ faces: NDArray[np.integer],
295
+ ) -> NDArray[np.floating]:
296
+ """Compute unit normal vectors for each face.
297
+
298
+ Args:
299
+ vertices: Vertex positions, shape (N, 3).
300
+ faces: Triangle indices, shape (M, 3).
301
+
302
+ Returns:
303
+ Unit normals, shape (M, 3).
304
+ """
305
+ v0 = vertices[faces[:, 0]]
306
+ v1 = vertices[faces[:, 1]]
307
+ v2 = vertices[faces[:, 2]]
308
+
309
+ # Cross product of two edges
310
+ edge1 = v1 - v0
311
+ edge2 = v2 - v0
312
+ normals = np.cross(edge1, edge2)
313
+
314
+ # Normalize
315
+ lengths = np.linalg.norm(normals, axis=1, keepdims=True)
316
+ lengths = np.maximum(lengths, 1e-12) # Avoid division by zero
317
+ return normals / lengths
318
+
319
+
320
+ def compute_edge_dihedral_angles(
321
+ vertices: NDArray[np.floating],
322
+ faces: NDArray[np.integer],
323
+ ) -> dict[tuple[int, int], float]:
324
+ """Compute dihedral angles at each edge.
325
+
326
+ The dihedral angle is the angle between normals of adjacent faces.
327
+ Edges with high dihedral angles indicate high curvature.
328
+
329
+ Args:
330
+ vertices: Vertex positions, shape (N, 3).
331
+ faces: Triangle indices, shape (M, 3).
332
+
333
+ Returns:
334
+ Dictionary mapping (min_idx, max_idx) edge tuples to dihedral angles
335
+ in radians. Boundary edges (single adjacent face) are not included.
336
+ """
337
+ normals = compute_face_normals(vertices, faces)
338
+
339
+ # Build edge to face adjacency
340
+ edge_to_faces: dict[tuple[int, int], list[int]] = {}
341
+ for face_idx, face in enumerate(faces):
342
+ for i in range(3):
343
+ v0, v1 = int(face[i]), int(face[(i + 1) % 3])
344
+ edge = (min(v0, v1), max(v0, v1))
345
+ if edge not in edge_to_faces:
346
+ edge_to_faces[edge] = []
347
+ edge_to_faces[edge].append(face_idx)
348
+
349
+ # Compute dihedral angles for interior edges
350
+ dihedral_angles: dict[tuple[int, int], float] = {}
351
+ for edge, face_indices in edge_to_faces.items():
352
+ if len(face_indices) == 2: # noqa: PLR2004 - interior edge has exactly 2 faces
353
+ n1 = normals[face_indices[0]]
354
+ n2 = normals[face_indices[1]]
355
+ # Angle between normals
356
+ dot = np.clip(np.dot(n1, n2), -1.0, 1.0)
357
+ angle = np.arccos(dot)
358
+ dihedral_angles[edge] = float(angle)
359
+
360
+ return dihedral_angles
361
+
362
+
363
+ def _project_to_surface(
364
+ points: NDArray[np.floating],
365
+ residual_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]],
366
+ max_iterations: int = 20,
367
+ tolerance: float = 1e-8,
368
+ ) -> NDArray[np.floating]:
369
+ """Project points onto the isosurface using Newton-Raphson along gradient.
370
+
371
+ Uses a scale-invariant approach: step = residual / |grad|^2 * grad,
372
+ which gives correct step sizes regardless of residual normalization.
373
+
374
+ Args:
375
+ points: Points to project, shape (N, 3).
376
+ residual_fn: Implicit surface function.
377
+ max_iterations: Maximum Newton iterations.
378
+ tolerance: Convergence tolerance for residual.
379
+
380
+ Returns:
381
+ Projected points, shape (N, 3).
382
+ """
383
+ points = np.asarray(points, dtype=np.float64).copy()
384
+ h = 1e-6 # Finite difference step
385
+
386
+ for _ in range(max_iterations):
387
+ residuals = residual_fn(points)
388
+
389
+ # Check convergence
390
+ if np.all(np.abs(residuals) < tolerance):
391
+ break
392
+
393
+ # Compute gradient via finite differences
394
+ grad = np.zeros_like(points)
395
+ for dim in range(3):
396
+ points_plus = points.copy()
397
+ points_plus[:, dim] += h
398
+ grad[:, dim] = (residual_fn(points_plus) - residuals) / h
399
+
400
+ # Newton-Raphson step: move by residual / |grad|^2 * grad
401
+ # This is scale-invariant: if residual is normalized by k, grad is also
402
+ # scaled by 1/k, so residual / |grad|^2 has units of length
403
+ grad_sq = np.sum(grad * grad, axis=1, keepdims=True)
404
+ grad_sq = np.maximum(grad_sq, 1e-20)
405
+
406
+ # Step along gradient direction by residual / |grad|^2
407
+ points -= (residuals[:, np.newaxis] / grad_sq) * grad
408
+
409
+ return points
410
+
411
+
412
+ def _subdivide_face(
413
+ face: NDArray[np.integer],
414
+ edge_to_new_vertex: dict[tuple[int, int], int],
415
+ ) -> list[list[int]]:
416
+ """Subdivide a single face based on available edge midpoints.
417
+
418
+ Args:
419
+ face: Triangle vertex indices, shape (3,).
420
+ edge_to_new_vertex: Mapping from edge tuples to midpoint vertex indices.
421
+
422
+ Returns:
423
+ List of new face vertex index lists.
424
+ """
425
+ v0, v1, v2 = int(face[0]), int(face[1]), int(face[2])
426
+ edges = [
427
+ (min(v0, v1), max(v0, v1)),
428
+ (min(v1, v2), max(v1, v2)),
429
+ (min(v2, v0), max(v2, v0)),
430
+ ]
431
+ midpoints = [edge_to_new_vertex.get(e) for e in edges]
432
+ split_count = sum(m is not None for m in midpoints)
433
+
434
+ if split_count == 3: # noqa: PLR2004 - all 3 edges of triangle split
435
+ # All three edges split: 1-to-4 subdivision
436
+ m01, m12, m20 = midpoints[0], midpoints[1], midpoints[2]
437
+ if m01 is None or m12 is None or m20 is None:
438
+ return [[v0, v1, v2]] # Should not happen when split_count == 3
439
+ return [[v0, m01, m20], [v1, m12, m01], [v2, m20, m12], [m01, m12, m20]]
440
+
441
+ if split_count == 2: # noqa: PLR2004 - 2 of 3 edges split
442
+ return _subdivide_face_two_edges(v0, v1, v2, midpoints)
443
+
444
+ if split_count == 1:
445
+ return _subdivide_face_one_edge(v0, v1, v2, midpoints)
446
+
447
+ return [[v0, v1, v2]]
448
+
449
+
450
+ def _subdivide_face_two_edges(
451
+ v0: int,
452
+ v1: int,
453
+ v2: int,
454
+ midpoints: list[int | None],
455
+ ) -> list[list[int]]:
456
+ """Handle 1-to-3 subdivision when two edges are split."""
457
+ if midpoints[0] is not None and midpoints[1] is not None:
458
+ m01, m12 = midpoints[0], midpoints[1]
459
+ return [[v0, m01, v2], [m01, v1, m12], [m01, m12, v2]]
460
+ if midpoints[1] is not None and midpoints[2] is not None:
461
+ m12, m20 = midpoints[1], midpoints[2]
462
+ return [[v0, v1, m20], [v1, m12, m20], [m12, v2, m20]]
463
+ # midpoints[0] and midpoints[2] must be set
464
+ m01, m20 = midpoints[0], midpoints[2]
465
+ if m01 is None or m20 is None:
466
+ return [[v0, v1, v2]] # Should not happen
467
+ return [[v0, m01, m20], [m01, v1, v2], [m01, v2, m20]]
468
+
469
+
470
+ def _subdivide_face_one_edge(
471
+ v0: int,
472
+ v1: int,
473
+ v2: int,
474
+ midpoints: list[int | None],
475
+ ) -> list[list[int]]:
476
+ """Handle 1-to-2 subdivision when one edge is split."""
477
+ if midpoints[0] is not None:
478
+ m01 = midpoints[0]
479
+ return [[v0, m01, v2], [m01, v1, v2]]
480
+ if midpoints[1] is not None:
481
+ m12 = midpoints[1]
482
+ return [[v0, v1, m12], [v0, m12, v2]]
483
+ m20 = midpoints[2]
484
+ if m20 is None:
485
+ return [[v0, v1, v2]] # Should not happen
486
+ return [[v0, v1, m20], [m20, v1, v2]]
487
+
488
+
489
+ def _find_faces_to_split(
490
+ faces: NDArray[np.integer],
491
+ edges_to_split: set[tuple[int, int]],
492
+ ) -> set[int]:
493
+ """Find face indices that have at least one edge marked for splitting."""
494
+ faces_to_split = set()
495
+ for face_idx, face in enumerate(faces):
496
+ for i in range(3):
497
+ v0, v1 = int(face[i]), int(face[(i + 1) % 3])
498
+ edge = (min(v0, v1), max(v0, v1))
499
+ if edge in edges_to_split:
500
+ faces_to_split.add(face_idx)
501
+ break
502
+ return faces_to_split
503
+
504
+
505
+ def _create_midpoint_vertices(
506
+ vertices: NDArray[np.floating],
507
+ edges_to_split: set[tuple[int, int]],
508
+ ) -> tuple[NDArray[np.floating], dict[tuple[int, int], int]]:
509
+ """Create new vertices at edge midpoints.
510
+
511
+ Returns:
512
+ Tuple of (new_vertices_array, edge_to_new_vertex_mapping).
513
+ """
514
+ edge_to_new_vertex: dict[tuple[int, int], int] = {}
515
+ new_vertices_list = [vertices]
516
+ next_vertex_idx = len(vertices)
517
+
518
+ for edge in edges_to_split:
519
+ midpoint = (vertices[edge[0]] + vertices[edge[1]]) / 2
520
+ edge_to_new_vertex[edge] = next_vertex_idx
521
+ new_vertices_list.append(midpoint.reshape(1, 3))
522
+ next_vertex_idx += 1
523
+
524
+ return np.vstack(new_vertices_list), edge_to_new_vertex
525
+
526
+
527
+ def refine_mesh(
528
+ vertices: NDArray[np.floating],
529
+ faces: NDArray[np.integer],
530
+ residual_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]] | None = None,
531
+ angle_threshold: float = 0.3,
532
+ max_iterations: int = 3,
533
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
534
+ """Refine mesh by subdividing high-curvature triangles.
535
+
536
+ Triangles with edges that have high dihedral angles (indicating high
537
+ curvature) are subdivided using a 1-to-4 split. New vertices are placed
538
+ at edge midpoints and optionally projected onto the implicit surface.
539
+
540
+ Args:
541
+ vertices: Vertex positions, shape (N, 3).
542
+ faces: Triangle indices, shape (M, 3).
543
+ residual_fn: Optional implicit surface function for projecting new
544
+ vertices onto the surface. If None, new vertices stay at midpoints.
545
+ angle_threshold: Dihedral angle threshold in radians. Edges with
546
+ angles above this are marked for subdivision. Default 0.3 (~17 deg).
547
+ max_iterations: Number of refinement passes. Each pass subdivides
548
+ triangles that exceed the threshold.
549
+
550
+ Returns:
551
+ Tuple of (refined_vertices, refined_faces).
552
+ """
553
+ vertices = np.asarray(vertices, dtype=np.float64)
554
+ faces = np.asarray(faces, dtype=np.int64)
555
+
556
+ for _ in range(max_iterations):
557
+ # Compute dihedral angles and find edges to subdivide
558
+ dihedral_angles = compute_edge_dihedral_angles(vertices, faces)
559
+ edges_to_split = {
560
+ edge for edge, angle in dihedral_angles.items() if angle > angle_threshold
561
+ }
562
+
563
+ if not edges_to_split:
564
+ break
565
+
566
+ faces_to_split = _find_faces_to_split(faces, edges_to_split)
567
+ if not faces_to_split:
568
+ break
569
+
570
+ # Create midpoint vertices
571
+ vertices, edge_to_new_vertex = _create_midpoint_vertices(
572
+ vertices,
573
+ edges_to_split,
574
+ )
575
+
576
+ # Project new vertices onto surface if residual_fn provided
577
+ if residual_fn is not None and edge_to_new_vertex:
578
+ new_vertex_indices = list(edge_to_new_vertex.values())
579
+ projected = _project_to_surface(vertices[new_vertex_indices], residual_fn)
580
+ vertices[new_vertex_indices] = projected
581
+
582
+ # Build new face list
583
+ new_faces = []
584
+ for face_idx, face in enumerate(faces):
585
+ if face_idx in faces_to_split:
586
+ new_faces.extend(_subdivide_face(face, edge_to_new_vertex))
587
+ else:
588
+ new_faces.append(face.tolist())
589
+
590
+ faces = np.array(new_faces, dtype=np.int64)
591
+
592
+ return vertices, faces
593
+
594
+
595
+ __all__ = [
596
+ "DEFAULT_MESH_RESOLUTION",
597
+ "compute_edge_dihedral_angles",
598
+ "compute_face_normals",
599
+ "field_to_mesh",
600
+ "grid_to_mesh",
601
+ "refine_mesh",
602
+ "vertices_to_mesh3d",
603
+ ]