Skip to content

Commit 862e0c2

Browse files
feat(tidy3d): FXC-3693-triangle-mesh-support-for-adjoint
1 parent e73fbb4 commit 862e0c2

File tree

5 files changed

+553
-1
lines changed

5 files changed

+553
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- Added configurable local simulation result caching with checksum validation, eviction limits, and per-call overrides across `web.run`, `web.load`, and job workflows.
3030
- Added `DirectivityMonitorSpec` for automated creation and configuration of directivity radiation monitors in `TerminalComponentModeler`.
3131
- Added multimode support to `WavePort` in the smatrix plugin, allowing multiple modes to be analyzed per port.
32+
- Added support of `TriangleMesh` for autograd.
3233

3334
### Breaking Changes
3435
- Edge singularity correction at PEC and lossy metal edges defaults to `True`.

docs/api/geometry.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,5 +295,6 @@ Use the ``from_stl()`` class method to import from an external STL file, or ``fr
295295
+ `Importing STL files <../notebooks/STLImport.html>`_
296296
+ `Defining complex geometries using trimesh <../notebooks/CreatingGeometryUsingTrimesh.html>`_
297297

298-
~~~~
298+
Shape gradients for ``TriangleMesh`` geometries are supported through the autograd workflow. When a mesh participates in an adjoint optimization, boundary sensitivities are evaluated on the triangle faces using the same permittivity-based formulation available to polygonal slabs. The cost of the surface integral scales with the number of mesh faces; very fine meshes may require additional sampling to converge gradients, so consider simplifying or coarsening meshes when possible, or adjusting the autograd configuration to trade off accuracy and runtime.
299299

300+
~~~~

tests/test_components/autograd/test_autograd.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,31 @@ def make_structures(params: anp.ndarray) -> dict[str, td.Structure]:
527527
)
528528
cylinder = td.Structure(geometry=cylinder_geo, medium=polyslab.medium)
529529

530+
# triangle mesh geometry with param-dependent medium response
531+
base_vertices = np.array(
532+
[
533+
(0.0, 0.0, 0.0),
534+
(0.6, 0.0, 0.0),
535+
(0.0, 0.6, 0.0),
536+
(0.0, 0.0, 0.6),
537+
],
538+
)
539+
faces = np.array(
540+
[
541+
(0, 2, 1),
542+
(0, 1, 3),
543+
(0, 3, 2),
544+
(1, 2, 3),
545+
],
546+
dtype=int,
547+
)
548+
triangle_mesh_geo = td.TriangleMesh.from_vertices_faces(base_vertices, faces)
549+
mesh_eps = 1.8 + 0.2 * anp.abs(vector @ params)
550+
triangle_mesh = td.Structure(
551+
geometry=triangle_mesh_geo,
552+
medium=td.Medium(permittivity=mesh_eps),
553+
)
554+
530555
return {
531556
"medium": medium,
532557
"center_list": center_list,
@@ -541,6 +566,7 @@ def make_structures(params: anp.ndarray) -> dict[str, td.Structure]:
541566
"pole_res": pole_res,
542567
"custom_pole_res": custom_pole_res,
543568
"cylinder": cylinder,
569+
"triangle_mesh": triangle_mesh,
544570
}
545571

546572

