Topology optimisation on unfitted meshes

In this tutorial, we will learn:

  • How to formulate a Laplace's equation with CutFEM based on Burman et al. (2015)
  • How to use unfitted discretisations from GridapEmbedded in GridapTopOpt
  • How to use automatic (unfitted) shape differentation

This tutorial is for intermediate-level users. You should be familar with the GridapTopOpt framework before attempting this tutorial. In addition, it is recommended that this Gridap tutorial is read before attempting the below.

Problem formulation

The goal of this tutorial is to solve a level-set topology optimisation problem with an unfitted discretisation and automatic shape differentiation. To simplify this tutorial, we consider Laplace's equation for the underlying PDE. Specifically, the PDE-constrained optimisation problem that we consider is

\[\begin{aligned} \min_{\Omega\subset D}&~J(\Omega)\coloneqq\int_{\Omega(\phi)}\boldsymbol{\nabla}u\cdot\boldsymbol{\nabla}u~\mathrm{d}\boldsymbol{x}\\ \text{s.t. }&~C(\Omega)=0,\\ &~\left\lbrace\begin{aligned} -\boldsymbol{\nabla}^2u &= 0~\text{in }\Omega,\\ \boldsymbol{\nabla} u\cdot\boldsymbol{n} &= 1~\text{on }\Gamma_N,\\ \boldsymbol{\nabla} u\cdot\boldsymbol{n} &= 0~\text{on }\Gamma,\\ u &= 0~\text{on }\overline{\Omega}_D, \end{aligned}\right. \end{aligned}\]

where $C(\Omega)=(\operatorname{Vol}(\Omega) - 0.3)/\operatorname{Vol}(D)$ constrains the volume of $\Omega$. The background domain $D$ and boundary conditions are shown below:

Unfitted formulation

In the following, we discretise the PDE governing $u$ using CutFEM based on Burman et al. (2015). The weak formulation for this problem is: for $V=H^1(\Omega;u_{\Gamma_D}=0)$, find $u\in V$ such that

\[\int_{\Omega(\phi)}\boldsymbol{\nabla}u\cdot\boldsymbol{\nabla}v~\mathrm{d}\boldsymbol{x} + j(u,v) + i(u,v) = \int_{\Gamma_N}v~\mathrm{d}s,~\forall v\in V.\]

Those who are familiar with deriving the weak form will notice that we have two addition terms in this formulation. The first is the so-called ghost penalty term $j(u,v)$ that extends coercivity of the bilinear form from the physical domain to the cut domain. This is given by

\[j(u,v)=\int_{\Gamma_g}\gamma h \llbracket \boldsymbol{n}\cdot\boldsymbol{\nabla}u\rrbracket\llbracket \boldsymbol{n}\cdot\boldsymbol{\nabla}v\rrbracket~\mathrm{d}s.\]

The second term $i(u,v)$ enforces zero temperature within an isolated volumes marked by $\chi$. We can think of these isolated volumes as blobs of material that are not sufficently constrained. As a result, without an additional constraint on these "blobs", an unfitted discretisation will yield infinite solutions. This term is given by

\[i(u,v) = \int_{\Omega(\phi)}\chi uv~\mathrm{d}\boldsymbol{x}\]

Note that this only acts on the isolated volumes marked by $\chi$. As a result, the problem is still consistent.

Setup and parameters

As usual, we start by loading the required libraries and defining any parameters:

using Gridap, Gridap.Adaptivity, Gridap.Geometry
using GridapEmbedded, GridapEmbedded.LevelSetCutters
using GridapTopOpt, GridapSolvers

using GridapTopOpt: StateParamMap

path="./results/Unfitted_Thermal2D/"
mkpath(path)
# Params
n = 50            # Initial mesh size (pre-refinement)
max_steps = n/5   # Time-steps for evolution equation
vf = 0.3          # Volume fraction
α_coeff = 2       # Regularisation coefficient extension-regularisation
iter_mod = 10     # Write output every iter_mod iterations

