Adaptive Topology Optimization
This tutorial demonstrates 3D topology optimization with adaptive remeshing using FEAX's gene toolkit. The mesh is refined near material interfaces and coarsened in void regions, reducing computational cost while maintaining accuracy.
Overview
Adaptive topology optimization combines density-based optimization with mesh adaptivity:
- Solve the topology optimization on the current mesh
- Periodically remesh — refine where material gradients are large, coarsen elsewhere
- Transfer the density field to the new mesh via TET4 shape function interpolation
- Recompile the JAX pipeline for the new mesh and continue
The gene.optimizer.run() function handles this loop automatically.
Pipeline
Each iteration evaluates the following chain:
- Density filter smooths the node-based design variable
- Heaviside projection sharpens the density toward 0/1 (controlled by )
- SIMP penalizes intermediate densities in the finite element solve
- Compliance is minimized subject to a volume constraint
Problem Setup
Material and Geometry
import jax.numpy as np
import feax as fe
import feax.gene as gene
from feax.gene.optimizer import (
Pipeline, constraint, Continuation, AdaptiveConfig, run,
)
E0 = 70e3 # Young's modulus
nu = 0.3 # Poisson's ratio
E_eps = E0 * 1e-6 # Ersatz stiffness for void
penalty = 3 # SIMP penalty
L, W, H = 100.0, 5.0, 20.0 # Cantilever dimensions
tol = 1e-3
left = lambda pt: np.isclose(pt[0], 0., tol)
right = lambda pt: np.isclose(pt[0], L, tol) & (pt[2] < H / 4)
Linear Elasticity with SIMP
The problem class receives the projected density as a node-based internal variable:
class LinearElasticity(fe.problem.Problem):
def get_tensor_map(self):
def stress(u_grad, rho):
E = E_eps + (E0 - E_eps) * rho**penalty
mu = E / (2. * (1. + nu))
lmbda = E * nu / ((1 + nu) * (1 - 2 * nu))
eps = 0.5 * (u_grad + u_grad.T)
return lmbda * np.trace(eps) * np.eye(self.dim) + 2 * mu * eps
return stress
def get_surface_maps(self):
return [lambda u, x, *a: np.array([0., 0., -traction_mag])]
Gmsh Geometry Builder
Adaptive remeshing uses Gmsh. Define the geometry as a callable that builds the CAD model:
import gmsh
def cantilever_geometry():
"""Build cantilever box geometry via Gmsh OCC."""
gmsh.model.occ.addBox(0, 0, 0, L, W, H)
gmsh.model.occ.synchronize()
This callable is passed to gene.adaptive.adaptive_mesh(), which handles Gmsh initialization, meshing with a size callback, and TET4 extraction.
Pipeline Class
Subclass Pipeline to define the optimization problem. The build(mesh) method creates all mesh-dependent objects — it is called once per mesh and re-called after each remesh, triggering JAX recompilation.
The objective method is required (abstract). Constraints are added by decorating methods with @constraint.
class CantileverPipeline(Pipeline):
def build(self, mesh):
"""Create all mesh-dependent objects. Called once per mesh."""
problem = LinearElasticity(
mesh, vec=3, dim=3, ele_type=mesh.ele_type, location_fns=[right])
bc = fe.DCboundary.DirichletBCConfig([
fe.DCboundary.DirichletBCSpec(
location=left, component="all", value=0.),
]).create_bc(problem)
self._initial = fe.zero_like_initial_guess(problem, bc)
self._compliance_fn = gene.create_compliance_fn(problem)
self._volume_fn = gene.create_volume_fn(problem)
self._filter_fn = gene.create_density_filter(mesh, 3.0)
sample_iv = fe.InternalVars(
volume_vars=(fe.InternalVars.create_node_var(problem, 0.4),),
surface_vars=())
solver_opts = fe.DirectSolverOptions()
self._solver = fe.create_solver(
problem, bc, solver_options=solver_opts,
adjoint_solver_options=solver_opts,
iter_num=1, internal_vars=sample_iv)
def objective(self, rho, beta=1.0):
rho_f = self._filter_fn(rho)
rho_p = gene.heaviside_projection(rho_f, beta=beta)
iv = fe.InternalVars(volume_vars=(rho_p,), surface_vars=())
sol = self._solver(iv, self._initial)
return self._compliance_fn(sol)
@constraint(target=0.4)
def volume(self, rho, beta=1.0):
rho_f = self._filter_fn(rho)
rho_p = gene.heaviside_projection(rho_f, beta=beta)
return self._volume_fn(rho_p)
def filter(self, rho):
return self._filter_fn(rho)
Key points:
build(mesh)stores mesh-dependent objects as instance attributesobjectiveand@constraintmethods accept continuation parameters (herebeta) as keyword arguments@constraint(target=0.4)declares a volume fraction inequality constraint (). Use@constraint(target=x, type='eq')for equality constraints- Constraints are optional — pipelines without any
@constraintmethods run unconstrained filteris optional — the default is the identity function- After remesh,
run()callsbuild()again, then recompiles objective and constraints
Initial Mesh
Generate a TET4 mesh with Gmsh. The coarse h_max keeps the initial mesh lightweight:
mesh = gene.adaptive.adaptive_mesh(cantilever_geometry, h_max=1.0)
Adaptive Configuration
Continuation
Continuation schedules parameter updates during optimization. Here doubles every 20 iterations from 1 to 16:
Continuation(initial=1.0, final=16.0, update_every=20, multiply_by=2.0)
Updates happen at epoch boundaries (synchronized with remeshing). Continuation values are passed as traced JAX arguments, so updates do not trigger recompilation.
Remeshing
AdaptiveConfig controls adaptive remeshing:
AdaptiveConfig(
remesh=lambda m, rho: gene.adaptive.adaptive_mesh(
cantilever_geometry,
refinement_field=gene.adaptive.gradient_refinement(rho, m),
old_mesh=m,
h_min=0.2, h_max=1.0,
),
adapt_every=100,
n_adapts_max=4,
)
Parameters:
remesh— callable(old_mesh, filtered_density) -> new_meshadapt_every— remesh interval (iterations)n_adapts_max— maximum number of remeshes
Gradient-Based Refinement
gene.adaptive.gradient_refinement(rho, mesh) computes the density gradient magnitude per element using TET4 shape functions, then normalizes to :
where is the element Jacobian and are the nodal density differences. Elements with large gradients (material interfaces) get small mesh sizes; uniform regions get large sizes.
Field Transfer
After remeshing, the density field is transferred from the old mesh to the new mesh using gene.adaptive.interpolate_field(). This function uses TET4 barycentric coordinates (shape functions) directly — no Delaunay re-triangulation:
- Precompute inverse Jacobians for all old-mesh elements
- For each new node, find the containing old element via KD-tree on centroids
- Compute barycentric weights and interpolate
- Fall back to nearest-node for points outside the old mesh
Running the Optimization
result = run(
pipeline=CantileverPipeline(),
mesh=mesh,
max_iter=500,
continuations={
"beta": Continuation(initial=1.0, final=16.0, update_every=20,
multiply_by=2.0),
},
adaptive=AdaptiveConfig(
remesh=lambda m, rho: gene.adaptive.adaptive_mesh(
cantilever_geometry,
refinement_field=gene.adaptive.gradient_refinement(rho, m),
old_mesh=m,
h_min=0.2, h_max=1.0,
),
adapt_every=100,
n_adapts_max=4,
),
output_dir="output_adaptive",
save_every=2,
)
run() handles the full loop:
- Call
pipeline.build(mesh)and JIT-compile objective + constraints - At each epoch boundary, update continuation parameters
- Run MMA (NLopt) optimizer for one epoch
- If
iter_count % adapt_every == 0, remesh and transfer density - Rebuild pipeline for new mesh and continue
Set jit=False to disable JIT compilation (useful for debugging).
Visualization
The result contains convergence history:
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(result.history['iteration'], result.history['objective'])
axes[0].set_xlabel('Iteration')
axes[0].set_ylabel('Compliance')
axes[1].plot(result.history['iteration'], result.history['volume'])
axes[1].axhline(y=0.4, color='r', linestyle='--', label='Target')
axes[1].set_xlabel('Iteration')
axes[1].set_ylabel('Volume Fraction')
axes[1].legend()
plt.tight_layout()
plt.savefig("output_adaptive/history.png", dpi=150)
VTU files saved to output_dir can be opened in ParaView. Adapted meshes are saved as adapt_01.vtu, adapt_02.vtu, etc.
Complete Code
import jax.numpy as np
import numpy as onp
import gmsh
import feax as fe
import feax.gene as gene
from feax.gene.optimizer import (
Pipeline, constraint, Continuation, AdaptiveConfig, run,
)
# Material
E0 = 70e3
nu = 0.3
E_eps = E0 * 1e-6
penalty = 3
traction_mag = 1.0
# Geometry
L, W, H = 100.0, 5.0, 20.0
tol = 1e-3
left = lambda pt: np.isclose(pt[0], 0., tol)
right = lambda pt: np.isclose(pt[0], L, tol) & (pt[2] < H / 4)
def cantilever_geometry():
gmsh.model.occ.addBox(0, 0, 0, L, W, H)
gmsh.model.occ.synchronize()
class LinearElasticity(fe.problem.Problem):
def get_tensor_map(self):
def stress(u_grad, rho):
E = E_eps + (E0 - E_eps) * rho**penalty
mu = E / (2. * (1. + nu))
lmbda = E * nu / ((1 + nu) * (1 - 2 * nu))
eps = 0.5 * (u_grad + u_grad.T)
return lmbda * np.trace(eps) * np.eye(self.dim) + 2 * mu * eps
return stress
def get_surface_maps(self):
return [lambda u, x, *a: np.array([0., 0., -traction_mag])]
class CantileverPipeline(Pipeline):
def build(self, mesh):
problem = LinearElasticity(
mesh, vec=3, dim=3, ele_type=mesh.ele_type, location_fns=[right])
bc = fe.DCboundary.DirichletBCConfig([
fe.DCboundary.DirichletBCSpec(
location=left, component="all", value=0.),
]).create_bc(problem)
self._initial = fe.zero_like_initial_guess(problem, bc)
self._compliance_fn = gene.create_compliance_fn(problem)
self._volume_fn = gene.create_volume_fn(problem)
self._filter_fn = gene.create_density_filter(mesh, 3.0)
sample_iv = fe.InternalVars(
volume_vars=(fe.InternalVars.create_node_var(problem, 0.4),),
surface_vars=())
solver_opts = fe.DirectSolverOptions()
self._solver = fe.create_solver(
problem, bc, solver_options=solver_opts,
adjoint_solver_options=solver_opts,
iter_num=1, internal_vars=sample_iv)
def objective(self, rho, beta=1.0):
rho_f = self._filter_fn(rho)
rho_p = gene.heaviside_projection(rho_f, beta=beta)
iv = fe.InternalVars(volume_vars=(rho_p,), surface_vars=())
sol = self._solver(iv, self._initial)
return self._compliance_fn(sol)
@constraint(target=0.4)
def volume(self, rho, beta=1.0):
rho_f = self._filter_fn(rho)
rho_p = gene.heaviside_projection(rho_f, beta=beta)
return self._volume_fn(rho_p)
def filter(self, rho):
return self._filter_fn(rho)
# Initial mesh
mesh = gene.adaptive.adaptive_mesh(cantilever_geometry, h_max=1.0)
# Run
result = run(
pipeline=CantileverPipeline(),
mesh=mesh,
max_iter=500,
continuations={
"beta": Continuation(initial=1.0, final=16.0, update_every=20,
multiply_by=2.0),
},
adaptive=AdaptiveConfig(
remesh=lambda m, rho: gene.adaptive.adaptive_mesh(
cantilever_geometry,
refinement_field=gene.adaptive.gradient_refinement(rho, m),
old_mesh=m,
h_min=0.2, h_max=1.0,
),
adapt_every=100,
n_adapts_max=4,
),
output_dir="output_adaptive",
save_every=2,
)
Summary
Key concepts:
Pipeline— abstract base class for optimization pipelines (build,objective,filter)@constraint— decorator to mark methods as inequality (type='le') or equality (type='eq') constraintsrun()— drives the optimization loop with JIT compilation, continuation, and adaptive remeshingContinuation— schedules parameter updates (, filter radius, etc.)AdaptiveConfig— configures remeshing interval, max remeshes, and remesh callablegene.adaptive.gradient_refinement()— density-gradient-based refinement field for TET4 meshesgene.adaptive.interpolate_field()— TET4 barycentric interpolation for field transfer
Workflow:
- Define geometry as Gmsh callable
- Subclass
Pipelinewithbuild(),objective(), and@constraintmethods - Configure
ContinuationandAdaptiveConfig - Call
run(pipeline, mesh, ...)— handles compilation, optimization, remeshing, and field transfer
Further Reading
examples/advance/topology_optimization_adaptive.py- Complete working example