@@ -638,6 +664,7 @@ def plot_sim(sim: td.Simulation, plot_eps: bool = True) -> None:
638664
"pole_res",
639665
"custom_pole_res",
640666
"cylinder",
667+
"triangle_mesh",
641668
)
642669
monitor_keys_ = ("mode", "diff", "field_vol", "field_point")
643670

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""Tests for TriangleMesh autograd derivatives."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import numpy.testing as npt
7+
import pytest
8+
9+
import tidy3d as td
10+
11+
VERTICES_TETRA = np.array(
12+
[
13+
(0.0, 0.0, 0.0),
14+
(1.0, 0.0, 0.0),
15+
(0.0, 1.0, 0.0),
16+
(0.0, 0.0, 1.0),
17+
],
18+
dtype=float,
19+
)
20+
21+
FACES_TETRA = np.array(
22+
[
23+
(0, 2, 1),
24+
(0, 1, 3),
25+
(0, 3, 2),
26+
(1, 2, 3),
27+
]
28+
)
29+
30+
VERTICES_OCTA = np.array(
31+
[
32+
(1.0, 0.0, 0.0),
33+
(-1.0, 0.0, 0.0),
34+
(0.0, 1.0, 0.0),
35+
(0.0, -1.0, 0.0),
36+
(0.0, 0.0, 1.0),
37+
(0.0, 0.0, -1.0),
38+
],
39+
dtype=float,
40+
)
41+
42+
FACES_OCTA = np.array(
43+
[
44+
(4, 0, 2),
45+
(4, 2, 1),
46+
(4, 1, 3),
47+
(4, 3, 0),
48+
(5, 2, 0),
49+
(5, 1, 2),
50+
(5, 3, 1),
51+
(5, 0, 3),
52+
],
53+
dtype=int,
54+
)
55+
56+
VERTICES_NON_WATERTIGHT = np.array(
57+
[
58+
(0.0, 0.0, 0.0),
59+
(1.0, 0.0, 0.0),
60+
(0.0, 1.0, 0.0),
61+
(0.0, 0.0, 1.0),
62+
],
63+
dtype=float,
64+
)
65+
66+
FACES_NON_WATERTIGHT = np.array(
67+
[
68+
(0, 1, 2),
69+
(0, 2, 3),
70+
],
71+
dtype=int,
72+
)
73+
74+
MESH_DEFINITIONS: dict[str, tuple[np.ndarray, np.ndarray]] = {
75+
"tetrahedron": (VERTICES_TETRA, FACES_TETRA),
76+
"octahedron": (VERTICES_OCTA, FACES_OCTA),
77+
}
78+
79+
80+
@pytest.fixture(params=list(MESH_DEFINITIONS.keys()), ids=list(MESH_DEFINITIONS.keys()))
81+
def watertight_mesh(request) -> td.TriangleMesh:
82+
"""Parameterized fixture returning watertight meshes of varying complexity."""
83+
84+
vertices, faces = MESH_DEFINITIONS[request.param]
85+
return td.TriangleMesh.from_vertices_faces(vertices, faces)
86+
87+
88+
@pytest.fixture
89+
def non_watertight_mesh() -> td.TriangleMesh:
90+
"""Simple non-watertight surface used to validate graceful handling."""
91+
92+
return td.TriangleMesh.from_vertices_faces(VERTICES_NON_WATERTIGHT, FACES_NON_WATERTIGHT)
93+
94+
95+
class DummyDerivativeInfo:
96+
"""Lightweight stand-in for ``DerivativeInfo`` used in unit tests."""
97+
98+
def __init__(
99+
self,
100+
grad_func,
101+
spacing: float = 0.2,
102+
simulation_bounds: tuple[tuple[float, float, float], tuple[float, float, float]]
103+
| None = None,
104+
) -> None:
105+
self.paths = [("mesh_dataset", "surface_mesh")]
106+
self.frequencies = [200e12]
107+
self.eps_in = 12.0
108+
self.interpolators = {}
109+
default_bounds = ((-10.0, -10.0, -10.0), (10.0, 10.0, 10.0))
110+
self.simulation_bounds = simulation_bounds or default_bounds
111+
self._grad_func = grad_func
112+
self._spacing = spacing
113+
self.bounds_intersect = self.simulation_bounds
114+
115+
def adaptive_vjp_spacing(self) -> float:
116+
return self._spacing
117+
118+
def create_interpolators(self, dtype=None):
119+
return {}
120+
121+
def evaluate_gradient_at_points(
122+
self, spatial_coords, normals, perps1, perps2, interpolators=None
123+
):
124+
return self._grad_func(spatial_coords)
125+
126+
127+
def area_and_normal(triangle: np.ndarray) -> tuple[float, np.ndarray]:
128+
"""Return signed area and unit normal for a triangle."""
129+
130+
edge01 = triangle[1] - triangle[0]
131+
edge02 = triangle[2] - triangle[0]
132+
cross = np.cross(edge01, edge02)
133+
norm = np.linalg.norm(cross)
134+
if np.isclose(norm, 0.0):
135+
return 0.0, np.zeros(3, dtype=triangle.dtype)
136+
return 0.5 * norm, cross / norm
137+
138+
139+
def linear_grad_func_factory(coeffs: np.ndarray, offset: float):
140+
"""Create a linear function g(x) = coeffs.x + offset."""
141+
142+
def grad_func(points: np.ndarray) -> np.ndarray:
143+
return points @ coeffs + offset
144+
145+
return grad_func
146+
147+
148+
def test_triangle_mesh_gradient_linear_matches_analytic(watertight_mesh):
149+
"""Validate per-vertex gradients against analytic integrals for linear g."""
150+
151+
mesh = watertight_mesh
152+
coeffs = np.array([0.6, -0.25, 0.4], dtype=float)
153+
offset = -0.15
154+
grad_func = linear_grad_func_factory(coeffs, offset)
155+
156+
spacing = 0.01 if mesh.triangles.shape[0] <= 4 else 0.005
157+
derivative_info = DummyDerivativeInfo(grad_func, spacing=spacing)
158+
grads = mesh._compute_derivatives(derivative_info)[("mesh_dataset", "surface_mesh")]
159+
160+
expected = np.zeros_like(grads)
161+
for face_idx, tri in enumerate(mesh.triangles):
162+
area, normal = area_and_normal(tri)
163+
if np.isclose(area, 0.0):
164+
continue
165+
166+
g_vals = grad_func(tri)
167+
for local_idx in range(3):
168+
others = [(local_idx + 1) % 3, (local_idx + 2) % 3]
169+
gi = g_vals[local_idx]
170+
gj = g_vals[others[0]]
171+
gk = g_vals[others[1]]
172+
integral = area / 12.0 * (2.0 * gi + gj + gk)
173+
expected[face_idx, local_idx, :] = integral * normal
174+
175+
# surface integration uses adaptive sampling so allow a few-percent mismatch
176+
npt.assert_allclose(grads, expected, rtol=8e-2, atol=1e-6)
177+
178+
179+
def test_triangle_mesh_gradient_directional_derivative_matches_quadrature(watertight_mesh):
180+
"""Directional derivative from gradients matches exact integral."""
181+
182+
mesh = watertight_mesh
183+
coeffs = np.array([0.3, -0.45, 0.55], dtype=float)
184+
offset = 0.2
185+
grad_func = linear_grad_func_factory(coeffs, offset)
186+
187+
spacing = 0.01 if mesh.triangles.shape[0] <= 4 else 0.005
188+
derivative_info = DummyDerivativeInfo(grad_func, spacing=spacing)
189+
grads = mesh._compute_derivatives(derivative_info)[("mesh_dataset", "surface_mesh")]
190+
191+
rng = np.random.default_rng(1234)
192+
delta = rng.normal(scale=5e-3, size=grads.shape)
193+
194+
total_pred = float(np.sum(grads * delta))
195+
196+
total_exact = 0.0
197+
weight_matrix = np.full((3, 3), 1.0 / 12.0)
198+
np.fill_diagonal(weight_matrix, 1.0 / 6.0)
199+
200+
for face_idx, tri in enumerate(mesh.triangles):
201+
area, normal = area_and_normal(tri)
202+
if np.isclose(area, 0.0):
203+
continue
204+
205+
g_vals = grad_func(tri)
206+
dot_vals = delta[face_idx] @ normal
207+
total_exact += area * dot_vals @ weight_matrix @ g_vals
208+
209+
npt.assert_allclose(total_pred, total_exact, rtol=1e-3, atol=1e-6)
210+
211+
212+
def test_triangle_mesh_gradient_constant_field_integrates_to_zero(watertight_mesh):
213+
"""Constant surface gradient should integrate to zero net force on a watertight mesh."""
214+
215+
mesh = watertight_mesh
216+
217+
def constant_grad(points: np.ndarray) -> np.ndarray:
218+
return np.ones(points.shape[0], dtype=float)
219+
220+
derivative_info = DummyDerivativeInfo(constant_grad, spacing=0.01)
221+
grads = mesh._compute_derivatives(derivative_info)[("mesh_dataset", "surface_mesh")]
222+
223+
net_force = np.sum(grads, axis=(0, 1))
224+
npt.assert_allclose(net_force, np.zeros(3), atol=5e-6, rtol=1e-3)
225+
226+
227+
def test_triangle_mesh_gradient_face_permutation_invariant(watertight_mesh):
228+
"""Reordering faces does not change the per-face gradients after reindexing."""
229+
230+
base_mesh = watertight_mesh
231+
perm = np.random.default_rng(42).permutation(base_mesh.triangles.shape[0])
232+
permuted_mesh = td.TriangleMesh.from_triangles(base_mesh.triangles[perm])
233+
234+
coeffs = np.array([0.2, 0.1, -0.3], dtype=float)
235+
offset = 0.05
236+
grad_func = linear_grad_func_factory(coeffs, offset)
237+
238+
derivative_info = DummyDerivativeInfo(grad_func, spacing=0.01)
239+
grad_base = base_mesh._compute_derivatives(derivative_info)[("mesh_dataset", "surface_mesh")]
240+
grad_perm = permuted_mesh._compute_derivatives(derivative_info)[
241+
("mesh_dataset", "surface_mesh")
242+
]
243+
244+
inv_perm = np.argsort(perm)
245+
grad_perm_reordered = grad_perm[inv_perm]
246+
247+
npt.assert_allclose(grad_base, grad_perm_reordered, rtol=1e-3, atol=1e-6)
248+
249+
250+
def test_triangle_mesh_gradient_zero_when_outside_bounds(watertight_mesh):
251+
"""Gradients vanish when the mesh lies entirely outside the simulation bounds."""
252+
253+
mesh = watertight_mesh
254+
255+
def constant_grad(points: np.ndarray) -> np.ndarray:
256+
return np.ones(points.shape[0], dtype=float)
257+
258+
far_bounds = ((100.0, 100.0, 100.0), (101.0, 101.0, 101.0))
259+
derivative_info = DummyDerivativeInfo(
260+
constant_grad,
261+
spacing=0.05,
262+
simulation_bounds=far_bounds,
263+
)
264+
265+
grads = mesh._compute_derivatives(derivative_info)[("mesh_dataset", "surface_mesh")]
266+
npt.assert_allclose(grads, np.zeros_like(grads))
267+
268+
269+
def test_triangle_mesh_non_watertight_warns_and_computes(non_watertight_mesh, caplog):
270+
"""Non-watertight meshes should warn but still return finite gradients."""
271+
272+
def constant_grad(points: np.ndarray) -> np.ndarray:
273+
return np.ones(points.shape[0], dtype=float)
274+
275+
derivative_info = DummyDerivativeInfo(constant_grad, spacing=0.05)
276+
277+
with caplog.at_level("WARNING"):
278+
grads = non_watertight_mesh._compute_derivatives(derivative_info)[
279+
("mesh_dataset", "surface_mesh")
280+
]
281+
282+
assert grads.shape == non_watertight_mesh.triangles.shape
283+
assert np.all(np.isfinite(grads))
284+
assert not non_watertight_mesh.trimesh.is_watertight

0 commit comments

Comments
 (0)