Here, we deictate that the initial mesh size is $50^2$ quadrilateral elements, the time steps for the evolution equation is set to $n/5$, the required volume fraction is 0.3, the reguarisation coefficent for the Hilbertian extension-regularisation is 2, and we data files at each iteration.

Mesh with refinement

For this problem, we use a refined mesh using Gridap's adaptivity features. In addition, we mark mesh entities that are part of $\overline{\Omega}_D$ and $\Gamma_N$:

# Base model
_model = CartesianDiscreteModel((0,1,0,1),(n,n))
base_model = UnstructuredDiscreteModel(_model)
# Refinement
ref_model = refine(base_model, refinement_method = "barycentric")
ref_model = refine(ref_model)
ref_model = refine(ref_model)
model = ref_model.model
# Get mesh size
h = minimum(get_element_diameters(model))
hₕ = get_element_diameter_field(model)
# Mark mesh entities
f_Γ_D(x) = (x[1]-0.5)^2 + (x[2]-0.5)^2 <= 0.05^2
f_Γ_N(x) = ((x[1] ≈ 0 || x[1] ≈ 1) && (0.2 <= x[2] <= 0.3 + eps() || 0.7 - eps() <= x[2] <= 0.8)) ||
  ((x[2] ≈ 0 || x[2] ≈ 1) && (0.2 <= x[1] <= 0.3 + eps() || 0.7 - eps() <= x[1] <= 0.8))
update_labels!(1,model,f_Γ_D,"Omega_D")
update_labels!(2,model,f_Γ_N,"Gamma_N")
writevtk(model,path*"model")
Warning

Non-TET/TRI polytopes are simplexified by GridapEmbedded when cutting. As a result, derivative information from AD will not be correct when using a mesh that isn't made of TRI/TET. Please use a mesh with TRI/TET polytopes to ensure correctness of derivative results.

FESpace for level-set function and derivatives

In example, we consider piecewise linear cuts defined via a level-set function. As such, the level-set function should be discretised using continuous piecewise-linear finite elements. In addition, we use the same space for the derivatives, except we also constrain the derivative space so that the shape derivatives are zero on $\overline{\Omega}_D$ and $\Gamma_N$.

reffe_scalar = ReferenceFE(lagrangian,Float64,1)
V_φ = TestFESpace(model,reffe_scalar)
V_reg = TestFESpace(model,reffe_scalar;dirichlet_tags=["Omega_D","Gamma_N"])
U_reg = TrialFESpace(V_reg)

Next, we build the initial level-set function using

f1 = (x,y) -> -cos(6π*(x-1/12))*cos(6π*(y-1/12))-0.5
f2 = (x,y) -> -cos(6π*(x-3/12))*cos(6π*(y-1/12))-0.5
f3 = (x,y) -> (x-0.5)^2 + (y-0.5)^2 - 0.06^2
f((x,y)) = min(max(f1(x,y),f2(x,y)),f3(x,y))
φh = interpolate(f,V_φ)

Before defining the triangulation, we need to ensure that the initial cut interface defined by the level-set function does not intersect the vertices in the background domain. We do this using:

GridapTopOpt.correct_ls!(φh)

Background triangulations and measures

Next we define the measures for the background domain as usual

Ω_bg = Triangulation(model)
Γ_N = BoundaryTriangulation(model,tags="Gamma_N")
dΩ_bg = Measure(Ω_bg,2)
dΓ_N = Measure(Γ_N,2)
vol_D = sum(∫(1)dΩ_bg)

Embedded triangulations and measures

To make it possible to update the embedded triangulations, measures, and the indicator function we use an EmbeddedCollection. This is given by

