Multi-Material Topology Optimization with Softmax Parametrization

Description

This tutorial demonstrates multi-material topology optimization, where the design distributes several candidate materials (plus void) over the domain. Each element holds a softmax over nmats - 1 decision variables, ensuring material fractions sum to one. Material interpolation maps these fractions to Young’s moduli (for compliance) and physical densities (for mass constraints).

We optimize a cantilever beam with 3 materials: void (E=1e-5), soft material (E=1.0), and stiff material (E=4.0), subject to a 40% mass constraint.

Setup

using TopOpt, Zygote, Test

Define the Problem

Es = [1e-5, 1.0, 4.0]      # Young's moduli: void, soft, stiff
densities = [0.0, 0.5, 1.0] # physical densities for mass constraint
nmats = 3                   # number of materials

nu = 0.3  # Poisson's ratio
f = 1.0   # downward force

problem = PointLoadCantilever(
    Val{:Linear},    # linear elements
    (160, 40),       # 160×40 mesh
    (1.0, 1.0),      # element size
    1.0,             # base Young's modulus
    nu,              # Poisson's ratio
    f,               # load
)
ncells = getncells(problem)

FEA Solver and Filter

solver = FEASolver(DirectSolver, problem; xmin=0.0)
filter = DensityFilterFun(solver; rmin=4.0)
comp = ComplianceFun(solver)

Material Interpolation

# Interpolation 1: Young's moduli for compliance (penalty=3.0)
penalty1 = PowerPenaltyFun(3.0)
interp1 = MaterialInterpolationFun(Es, penalty1)

# Interpolation 2: Physical densities for mass constraint (penalty=1.0)
penalty2 = PowerPenaltyFun(1.0)
interp2 = MaterialInterpolationFun(densities, penalty2)

Two interpolations serve different purposes:

  • interp1 maps softmax outputs to stiffness for FEA
  • interp2 maps to physical densities for mass calculation

Objective and Constraint

obj = y -> begin
    x = tounit(MultiMaterialVariablesFun(y, nmats))
    _E = interp1(filter(x))
    return comp(_E)
end

y0 = zeros(ncells * (nmats - 1))

# Sanity-check objective and gradient
println("Initial objective: $(obj(y0))")
grad_obj = Zygote.gradient(obj, y0)[1]
println("Gradient norm: $(norm(grad_obj))")

constr = y -> begin
    _rhos = interp2(MultiMaterialVariablesFun(y, nmats))
    return sum(_rhos.x) / ncells - 0.4  # 40% mass constraint
end

println("Initial constraint: $(constr(y0))")
grad_constr = Zygote.gradient(constr, y0)[1]
println("Constraint gradient norm: $(norm(grad_constr))")

The softmax parametrization ensures material fractions sum to one at each element. Design variables y are unconstrained (−10 to 10), transformed to material fractions via softmax.

Optimization

model = Model(obj)
addvar!(model, fill(-10.0, length(y0)), fill(10.0, length(y0)))
add_ineq_constraint!(model, constr)

alg = MMA87()
options = MMAOptions(; s_init=0.1, tol=Tolerance(; kkt=1e-3))

