Skip to content

Commit 427fe09

Browse files
feat(tidy3d): FXC-3693-triangle-mesh-support-for-adjoint
1 parent 989e807 commit 427fe09

File tree

5 files changed

+604
-1
lines changed

5 files changed

+604
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
- Added multimode support to `WavePort` in the smatrix plugin, allowing multiple modes to be analyzed per port.
3232
- Added support for `.lydrc` files for design rule checking in the `klayout` plugin.
3333
- Added a Gaussian inverse design filter option with autograd gradients and complete padding mode coverage.
34+
- Added support of `TriangleMesh` for autograd.
3435

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

0 commit comments

Comments
 (0)