Ωs = EmbeddedCollection(model,φh) do cutgeo,cutgeo_facets,_φh
  Ωin = DifferentiableTriangulation(Triangulation(cutgeo,PHYSICAL),V_φ)
  Γ = DifferentiableTriangulation(EmbeddedBoundary(cutgeo),V_φ)
  Γg = GhostSkeleton(cutgeo)
  Ωact = Triangulation(cutgeo,ACTIVE)
  φ_cell_values = get_cell_dof_values(_φh)
  χ,_ = get_isolated_volumes_mask_polytopal(model,φ_cell_values,["Omega_D",])
  (;
    :Ωin  => Ωin,
    :dΩin => Measure(Ωin,2),
    :Γg   => Γg,
    :dΓg  => Measure(Γg,2),
    :n_Γg => get_normal_vector(Γg),
    :Γ    => Γ,
    :dΓ   => Measure(Γ,2),
    :n_Γ  => get_normal_vector(Γ),
    :Ωact => Ωact,
    :χ => χ
  )
end

The do statement above provides a recipe for the EmbeddedCollection to generate the data in Ωs that can be accessed, for example, via Ωs.Ωin. The contents of Ωs can then be updated via update_collection!(c::EmbeddedCollection,φh). In addition, new recipes can be added using add_recipe!(c::EmbeddedCollection,r::Function[,φh]). Inside EmbeddedCollection, cut geometries are created and then passed to recipes when update_collection! is called.

Isolated volume marking and automatic differentation

In the above, we use get_isolated_volumes_mask_polytopal to create a cell field that marks cells based on whether they are connected to Omega_D. In addition, we wrap the cut triangulations inside DifferentiableTriangulation. This is a wrapper around an embedded triangulation (i.e SubCellTriangulation or SubFacetTriangulation) implementing all the necessary methods to compute derivatives with respect to deformations of the embedded mesh. To do so, it propagates dual numbers into the geometric maps mapping cut subcells/subfacets to the background mesh. We refer to this article for the mathematical discussion:

Wegert, Z.J., Manyer, J., Mallon, C.N. et al. Level-set topology optimisation with unfitted finite elements and automatic shape differentiation. Accepted for publication in Computer Methods in Applied Mechanics and Engineering (2025). http://arxiv.org/abs/2504.09748

FE problem

Now that all the measures are defined, lets define the weak form, optimisation functionals, and the FE operators. First, we can define the weak form and optimisation functionals as

γg = 0.1
a(u,v,φ) = ∫(∇(v)⋅∇(u))Ωs.dΩin +
           ∫((γg*mean(hₕ))*jump(Ωs.n_Γg⋅∇(v))*jump(Ωs.n_Γg⋅∇(u)))Ωs.dΓg +
           ∫(Ωs.χ*v*u)Ωs.dΩin
l(v,φ) = ∫(v)dΓ_N

and

J(u,φ) = ∫(∇(u)⋅∇(u))Ωs.dΩin
Vol(u,φ) = ∫(1/vol_D)Ωs.dΩin - ∫(vf/vol_D)dΩ_bg
dVol(q,u,φ) = ∫(-1/vol_D*q/(abs(Ωs.n_Γ ⋅ ∇(φ))))Ωs.dΓ

We have directly computed the shape derivative of the volume functional using the analytical results by Wegert et al. (2025) referenced above.

Next, we setup the AffineFEStateMap and PDEConstrainedFunctionals objects. Here we use another new objective EmbeddedCollection_in_φh that is similar to EmbeddedCollection except it does not cut the mesh and only expects recipes that take arguments in φh. This allows us to update the spaces and state maps whenever required. Note that this is different to how we usually setup these objects because we have to recreate the FE spaces and state maps at each iteration. This is because the active mesh for the unfitted discretisation is changing over the course of the level-set evolution. This new state_collection holds the state map, the objective as a StateParamMap, and set of StateParamMap for the constraints. This is given by the follow snippet

state_collection = EmbeddedCollection_in_φh(model,φh) do _φh
  update_collection!(Ωs,_φh)
  V = TestFESpace(Ωs.Ωact,reffe_scalar;dirichlet_tags=["Omega_D"])
  U = TrialFESpace(V,0.0)
  state_map = AffineFEStateMap(a,l,U,V,V_φ,U_reg,_φh)
  (;
    :state_map => state_map,
    :J => StateParamMap(J,state_map),
    :C => map(Ci -> StateParamMap(Ci,state_map),[Vol,])
  )