y0 = zeros(ncells * (nmats - 1))
res = optimize(model, alg, y0; options=options)
y = res.minimizer
[ Info:   iter       obj      Δobj  violation  kkt_residual  
[ Info:      0   1.3e+03       Inf   1.0e-01   1.7e+00
[ Info:      1   1.5e+03   1.5e+02   0.0e+00   9.5e+02
[ Info:      2   5.6e+02   9.1e+02   0.0e+00   2.3e+02
[ Info:      3   3.4e+02   2.2e+02   0.0e+00   5.1e+01
[ Info:      4   2.6e+02   7.7e+01   0.0e+00   1.7e+01
[ Info:      5   2.3e+02   3.3e+01   0.0e+00   7.9e+00
[ Info:      6   2.1e+02   1.7e+01   0.0e+00   4.4e+00
[ Info:      7   2.0e+02   1.0e+01   0.0e+00   3.0e+00
[ Info:      8   1.9e+02   7.4e+00   0.0e+00   2.2e+00
[ Info:      9   1.9e+02   5.6e+00   0.0e+00   1.7e+00
[ Info:     10   1.8e+02   4.5e+00   0.0e+00   1.3e+00
[ Info:     11   1.8e+02   3.6e+00   0.0e+00   1.1e+00
[ Info:     12   1.8e+02   3.0e+00   0.0e+00   8.7e-01
[ Info:     13   1.8e+02   2.4e+00   0.0e+00   7.3e-01
[ Info:     14   1.7e+02   2.2e+00   0.0e+00   4.6e-01
[ Info:     15   1.7e+02   1.9e+00   0.0e+00   2.6e-01
[ Info:     16   1.7e+02   1.8e+00   4.3e-05   3.3e-02
[ Info:     17   1.7e+02   1.3e+00   5.2e-05   3.8e-02
[ Info:     18   1.7e+02   5.6e-01   0.0e+00   1.8e+00
[ Info:     19   1.7e+02   2.2e+00   0.0e+00   8.9e-01
[ Info:     20   1.7e+02   8.2e-01   0.0e+00   7.8e-01
[ Info:     21   1.6e+02   1.2e+00   0.0e+00   2.4e-01
[ Info:     22   1.6e+02   4.7e-01   0.0e+00   2.3e-01
[ Info:     23   1.6e+02   5.8e-01   0.0e+00   7.0e-02
[ Info:     24   1.6e+02   4.2e-01   0.0e+00   4.8e-02
[ Info:     25   1.6e+02   3.4e-01   0.0e+00   1.1e-01
[ Info:     26   1.6e+02   4.2e-01   0.0e+00   1.0e-01
[ Info:     27   1.6e+02   4.7e-01   0.0e+00   9.5e-02
[ Info:     28   1.6e+02   5.4e-01   0.0e+00   9.3e-02
[ Info:     29   1.6e+02   5.3e-01   0.0e+00   1.4e-01
[ Info:     30   1.6e+02   6.5e-01   0.0e+00   9.3e-02
[ Info:     31   1.6e+02   6.4e-01   0.0e+00   1.1e-01
[ Info:     32   1.6e+02   8.6e-01   0.0e+00   3.4e-02
[ Info:     33   1.6e+02   9.1e-01   5.3e-05   3.2e-02
[ Info:     34   1.6e+02   8.6e-01   4.6e-05   2.7e-02
[ Info:     35   1.6e+02   9.7e-01   0.0e+00   6.9e-02
[ Info:     36   1.5e+02   2.0e+00   8.4e-04   4.5e-01
[ Info:     37   1.5e+02   1.4e+00   1.4e-03   7.1e-01
[ Info:     38   1.5e+02   6.8e-03   0.0e+00   4.6e-02
[ Info:     39   1.5e+02   2.1e-01   0.0e+00   2.5e-01
[ Info:     40   1.5e+02   3.6e-01   0.0e+00   1.6e-01
[ Info:     41   1.5e+02   2.2e-01   0.0e+00   1.3e-01
[ Info:     42   1.5e+02   1.3e-01   0.0e+00   1.2e-01
[ Info:     43   1.5e+02   3.3e-01   2.2e-04   1.1e-01
[ Info:     44   1.5e+02   7.7e-02   0.0e+00   2.1e-02
[ Info:     45   1.5e+02   6.8e-02   1.9e-05   9.9e-03
[ Info:     46   1.5e+02   4.3e-02   4.3e-05   2.2e-02
[ Info:     47   1.5e+02   2.6e-02   0.0e+00   2.8e-02
[ Info:     48   1.5e+02   3.7e-02   0.0e+00   4.7e-03
[ Info:     49   1.5e+02   5.5e-02   6.3e-05   3.3e-02
[ Info:     50   1.5e+02   7.1e-02   0.0e+00   6.1e-02
[ Info:     51   1.5e+02   6.3e-02   0.0e+00   2.0e-02
[ Info:     52   1.5e+02   3.4e-02   1.6e-05   8.5e-03
[ Info:     53   1.5e+02   2.9e-03   1.5e-05   7.7e-03
[ Info:     54   1.5e+02   7.1e-03   0.0e+00   3.2e-03
[ Info:     55   1.5e+02   1.5e-02   1.4e-05   7.5e-03
[ Info:     56   1.5e+02   1.5e-02   3.5e-05   1.8e-02
[ Info:     57   1.5e+02   2.6e-03   2.2e-05   1.2e-02
[ Info:     58   1.5e+02   1.1e-02   0.0e+00   3.6e-03
[ Info:     59   1.5e+02   2.3e-02   0.0e+00   4.0e-02
[ Info:     60   1.5e+02   5.0e-02   0.0e+00   9.7e-02
[ Info:     61   1.5e+02   7.8e-02   0.0e+00   2.4e-02
[ Info:     62   1.5e+02   5.2e-03   0.0e+00   2.5e-02
[ Info:     63   1.5e+02   2.4e-02   0.0e+00   6.9e-03
[ Info:     64   1.5e+02   3.5e-03   0.0e+00   7.3e-03
[ Info:     65   1.5e+02   1.6e-03   0.0e+00   9.7e-03
[ Info:     66   1.5e+02   2.9e-02   0.0e+00   5.0e-02
[ Info:     67   1.5e+02   2.6e-02   0.0e+00   3.7e-02
[ Info:     68   1.5e+02   8.1e-02   7.1e-05   3.8e-02
[ Info:     69   1.5e+02   8.2e-03   7.4e-05   3.9e-02
[ Info:     70   1.5e+02   1.5e-02   4.0e-05   2.1e-02
[ Info:     71   1.5e+02   1.4e-02   1.0e-05   5.3e-03
[ Info:     72   1.5e+02   2.5e-03   3.5e-06   1.9e-03
[ Info:     73   1.5e+02   1.8e-03   5.4e-06   2.8e-03
[ Info:     74   1.5e+02   3.1e-03   9.2e-06   4.9e-03
[ Info:     75   1.5e+02   1.6e-02   3.4e-05   1.8e-02
[ Info:     76   1.5e+02   1.7e-02   0.0e+00   3.1e-04
12800-element Vector{Float64}:
 -10.0
 -10.0
 -10.0
 -10.0
 -10.0
 -10.0
 -10.0
 -10.0
 -10.0
 -10.0
   ⋮
  10.0
  10.0
  10.0
  10.0
  10.0
  10.0
  10.0
  10.0
  10.0

Verify Results

println("Constraint value: $(constr(y))")
x = tounit(reshape(y, ncells, nmats - 1))
println("Non-void fraction: $(sum(x[:, 2:3]) / size(x, 1))")

# Verify constraints are satisfied
println("Mass constraint satisfied: $(constr(y) < 1e-6)")
println("Softmax sums to 1: $(all(x -> isapprox(x, 1), sum(x; dims=2)))")
Constraint value: -1.0341403505753632e-7
Non-void fraction: 0.5683926946582403
Mass constraint satisfied: true
Softmax sums to 1: true

Visualization

The visualize function accepts a per-cell topology vector (length ncells) that controls each cell’s transparency, and an optional cell_colors vector (same length) mapped through a colormap. For multi-material designs we first convert the raw decision variables y into per-cell material fractions via tounit, yielding an ncells × nmats matrix whose rows sum to 1. Each column is one material’s fraction per element, which we can pass to visualize to inspect that material in isolation.

using CairoMakie
x = tounit(MultiMaterialVariablesFun(y, nmats))  # ncells × nmats, rows sum to 1
rhos = x.x * densities  # physical density per cell, used for transparency
fig = visualize(problem; topology=rhos, cell_colors=interp1(x).x, draw_legend=true)
Precompiling packages...
   5425.9 msQuartoNotebookWorkerMakieExt (serial)
  1 dependency successfully precompiled in 6 seconds
Precompiling packages...
   4613.7 msQuartoNotebookWorkerCairoMakieExt (serial)
  1 dependency successfully precompiled in 5 seconds
Figure 1: Multi-material design: void (white), soft material (gray), stiff material (black)

Here topology=rhos sets each cell’s alpha to its physical density (0 for void, 0.5 for soft, 1.0 for stiff), and cell_colors is the per-cell Young’s modulus so the colormap distinguishes the three materials. To inspect a single material, pass that material’s fraction column as the topology and mask the others:

stiff_frac = x.x[:, 3]  # fraction of stiff material per cell
fig_stiff = visualize(problem; topology=stiff_frac)
Figure 2: Stiff material (E=4.0) distribution only
soft_frac = x.x[:, 2]  # fraction of soft material per cell
fig_soft = visualize(problem; topology=soft_frac)
Figure 3: Soft material (E=1.0) distribution only

The combined view shows optimal material distribution — stiff material concentrates in high-stress regions, soft material in intermediate areas, and void where material is inefficient. The per-material figures isolate each phase, making it easier to verify the design.