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.
- gri_plot/__init__.py +94 -0
- gri_plot/figure3d.py +476 -0
- gri_plot/frames.py +181 -0
- gri_plot/observables/__init__.py +21 -0
- gri_plot/observables/aoa.py +123 -0
- gri_plot/observables/fdoa.py +254 -0
- gri_plot/observables/los.py +154 -0
- gri_plot/observables/range_sphere.py +74 -0
- gri_plot/observables/tdoa.py +201 -0
- gri_plot/observables/terrain.py +293 -0
- gri_plot/plot_ellipse.py +183 -0
- gri_plot/py.typed +0 -0
- gri_plot/scatter.py +76 -0
- gri_plot/scatter_map.py +93 -0
- gri_plot/shapes/__init__.py +19 -0
- gri_plot/shapes/cone.py +227 -0
- gri_plot/shapes/cylinder.py +219 -0
- gri_plot/shapes/ellipse.py +117 -0
- gri_plot/shapes/ellipsoid.py +227 -0
- gri_plot/shapes/meshgen.py +245 -0
- gri_plot/shapes/sphere.py +152 -0
- gri_plot/surfaces/__init__.py +171 -0
- gri_plot/surfaces/gradients/__init__.py +39 -0
- gri_plot/surfaces/gradients/axis.py +74 -0
- gri_plot/surfaces/gradients/line.py +86 -0
- gri_plot/surfaces/gradients/plane.py +53 -0
- gri_plot/surfaces/gradients/point.py +40 -0
- gri_plot/surfaces/intersection.py +217 -0
- gri_plot/surfaces/mesh.py +603 -0
- gri_plot-0.2.0.post1.dist-info/METADATA +149 -0
- gri_plot-0.2.0.post1.dist-info/RECORD +33 -0
- gri_plot-0.2.0.post1.dist-info/WHEEL +4 -0
- gri_plot-0.2.0.post1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|