end

Note that StateParamMap is a wrapper to handle partial differentation of a function in a ChainRules.jl compatible way with caching. See this documentation for further information.

Next, we create the EmbeddedPDEConstrainedFunctionals. This is similar to PDEConstrainedFunctionals except it takes state_collection as the argument and updates the unfitted triangulations where appropriate.

pcfs = EmbeddedPDEConstrainedFunctionals(state_collection;analytic_dC=(dVol,))

Evolution

We now define the evolution and reinitialisation methods for this problem. Here we use unfitted approaches to solve both of these problems

To set these up, we provide CutFEMEvolve and StabilisedReinit as follows:

evo = CutFEMEvolve(V_φ,Ωs,dΩ_bg,hₕ;max_steps,γg=0.1)
reinit = StabilisedReinit(V_φ,Ωs,dΩ_bg,hₕ;stabilisation_method=ArtificialViscosity(2.0))

We then define the UnfittedFEEvolution object that wraps these methods, and we reinitialise our initial level-set function via

ls_evo = UnfittedFEEvolution(evo,reinit)
reinit!(ls_evo,φh)

Hilbertian extension-regularisation problems

The Hilbertian extension-regularisation approach projects a directional derivative $\mathrm{d}J(\phi;w)$ of a functional $J(\phi)$ onto a Hilbert space $H$ on $D$, typically with additional regularity. This involves solving an identification problem: for Find $g_\Omega\in H$ such that

\[\langle g_\Omega,w\rangle_H=-\mathrm{d}J(\phi;w),~\forall w\in H,\]

where $\langle\cdot,\cdot\rangle_H$ is the inner product on $H$. Here, we use $H=H^1_{\Omega_D\cup\Gamma_N}(D)$ with the inner product

\[ \langle u,v\rangle_{H}=\int_D\left(\alpha^2\boldsymbol{\nabla} u\cdot\boldsymbol{\nabla} v+uv\right)\mathrm{d}\boldsymbol{x}.\]

This is implemented as follows:

α = (α_coeff)^2*hₕ*hₕ
a_hilb(p,q) =∫(α*∇(p)⋅∇(q) + p*q)dΩ_bg;
vel_ext = VelocityExtension(a_hilb,U_reg,V_reg)

Optimiser and solution

Finally, for the optimiser we use the AugmentedLagrangian method with the convergence criteria below. In addition, we output data on the background mesh, along with the solution $u_h$ on the unfitted triangulation. This is given by the following:

converged(m) = GridapTopOpt.default_al_converged(
  m;
  L_tol = 0.01*h,
  C_tol = 0.01
)
optimiser = AugmentedLagrangian(pcfs,ls_evo,vel_ext,φh;verbose=true,constraint_names=[:Vol],converged)
for (it,uh,φh) in optimiser
  if iszero(it % iter_mod)
    writevtk(Ω_bg,path*"Omega$it",cellfields=["φ"=>φh,"|∇(φ)|"=>(norm ∘ ∇(φh)),"uh"=>uh,"χ"=>Ωs.χ])
    writevtk(Ωs.Ωin,path*"Omega_in$it",cellfields=["uh"=>uh])
  end
  write_history(path*"/history.txt",optimiser.history)
end
it = get_history(optimiser).niter; uh = get_state(pcfs)
writevtk(Ω_bg,path*"Omega$it",cellfields=["φ"=>φh,"|∇(φ)|"=>(norm ∘ ∇(φh)),"uh"=>uh,"χ"=>Ωs.χ])
writevtk(Ωs.Ωin,path*"Omega_in$it",cellfields=["uh"=>uh])

Solving this problem results in the following iteration history:

The full script for this problem can be found in scripts/Examples/Unfitted/Thermal2D_CutFEM.jl.