SimulationLab ladder
Level 8 of 10 · Structural engineering
VERIFIEDCantilever tip deflection within 1% of Euler–Bernoulli; exactly 8.00% volume; the STL is watertight (351 g of Ti-6Al-4V).

L08 · Generative Bracket

Tell the computer where the bolts go, where the load comes in and how much titanium you can afford, and let it grow the part. This is what the "generative design" button in Fusion 360, Altair Inspire and Ansys Discovery does underneath: a finite-element solver inside an optimiser that moves material to where it carries load. Here it is in one Python file, on a titanium engine-mount bracket after the GE / GrabCAD 2013 jet-engine bracket challenge. It uses the challenge's four load cases, 8 % of the design box, 45 design iterations and about 3.5 minutes of CPU, and the result is a watertight STL you can send to a 3D printer.

The optimised Ti-6Al-4V bracket (silver) with its pin and bolts (orange), the design box (thin lines) and the three force load cases (cyan arrows)

Movies: output/turntable.mp4 (1280×720, 4 s loop) and output/evolution.mp4 (1280×720, 6.5 s). The second shows the optimiser at work: the translucent blue cloud is material at density ≥ 0.15, the silver surface is density 0.5, and the passive lugs and bolt bosses are there from the first frame. Print it: output/bracket.stl (27,184 triangles, watertight, millimetres).

You are here

Welcome to level 8 of 10. Each level jumps to a new field and adds tools to the stack. This one brings the field every mechanical and aerospace engineer uses daily: finite elements. It also brings the two things that turn FEM into a design tool: sparse iterative solvers that scale to 3D, and gradient-based topology optimisation.

Level Field M&S paradigm New tools
L01 Wildfire Percolation Statistical physics / ecology Cellular automata, Monte Carlo NumPy, Matplotlib
L02 Noise in Gene Circuits Synthetic biology ODEs vs Gillespie SSA + SciPy
L03 ATL Checkpoint Operations research Discrete-event simulation + SimPy
L04 Echo Chambers Computational social science Agent-based models on networks + Mesa, NetworkX
L05 Galactic Collision Astrophysics N-body, HPC Python + Numba, MP4
L06 Virtual Wind Tunnel Fluid dynamics Lattice Boltzmann CFD, V&V + Numba stencils
L07 Earth's Thermostat Climate science Bayesian calibration, UQ + pandas, emcee, SALib
▶ L08 Generative Bracket Structural engineering FEM + 3D topology optimisation + scikit-fem, pyamg, PyVista/VTK, STL
L09 Orbital Traffic Control Astrodynamics Real-catalog propagation + sgp4, Three.js
L10 Solar Stinger Energy + mobility Real-data digital twin + pvlib, GIS

Things from earlier levels that come back: the "check the fast code against the obviously-correct code" habit (L01), a solver comparison with a scaling plot (L05), and a V&V table with honest misses (L06, L07). Numba (L05) does the one loop NumPy cannot vectorise, and pandas (L07) keeps the tables.

The question

  1. Design. Take a 176 × 109 × 64 mm design box with four bolts on the bottom and a clevis pin near the top. The pin carries the four GE-challenge load cases: 8,000 lbf up, 8,500 lbf sideways, 9,500 lbf at 42° and 5,000 lbf·in of torsion. Where should 8 % of the box's volume of titanium go to make the stiffest possible bracket, summed over all four cases?
  2. Trust. Before believing a 3D FE answer, is the solver right? We check a clamped cantilever against Euler–Bernoulli and Timoshenko beam theory at several mesh resolutions, and we check our fast home-made assembly against scikit-fem's reference assembly.
  3. Cost. Why does every 3D topology optimisation code use an iterative multigrid-preconditioned solver instead of the sparse direct solver that works so well in 2D?

The model

1. Linear elasticity and the FEM pipeline

Small-strain, linear-elastic, isotropic solid (Ti-6Al-4V: $E = 113.8$ GPa, $\nu = 0.342$, 4.43 g/cm³). Units throughout are mm, N and MPa, so compliance comes out in N·mm = mJ.

$$\nabla\cdot\boldsymbol\sigma = \mathbf 0,\qquad \boldsymbol\sigma = \lambda\,\mathrm{tr}(\boldsymbol\varepsilon)\,\mathbf I + 2\mu\,\boldsymbol\varepsilon,\qquad \boldsymbol\varepsilon = \tfrac12(\nabla\mathbf u + \nabla\mathbf u^{\mathsf T}),$$

with $\mathbf u = \mathbf 0$ on the clamped boundary and traction $\mathbf t$ on the loaded boundary. The finite-element method turns this into a linear system in five steps:

Step What happens In the code
Weak form Multiply by a test field $\mathbf v$ and integrate by parts: find $\mathbf u$ with $\int_\Omega \boldsymbol\sigma(\mathbf u):\boldsymbol\varepsilon(\mathbf v)\,d\Omega = \int_{\Gamma_t}\mathbf t\cdot\mathbf v\,d\Gamma$ for all admissible $\mathbf v$. Only first derivatives remain, so piecewise-polynomial fields are allowed. skfem.models.elasticity.linear_elasticity is exactly this bilinear form
Element stiffness On each element $\mathbf u \approx \sum_a N_a \mathbf u_a$ with trilinear shape functions (8 nodes × 3 dofs = 24). Then $\mathbf k_e = \int_{\Omega_e}\mathbf B^{\mathsf T}\mathbf D\,\mathbf B\,d\Omega$, evaluated by 2×2×2 Gauss quadrature ($\mathbf B$ = strain–displacement matrix, $\mathbf D$ = elasticity matrix). voxel_fem: scikit-fem integrates the form on one voxel; every voxel is a translated copy, so one 24×24 $\mathbf k_0$ serves all
Assembly $\mathbf K = \sum_e \mathbf P_e^{\mathsf T}(E_e\,\mathbf k_0)\,\mathbf P_e$: scatter-add each element matrix into the global sparse matrix via its dof map. set_supports precomputes where each of the $24^2$ entries lands in the CSR array, and scatter (Numba) adds them: about 0.1 s for 23 M entries
Boundary conditions Dirichlet: delete the rows and columns of fixed dofs (what scikit-fem's condense does). Loads: nodal forces. For a uniform traction on bilinear faces the consistent weights are 1 (interior node), ½ (edge) and ¼ (corner). set_supports, bracket, cantilever_voxel
Sparse solve $\mathbf K\mathbf u = \mathbf f$, where $\mathbf K$ is symmetric positive-definite with about 81 non-zeros per row. solve → amg + pcg

2. Why AMG-preconditioned CG beats a direct solver in 3D

A sparse direct solver (Cholesky or LU) factorises $\mathbf K$. The factors "fill in": entries that were zero become non-zero. For a 2D grid the fill is mild. For a 3D grid even the best ordering (nested dissection, George 1973) must eliminate separator planes of $N^{2/3}$ unknowns, which become dense blocks. That costs $O(N^{4/3})$ memory and $O(N^{2})$ flops, and it is why "just call spsolve" dies somewhere past $10^5$ unknowns in 3D.

Conjugate gradients (CG) needs only matrix–vector products ($O(N)$ memory), but its iteration count grows like $\sqrt{\kappa(\mathbf K)}$, and $\kappa \propto h^{-2}$. With a simple diagonal (Jacobi) preconditioner that means $\propto 1/h \propto N^{1/3}$ iterations, so $O(N^{4/3})$ work, and SIMP makes it worse: void regions have a stiffness $10^{9}$ times smaller than solid.

Algebraic multigrid (AMG) fixes the conditioning. CG's slow-to-converge error components are smooth, low-energy modes. A multigrid V-cycle removes the high-frequency error with a cheap smoother (Gauss–Seidel) and hands the smooth remainder to a coarser copy of the problem, recursively. Algebraic means the coarse levels are built from the matrix alone, with no geometry needed. Smoothed aggregation (Vaněk, Mandel & Brezina 1996) groups nodes into aggregates. For elasticity it must be told the near-null space, the six rigid-body modes (3 translations and 3 rotations) that cost no strain energy. rigid_body_modes builds them, and pyamg keeps them exactly representable on every level. Used as a CG preconditioner, the V-cycle gives an iteration count that barely depends on $N$ (9–13 here from 2×10³ to 2×10⁵ unknowns), so the total work is $O(N)$. Topology optimisation adds two more reasons to go iterative. The matrix changes every design iteration, so a factorisation is never reused. And the previous design's displacement is an excellent initial guess (warm start), which only an iterative method can exploit.

3. SIMP: from "where is material" to a smooth optimisation problem

Each voxel $e$ gets a density $\rho_e \in [0,1]$ and a Young's modulus (Solid Isotropic Material with Penalisation, Bendsøe 1989):

$$E_e(\tilde\rho_e) = E_{\min} + \tilde\rho_e^{\,p}\,(E_0 - E_{\min}),\qquad p = 3,\ E_{\min} = 10^{-9}E_0 .$$

The optimisation problem, for load cases $k = 1..4$, is

$$\min_{\boldsymbol\rho}\ c = \sum_k \mathbf f_k^{\mathsf T}\mathbf u_k \quad\text{s.t.}\quad \mathbf K(\tilde{\boldsymbol\rho})\,\mathbf u_k = \mathbf f_k,\qquad \frac{1}{N_e}\sum_e \tilde\rho_e = V^* = 0.08,\qquad 0 \le \rho_e \le 1 .$$

Compliance $c$ is the work done by the loads, and minimising it means maximising stiffness. The problem is self-adjoint, so the gradient needs no extra solve:

$$\frac{\partial c}{\partial\tilde\rho_e} = -\,p\,\tilde\rho_e^{\,p-1}(E_0 - E_{\min})\sum_k \mathbf u_{k,e}^{\mathsf T}\mathbf k_0\,\mathbf u_{k,e}.$$

Why penalise? With $p = 1$ stiffness is proportional to density, so half-dense material is exactly as efficient as full material spread thinner. The optimum is a smooth grey "variable-thickness sheet" you cannot manufacture (top panel of the MBB figure below). With $p = 3$ a half-dense voxel costs half the volume but gives only 1/8 of the stiffness. Grey becomes a bad deal, and the optimiser pushes densities to 0 or 1 on its own.

4. The density filter, and why checkerboards happen

Run SIMP on bilinear/trilinear elements without regularisation and you get checkerboards: solid and void voxels alternating like a chessboard (middle panel below). They are a numerical artefact. Two solid elements touching only at a corner node still transfer load through that node, and low-order elements overestimate the stiffness of such a pattern (Díaz & Sigmund 1995). The optimiser is doing its job perfectly and exploiting a flaw in the discretisation. Here the unfiltered design reports a lower compliance (185.4) than the clean one (194.6). A second problem is mesh dependence. Without a length scale the continuum problem wants ever-finer members, so refining the mesh changes the answer instead of converging it.

The density filter (Bruns & Tortorelli 2001, Bourdin 2001) fixes both. The physical density is a weighted average of the design variables within a radius $r_{\min}$:

$$\tilde\rho_e = \frac{\sum_i H_{ei}\,\rho_i}{\sum_i H_{ei}},\qquad H_{ei} = \max\bigl(0,\ r_{\min} - \lVert\mathbf x_e - \mathbf x_i\rVert\bigr).$$

Nothing can vary faster than the filter allows, so checkerboards average out and members are at least about $2r_{\min}$ wide. The chain rule sends gradients back through the same weights, $\partial c/\partial\rho_i = \sum_e H_{ei}\,(\partial c/\partial\tilde\rho_e)/\sum_j H_{ej}$. The grid is regular, so $\mathbf H$ is never stored. It is a 3D correlation with a cone kernel (scipy.ndimage.correlate), and its adjoint is the same correlation because the kernel is symmetric. The price is a grey band about one filter radius wide at every surface (23 % "grey" in the bottom MBB panel). We cut through that band with the $\tilde\rho = 0.5$ isosurface. A Heaviside projection would remove it (see Go further).

5. The optimality-criteria (OC) update

The KKT condition for a single volume constraint is $B_e \equiv -\dfrac{\partial c/\partial\rho_e}{\lambda\,\partial V/\partial\rho_e} = 1$ wherever $0 < \rho_e < 1$. OC turns this into a fixed-point update (Bendsøe & Sigmund 2003):

$$\rho_e^{\text{new}} = \mathrm{clamp}\Bigl(\rho_e\,B_e^{\,\eta},\ \ \rho_e \pm m,\ \ [0,1]\Bigr),\qquad \eta = \tfrac12,\ m = 0.2 .$$

The Lagrange multiplier $\lambda$ is found by bisection so that the filtered volume hits 8 % exactly after every step. Elements with $B_e > 1$ are doing more than their share of work and grow. The others shrink. OC is a heuristic, but for one constraint it is hard to beat. With several constraints (stress, frequency, several volumes) you switch to MMA (Svanberg 1987), which is what the commercial codes use.

6. The bracket problem

Item Value
Design box 176 × 108.8 × 64 mm, voxels of 3.2 mm. The half model ($y \ge 0$) is 55 × 17 × 20 = 18,700 voxels
Clevis pin Ø 19.2 mm along $y$ at $x = 88$, $z = 44$ mm. Two lugs (passive solid, outer R 19.2 mm, 12.8 mm thick, at 11.2 ≤ |y| ≤ 24 mm), a passive-void slot between them for the mating tongue
Bolts four, at $(x, y) = (18, \pm36)$ and $(158, \pm36)$ mm. Passive-solid bosses (R 12.8 mm, 9.6 mm tall) with Ø 9.6 mm holes, and a passive-void column above each boss for bolt-head and wrench access. Supports: boss bottom faces clamped
Loads (at the pin bore, spread evenly over the bore nodes) vertical 8,000 lbf · horizontal ($+x$) 8,500 lbf · 42° from vertical 9,500 lbf · torsion 5,000 lbf·in about the horizontal $x$ axis (the two lugs pushed in opposite $z$ directions)
Optimiser SIMP $p = 3$, $E_{\min} = 10^{-9}E_0$, filter $r_{\min}$ = 1.5 voxels (4.8 mm), OC with move 0.2, $V^* = 0.08$, 45 iterations
Linear solver pyamg smoothed-aggregation AMG + CG, relative residual $10^{-5}$, warm-started from the previous iteration

Symmetry, including the torsion case. The part is symmetric about $y = 0$, so we mesh half of it. The three force cases are symmetric, so on the mid-plane $u_y = 0$. The torsion case is antisymmetric (mirror the part and the load flips sign), so on the mid-plane $u_x = u_z = 0$ instead. Any load splits into a symmetric and an antisymmetric part, so this decomposition is exact: two sets of supports, two AMG hierarchies, half the unknowns each (61,932 and 60,756). The half model's compliance is half the full part's. All compliances quoted below are for the full part. The per-iteration console log prints the half-model value.

7. The verification problem

A 50 × 10 × 10 mm titanium cantilever ($L/b = 5$, stubby on purpose so that shear matters) is clamped on one end face. It carries a 1 kN uniform shear traction on the other, and we measure the work-conjugate tip deflection $\delta = \mathbf f\cdot\mathbf u/P$ (the traction-weighted mean of $u_z$ over the tip face).

$$\delta_{EB} = \frac{PL^3}{3EI} = 0.4394\ \text{mm},\qquad \delta_{T} = \frac{PL^3}{3EI} + \frac{PL}{\kappa G A} = 0.4532\ \text{mm},\qquad \kappa = \frac{10(1+\nu)}{12+11\nu} = 0.851 \ \text{(Cowper 1966)} .$$

At this aspect ratio shear adds 3.2 % to the deflection.

M&S concepts you'll learn here

Tech stack

Tool Since Role here
Python 3.12 L01 glue
NumPy L01 voxel-mesh index arithmetic (np.indices), element strain energies and stresses (einsum), the OC bisection
Matplotlib L01 verification, solver-benchmark, MBB and convergence figures
SciPy L02 CSR sparse matrices, SuperLU (splu) for 2D and for the direct-solver benchmark, ndimage.correlate as the density filter
SimPy, Mesa, NetworkX L03–L04 not needed: this is continuum mechanics, not events or agents
Numba L05 the assembly scatter-add. It is the one loop with data-dependent writes that NumPy can only do with a slower bincount
imageio-ffmpeg L05 H.264 MP4 (yuv420p). PyVista frames are piped straight into ffmpeg
pandas L07 verification and benchmark tables, history.csv
★ scikit-fem L08 Weak forms written as Python functions, assembled into sparse matrices. It gives us the element stiffness for the optimiser (Hex8 in 3D, Quad4 in 2D), and it is also an independent end-to-end FE pipeline (Hex8 and Hex27, FacetBasis traction, condense) that the fast code is verified against. It is pure NumPy, so it is easy to read, but its generic assembly is slow (a Hex27 mesh with 10k dofs takes about 2 min), which is why the optimiser only borrows $\mathbf k_0$ from it
★ pyamg L08 Smoothed-aggregation algebraic multigrid with a user-supplied near-null space (rigid-body modes), plus its CG with residual history. This makes 3D solves $O(N)$
★ PyVista / VTK L08 ImageData voxels → contour (flying-edges marching cubes) → smooth_taubin → Loop subdivide → off-screen rendering with a three-point light rig and SSAO → STL writer. VTK is the engine inside ParaView, and PyVista is its Pythonic front end
★ STL L08 a triangle soup with normals, the lingua franca of 3D-print slicers. Ours is closed (no open edges), so it slices as a solid

Industry & scientific tools

Tool What it is Who uses it How this project maps to it
Ansys Mechanical (+ Topology Optimization, Ansys Discovery) General-purpose implicit FE. Its topology-optimisation analysis is a density method with manufacturing constraints. Discovery runs it live on the GPU aerospace, automotive, energy Our loop is what its Topology Optimization system runs, on unstructured tets instead of voxels. Its solver switch "Sparse Direct vs PCG" is exactly our LU vs AMG-CG benchmark
Abaqus + Tosca Structure (Dassault) Nonlinear FE, plus Tosca for sensitivity-based (SIMP-like) and condition-based topology optimisation automotive, aero, medical devices Tosca's sensitivity-based run is this code plus stress, frequency and manufacturing constraints. Abaqus's C3D8 locks like our Hex8, while C3D8I (incompatible modes) and C3D20 (serendipity, a cousin of our Hex27) are the cures
Altair OptiStruct / Inspire The first commercial topology-optimisation solver (1994), a SIMP density method with member-size, draw-direction, extrusion and symmetry constraints. Inspire is the designer-facing GUI automotive and aerospace lightweighting OptiStruct's DTPL + MINDIM = our densities + $r_{\min}$. Its symmetry constraint is our half model
nTop (nTopology) Implicit (field-based) CAD for additive manufacturing aerospace AM, medical implants, heat exchangers Takes a density field or STL and makes it printable (smooth, offset, thicken, lattice-fill). Our isosurface + Taubin smoothing is the minimal version of that step
Autodesk Fusion 360 Generative Design Cloud service that runs many optimisations (level-set based) across materials, manufacturing methods and targets, and returns editable geometry product designers, small manufacturers "Generative" = this loop run many times with different $V^*$, materials and constraints, followed by B-rep/T-spline reconstruction instead of an STL, and re-analysis of the smoothed part
COMSOL Multiphysics (Optimization Module) Equation-based multiphysics FE. Density-method TO with a Helmholtz PDE filter and MMA academia, industrial R&D Our cone filter ≈ its Helmholtz filter. Our OC ≈ its MMA restricted to one constraint
FEniCS / dolfinx Python FE framework: weak forms in UFL, compiled kernels, PETSc solvers, MPI research scikit-fem's role here at HPC scale. dolfinx + PETSc GAMG (smoothed aggregation) is the parallel version of our pyamg solve, and dolfin-adjoint automates the sensitivity
deal.II C++ adaptive-FE library (hexahedra, AMG via Trilinos, MPI) research, large-scale TO the same pipeline with adaptive meshes and millions of cores
PETSc TopOpt (Aage et al.) Open-source parallel C++ TO framework: SIMP + density filter + MMA + multigrid-preconditioned Krylov research; the 1.1-billion-voxel aircraft wing (Nature 2017) literally this algorithm (voxels, SIMP, filter, MG-CG) on 8,000 cores
ParaView VTK GUI for large scientific data everyone who post-processes simulations Our contour → smooth_taubin → render chain is four ParaView filters in a script

What the commercial tools add on top of this script: CAD-conforming unstructured meshes; manufacturing constraints (additive overhang angle, casting draw direction, milling access, minimum and maximum member size, symmetry and patterns); stress, displacement, frequency and buckling constraints; general-purpose optimisers (MMA, SQP); geometry reconstruction from the density field into editable CAD; and, most importantly, re-analysis of the reconstructed part. The smoothed geometry is a new design and has to be verified again with a body-fitted mesh before anyone signs off.

Results

The bracket

von Mises stress envelope over the four load cases on the optimised bracket, two views

The optimiser found a pair of A-frames. Each lug sends one leg down to each of the two bolts on its side, so the pin sits at the apex of both frames. Three things are worth noticing:

Optimisation history

Compliance per load case, volume/greyness/change and solver effort per iteration

iteration 1 10 20 45 (final)
total compliance, N·mm 4,248,137 15,163 14,377 14,192
vertical / horizontal / 42° / torsion 3,843 / 3,088 / 4,100 / 3,160
volume fraction 0.083 0.0800 0.0800 0.0800
greyness $\overline{4\tilde\rho(1-\tilde\rho)}$ 19 % 5.8 % 5.4 % 5.3 %
AMG-CG iterations per load case 27 11 9 8

The first iterate is a uniform grey cloud that the four loads can barely hold up. Within ten iterations the legs exist, and the last 25 iterations only refine the shape: compliance falls by 0.23 % over the last 10. The design change $\max|\Delta\rho|$ never drops below the classic 0.01 threshold. OC with a 0.2 move limit keeps shuffling the surface voxels of a design whose objective has stopped moving, which is normal for 3D problems at this resolution. The CG work per load case drops from 27 iterations to 8, thanks to warm starts from the previous design and a crisper, better-conditioned design. The time per design iteration drops from 6.5 s to 2.8 s.

SIMP and the filter, one ingredient at a time (2D MBB beam)

MBB beam: p = 1 gives grey, p = 3 without a filter gives checkerboards, p = 3 with the density filter gives a clean truss

Run (120 × 40 half beam, mirrored for display) compliance grey checkerboard index
$p = 1$, filter $r_{\min} = 2.4$ 153.2 57 % –
$p = 3$, no filter 185.4 0.2 % 0.099
$p = 3$, filter $r_{\min} = 2.4$ 194.6 23 % 0.014

The ranking of compliances is the lesson. The "best" number belongs to the unphysical grey design and the second best to the checkerboard. Both beat the clean truss only by exploiting modelling artefacts. The checkerboard index is the mean mixed second difference $|\rho_{i,j} - \rho_{i+1,j} - \rho_{i,j+1} + \rho_{i+1,j+1}|/2$, which is 1 for a perfect checkerboard and 0 for smooth fields.

Solvers: sparse LU vs Jacobi-CG vs AMG-CG

Solve time, iteration counts, LU fill-in and residual histories on the final SIMP matrix

Clamped solid $n^3$ cube, tip shear, relative residual $10^{-8}$, wall-clock on a shared 8-core machine:

$n$ unknowns sparse LU LU fill nnz(L+U)/nnz(K) Jacobi-CG its / time AMG-CG its / time (setup)
8 1,944 0.02 s 5.4 72 / 0.008 s 10 / 0.02 s (0.01)
12 6,084 0.19 s 9.7 108 / 0.03 s 11 / 0.09 s (0.04)
16 13,872 1.17 s 14.4 144 / 0.14 s 9 / 0.23 s (0.12)
20 26,460 4.07 s 18.9 180 / 0.32 s 11 / 0.57 s (0.26)
26 56,862 – – 235 / 0.89 s 13 / 1.11 s (0.50)
32 104,544 – – 289 / 1.75 s 12 / 1.94 s (0.91)
40 201,720 – – 362 / 4.43 s 12 / 4.35 s (2.29)

Two things tried during development, and why they are not in the code:

Verification: cantilever vs beam theory

Tip deflection vs dofs for Hex8 and Hex27 against Euler-Bernoulli and Timoshenko, and successive-refinement differences

Model elements through thickness dofs tip deflection vs Timoshenko vs Euler–Bernoulli
scikit-fem Hex27 (triquadratic) 1 297 0.4350 mm −4.0 % −1.0 %
scikit-fem Hex27 2 1,575 0.4423 mm −2.4 % +0.7 %
scikit-fem Hex8 (trilinear) 2 297 0.3795 mm −16.3 % −13.6 %
scikit-fem Hex8 4 1,575 0.4249 mm −6.2 % −3.3 %
voxel code path (Hex8, Numba + AMG-CG) 2 / 4 297 / 1,575 identical to scikit-fem (2×10⁻¹³)
voxel code path 8 9,963 0.4395 mm −3.0 % +0.0 %
voxel code path 16 70,227 0.4438 mm −2.1 % +1.0 %

All checks (default run)

[PASS] voxel/Numba/AMG path reproduces scikit-fem: max rel. diff 2.2e-13 (tol 1e-6)
[PASS] Hex8, n = 16 vs Timoshenko: 0.4438 mm vs 0.4532 mm (-2.07 %, tol 3 %)
[PASS] Hex8, n = 16 vs Euler-Bernoulli: 0.4438 mm vs 0.4394 mm (+1.01 %, tol 3 %)
[PASS] Hex27, n = 2 vs Timoshenko: 0.4423 mm vs 0.4532 mm (-2.41 %, tol 3 %)
[PASS] Hex27, n = 2 vs Euler-Bernoulli: 0.4423 mm vs 0.4394 mm (+0.67 %, tol 3 %)
[PASS] Hex8 converges monotonically from below: observed order [1.64 1.76] (-> 2 for trilinear hexes)
[PASS] AMG-CG agrees with sparse LU: max rel. diff 2.2e-10
[PASS] volume constraint met: 0.0800 vs 0.08
[PASS] compliance history converged: 0.23 % change over the last 10 iterations
[PASS] STL is watertight: 27184 triangles, no open edges
[PASS] density filter kills checkerboards: checkerboard index 0.099 unfiltered vs 0.014 filtered
[PASS] penalisation p = 3 removes grey: greyness 57 % (p=1) vs 0.2 % (p=3 raw) and 23 % (p=3 filtered: the filter's own blur band)

Run it

From the repo root:

uv run python L08_generative_bracket/sim.py

Runtime: 215 s (3.6 min) for the logged run below. An earlier identical run took 291 s while other jobs had the CPU busy. The split is verification 9 s, solver benchmark 27 s, 2D MBB 9 s, bracket optimisation 151 s (45 iterations at 2.8–6.5 s each) plus 11 s of checks, and rendering 8 s. Threads are capped at 3 (OMP_NUM_THREADS, NUMBA_NUM_THREADS), although nearly everything here is single-threaded C++ anyway (pyamg, SuperLU, sparse mat-vecs). Everything is deterministic, so the numbers (not the timings) repeat exactly. To go faster or finer, change H_VOX (voxel size, mm) and ITERS near the bottom of sim.py. A 2.67 mm grid (31,680 half-model voxels) looks smoother but takes about 1.5–2× longer.

It writes to output/:

File What
hero.png the bracket with pin, bolts, design box and load arrows
turntable.mp4 360° turntable, 1280×720, 4 s
evolution.mp4 45 design iterations with the low-density cloud, compliance and volume
von_mises.png von Mises envelope over the four load cases, two views
bracket.stl the printable part (binary STL, mm, 27,184 triangles, watertight)
convergence.png, history.csv per-iteration compliance (full part) per load case, volume, greyness, CG iterations, seconds
verification.png cantilever tip deflection vs dofs, successive-refinement differences
solver_benchmark.png LU vs Jacobi-CG vs AMG-CG, fill-in, residual histories on the SIMP matrix
simp_filter_2d.png the MBB beam three ways
Console output of the default run (abridged)
1/5 Verification: clamped 3D cantilever vs beam theory
                          model  n_thick  tip_mm  dofs  cg_its  vs_timoshenko_pct  vs_euler_bernoulli_pct
scikit-fem Hex27 (triquadratic)        1 0.43502   297       0           -4.01496                -0.98912
scikit-fem Hex27 (triquadratic)        2 0.44230  1575       0           -2.40845                 0.66803
    scikit-fem Hex8 (trilinear)        2 0.37952   297       0          -16.26042               -13.62061
    scikit-fem Hex8 (trilinear)        4 0.42490  1575       0           -6.24828                -3.29285
 voxel code path (Hex8, AMG-CG)        2 0.37952   297       1          -16.26042               -13.62061
 voxel code path (Hex8, AMG-CG)        4 0.42490  1575      14           -6.24828                -3.29285
 voxel code path (Hex8, AMG-CG)        8 0.43951  9963      16           -3.02520                 0.03183
 voxel code path (Hex8, AMG-CG)       16 0.44382 70227      19           -2.07360                 1.01343
2/5 Solver benchmark: sparse LU vs Jacobi-CG vs AMG-CG     (9 s)
  n =  8    1944 dofs  ... amg_its 10  jacobi_its 72   lu_s 0.0213  lu_fill 5.38
  n = 20   26460 dofs  ... amg_its 11  jacobi_its 180  lu_s 4.07    lu_fill 18.9
  n = 40  201720 dofs  ... amg_s 4.35  amg_its 12  jacobi_s 4.43  jacobi_its 362
3/5 SIMP and the density filter on the 2D MBB beam     (36 s)
4/5 Bracket topology optimisation     (45 s)
  half model: (55, 17, 20) voxels of 3.2 mm = 18700 elements; symmetric system 61932 dofs x 3 load cases + antisymmetric system 60756 dofs x 1
  bracket it   1  C = 2.1241e+06  vol = 0.0830  change = 0.162  cg its = [27, 28, 27, 27]  6.53 s
  bracket it  10  C = 7581.7  vol = 0.0800  change = 0.200  cg its = [11, 11, 11, 12]  3.41 s
  bracket it  45  C = 7095.8  vol = 0.0800  change = 0.058  cg its = [8, 7, 8, 8]  2.81 s      <- half model
  final SIMP matrix: AMG-CG 23 its / 1.7 s;  Jacobi-CG 577 its / 2.2 s, final residual 9.1e-09, error vs AMG 1.0e-06
  compliance: optimised 14192 N mm vs solid box 1694 N mm (x8.38 for 8 % of the material)
5/5 Rendering (PyVista) and STL     (207 s)
  STL: 27184 triangles, 79.2 cm^3 (6.5 % of the 1226 cm^3 box) = 351 g of Ti-6Al-4V
  von Mises envelope on the surface: 99th pct 192 MPa, max 221 MPa
Verification
  ... 12 x [PASS], see above
Total runtime 215 s

Code tour

Everything is in sim.py (about 800 lines, roughly a third of them rendering and plotting).

Function What it does
voxel_fem(shape, h, E, nu) Structured 2D/3D voxel mesh (node ids via np.indices) and the element stiffness $\mathbf k_0$ from scikit-fem, integrated on one voxel and re-ordered from scikit-fem's global to local dof numbering
set_supports(fe, fixed) A system = mesh + supports. Removes fixed dofs and precomputes the CSR pattern and the slot of each of the $24^2$ entries of every element (np.unique on row·N + col)
scatter (Numba), assemble The assembly: data[slot] += E_e * k0. Then a CSR matrix is wrapped around the fixed pattern
rigid_body_modes, amg, pcg, solve Near-null space, pyamg smoothed aggregation (sparse-LU coarse solve), CG with residual history, and multi-load-case solving with warm starts
von_mises Element-centre strain from the trilinear shape-function gradients, then solid-material von Mises stress
topopt(fe, cases, volfrac, rmin, ...) The optimiser. Cone filter as ndimage.correlate plus its adjoint, SIMP stiffness, solves for every (system, load-case) pair, self-adjoint sensitivities, OC with bisection on λ, history and snapshots. The same function runs the 2D MBB beams and the 3D bracket
bracket(h) The GE-style problem: passive solids and voids from centroid geometry, clamped boss faces, symmetric and antisymmetric mid-plane conditions, pin-bore loads in N (lbf converted)
beam_theory, cantilever_skfem, cantilever_voxel, verify_cantilever Verification: closed forms, scikit-fem end to end (Hex8/Hex27, FacetBasis traction, condense), and the same problem through the optimiser's code path
solver_benchmark, design_matrix_check LU vs Jacobi-CG vs AMG-CG on cubes, and on the final SIMP matrix; solid-box compliance for scale
mbb(penal, rmin), checkerboard The 2D ablation (p = 1, no filter, filter) and the checkerboard metric
to_grid, part_surface Mirror the half model, pad, cell → point data, contour at 0.5, Taubin smooth, Loop subdivide, carry the stress field
studio, look, hardware, envelope, load_arrows, render_bracket, write_mp4 PyVista scene (camera-attached key/fill/rim lights, SSAO), pin and bolts, design box, load arrows; hero, turntable, evolution, stress views, STL
plot_*, verify, main Matplotlib figures, the 12 asserted checks, orchestration

Simplifications & ceilings

Go further

  1. Crisp designs. Add a Heaviside projection $\bar\rho = \frac{\tanh(\beta\eta) + \tanh(\beta(\tilde\rho-\eta))}{\tanh(\beta\eta) + \tanh(\beta(1-\eta))}$ with $\beta$ doubled every 20 iterations (Wang, Lazarov & Sigmund 2011), and compare greyness, STL mass and compliance with this run.
  2. Stress-constrained design. Replace OC with MMA (Svanberg 1987, e.g. the nlopt or mmapy implementations), add a p-norm von Mises constraint with relaxed stresses (Le et al. 2010), and watch the lug crowns and leg roots thicken.
  3. Print-ready. Add Langelaar's (2017) additive-manufacturing overhang filter for a chosen build direction, then slice bracket.stl in PrusaSlicer or Cura and compare support volume with and without it.
  4. Scale up. Replace pyamg with a matrix-free geometric multigrid on the voxel hierarchy (Amir, Aage & Lazarov 2014), or run the same problem in PETSc TopOpt, and push to 10⁶–10⁷ voxels. Rerun the solver benchmark to find where Jacobi-CG finally loses in wall time.
  5. Rebuild it in an industry tool. Load the four load cases into Altair Inspire (free student edition) or Fusion 360 Generative Design with the same keep-in/keep-out regions and 8 % volume target, and compare topology, mass and compliance. Then import our STL into Ansys Mechanical or Abaqus, mesh it with quadratic tets (C3D10) and see how far the voxel-level stresses were from the body-fitted ones.

References

Every output

Source code

sim.py 811 lines
"""L08 - Generative Bracket.

3D linear-elasticity FEM on a voxel hex mesh (element stiffness from scikit-fem, assembled by a
Numba scatter), solved with pyamg-preconditioned CG, driving SIMP topology optimisation
(density filter + optimality criteria).  The showcase is a titanium engine-mount bracket loosely
modelled on the GE / GrabCAD 2013 jet-engine bracket challenge, rendered with PyVista and exported
as a 3D-printable STL.

Run from the repo root:  uv run python L08_generative_bracket/sim.py
"""
import os
os.environ.setdefault("OMP_NUM_THREADS", "3")    # CPU etiquette: shared machine
os.environ.setdefault("NUMBA_NUM_THREADS", "3")

import time
from pathlib import Path
from types import SimpleNamespace

import numpy as np
import scipy.sparse as sp
from scipy.ndimage import correlate
from scipy.sparse.linalg import splu
import imageio_ffmpeg
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numba
import pandas as pd
import pyamg
import pyvista as pv
from skfem import (Basis, ElementHex1, ElementHex2, ElementQuad1, ElementVector, FacetBasis,
                   MeshHex, MeshQuad, asm, condense, LinearForm)
from skfem.models.elasticity import lame_parameters, linear_elasticity

OUT = Path(__file__).parent / "output"

# Ti-6Al-4V (additively manufactured titanium, the GE challenge material).  Units: mm, N, MPa.
E_TI, NU_TI, RHO_TI = 113_800.0, 0.342, 4.43e-3   # MPa, -, g/mm^3
LBF = 4.4482216                                    # N per pound-force


# ----------------------------------------------------------------------------------------------------
# FEM on a structured voxel grid
# ----------------------------------------------------------------------------------------------------
def voxel_fem(shape, h, E, nu):
    """Voxel mesh (2D quads or 3D hexes) plus its element stiffness matrix KE.
    scikit-fem does the weak-form integration on ONE element; every voxel is an identical
    translated copy, so KE is shared and assembly is a pure scatter (see `assemble`)."""
    d = len(shape)
    Mesh, Elem = (MeshQuad, ElementQuad1) if d == 2 else (MeshHex, ElementHex1)
    m1 = Mesh.init_tensor(*[np.array([0.0, h])] * d)
    b1 = Basis(m1, ElementVector(Elem()))
    e0 = b1.element_dofs[:, 0]                       # asm numbers dofs globally; reorder to local
    KE = asm(linear_elasticity(*lame_parameters(E, nu)), b1).toarray()[np.ix_(e0, e0)]
    off = np.rint(m1.p[:, m1.t[:, 0]].T / h).astype(int)       # local node offsets, skfem order
    nshape = tuple(s + 1 for s in shape)
    nid = np.arange(np.prod(nshape)).reshape(nshape)
    ijk = np.indices(shape).reshape(d, -1)
    t = np.stack([nid[tuple(ijk + o[:, None])] for o in off], axis=1)   # (nel, 2^d) node ids
    edof = (d * t[:, :, None] + np.arange(d)).reshape(len(t), -1)       # node-major, like skfem
    X = np.indices(nshape).reshape(d, -1).T * h                         # node coordinates
    return SimpleNamespace(d=d, shape=tuple(shape), h=h, E=E, nu=nu, KE=KE, off=off, edof=edof,
                           t=t, X=X, ndof=d * len(X), nel=len(t))


def set_supports(fe, fixed):
    """A solvable system = mesh + supports.  Removes the fixed dofs and precomputes where every KE
    entry lands in the reduced CSR matrix, so assembly is one scatter per design iteration."""
    fe = SimpleNamespace(**vars(fe))
    free = np.setdiff1d(np.arange(fe.ndof), fixed)
    nf = len(free)
    rid = np.full(fe.ndof, -1)
    rid[free] = np.arange(nf)
    r = rid[fe.edof]
    n = r.shape[1]
    ri, rj = np.repeat(r, n, axis=1).ravel(), np.tile(r, (1, n)).ravel()
    key = np.where((ri >= 0) & (rj >= 0), ri * nf + rj, -1)
    uniq, slot = np.unique(key, return_inverse=True)
    if uniq[0] < 0:                                  # entries touching a fixed dof -> slot -1
        uniq, slot = uniq[1:], slot - 1
    fe.free, fe.nf, fe.slot = free, nf, slot.astype(np.int32)
    fe.indices = (uniq % nf).astype(np.int32)
    fe.indptr = np.concatenate([[0], np.cumsum(np.bincount(uniq // nf, minlength=nf))])
    fe.B = rigid_body_modes(fe.X, fe.d)[free]
    return fe


def rigid_body_modes(X, d):
    """Near-null space of elasticity (translations + rotations): what smoothed-aggregation AMG must
    reproduce exactly on every coarse level to converge fast."""
    n = len(X)
    B = np.zeros((d * n, 3 if d == 2 else 6))
    for c in range(d):
        B[c::d, c] = 1.0
    rot = [(0, 1)] if d == 2 else [(0, 1), (1, 2), (2, 0)]
    for k, (a, b) in enumerate(rot):
        B[a::d, d + k], B[b::d, d + k] = -X[:, b], X[:, a]
    return B


@numba.njit(cache=True)
def scatter(slot, Ee, KEf, nnz):
    """Assembly: K.data[slot] += E_e * KE for every element (the classic FE scatter-add)."""
    data = np.zeros(nnz)
    n = KEf.size
    for e in range(Ee.size):
        for k in range(n):
            s = slot[e * n + k]
            if s >= 0:
                data[s] += Ee[e] * KEf[k]
    return data


def assemble(fe, Ee):
    data = scatter(fe.slot, Ee, fe.KE.ravel(), len(fe.indices))
    return sp.csr_matrix((data, fe.indices, fe.indptr), shape=(fe.nf, fe.nf))


def solve(fe, K, F, U0=None, rtol=1e-5):
    """K U = F for several load cases. 2D: sparse LU. 3D: CG preconditioned by smoothed-aggregation
    AMG (one hierarchy shared by all load cases), warm-started from U0 (the previous design).
    Returns U and the CG iteration count per load case."""
    if fe.d == 2:
        return splu(K.tocsc()).solve(F), [0]
    M = amg(K, fe.B).aspreconditioner()
    U = np.zeros_like(F) if U0 is None else U0.copy()
    its = []
    for k in range(F.shape[1]):
        U[:, k], its_k = pcg(K, F[:, k], M, U[:, k], rtol, 400)
        its.append(its_k)
    return U, its


def amg(K, B):
    """Smoothed-aggregation AMG hierarchy.  B = near-null space (rigid-body modes); the coarsest
    level is solved by sparse LU."""
    return pyamg.smoothed_aggregation_solver(K, B=B, max_coarse=500, improve_candidates=None,
                                             coarse_solver="splu")


def pcg(K, f, M, x0=None, rtol=1e-8, maxiter=2000, history=False):
    """Preconditioned CG to ||r|| <= rtol ||f||.  Returns x and the iteration count (or the
    relative residual history)."""
    res = []
    x, _ = pyamg.krylov.cg(K, f, x0=x0, tol=rtol, maxiter=maxiter, M=M, residuals=res)
    return x, (np.array(res) / np.linalg.norm(f) if history else len(res) - 1)


def jacobi(K):
    return sp.diags(1.0 / K.diagonal())


def full(fe, U):
    Uf = np.zeros((fe.ndof,) + U.shape[1:])
    Uf[fe.free] = U
    return Uf


def von_mises(fe, Uf):
    """Element von Mises stress of SOLID material at the element centre (trilinear hex)."""
    G = (2 * fe.off - 1) / (4 * fe.h)                                  # dN_a/dx_j at the centre
    Ue = Uf[fe.edof].reshape(fe.nel, 2 ** fe.d, fe.d)
    gu = np.einsum("eai,aj->eij", Ue, G)
    eps = 0.5 * (gu + gu.transpose(0, 2, 1))
    lam, mu = lame_parameters(fe.E, fe.nu)
    tr = np.trace(eps, axis1=1, axis2=2)
    s = 2 * mu * (eps - tr[:, None, None] * np.eye(3) / 3)            # deviatoric stress
    return np.sqrt(1.5 * (s * s).sum((1, 2)))


# ----------------------------------------------------------------------------------------------------
# SIMP topology optimisation: density filter + optimality criteria
# ----------------------------------------------------------------------------------------------------
def topopt(fe, cases, volfrac, rmin, penal=3.0, solid=None, void=None, iters=60, move=0.2, tol=0.01,
           emin=1e-9, label=""):
    """Minimise total compliance sum_k F_k.U_k  s.t.  mean(xPhys) = volfrac,  0 <= x <= 1.
    `cases` is a list of (system, F): each system (supports) with its load cases as columns of F."""
    shape, d, nel = fe.shape, fe.d, fe.nel
    solid = np.zeros(nel, bool) if solid is None else solid
    void = np.zeros(nel, bool) if void is None else void
    passive = solid | void
    x = np.where(solid, 1.0, 0.0)
    x[~passive] = (volfrac * nel - solid.sum()) / (~passive).sum()

    R = int(np.ceil(rmin)) - 1                                         # cone filter kernel
    k = np.indices((2 * R + 1,) * d) - R
    w = np.maximum(0.0, rmin - np.sqrt((k ** 2).sum(0)))
    Hs = correlate(np.ones(shape), w, mode="constant")

    def phys(v):
        xp = (correlate(v.reshape(shape), w, mode="constant") / Hs).ravel()
        xp[solid], xp[void] = 1.0, 0.0
        return xp

    def phys_T(g):                                                    # adjoint of the filter
        return correlate((g * ~passive).reshape(shape) / Hs, w, mode="constant").ravel()

    KE, Us, hist, snaps = fe.KE, [None] * len(cases), [], []
    for it in range(1, iters + 1):
        t0 = time.perf_counter()
        xp = phys(x)
        Ee = emin + xp ** penal * (1 - emin)
        ce, its = [], []
        for j, (sy, F) in enumerate(cases):
            Us[j], n = solve(sy, assemble(sy, Ee), F, Us[j])
            Ue = full(sy, Us[j])[fe.edof]                              # (nel, n_ke, n_load)
            ce.append(np.einsum("eil,ij,ejl->el", Ue, KE, Ue, optimize=True))   # 2 x strain energy
            its += n
        ce = np.concatenate(ce, axis=1)
        comp = (Ee[:, None] * ce).sum(0)
        dc = phys_T(-penal * xp ** (penal - 1) * (1 - emin) * ce.sum(1))
        dv = phys_T(np.ones(nel))
        l1, l2 = 0.0, (-dc / np.maximum(dv, 1e-30)).max()             # OC: bisect the multiplier
        while (l2 - l1) > 1e-4 * (l1 + l2):
            lm = 0.5 * (l1 + l2)
            xn = np.clip(x * np.sqrt(np.maximum(-dc, 0) / (dv * lm + 1e-300)), x - move, x + move)
            xn = np.clip(xn, 0.0, 1.0)
            xn[solid], xn[void] = 1.0, 0.0
            l1, l2 = (lm, l2) if phys(xn).mean() > volfrac else (l1, lm)
        change = np.abs(xn - x).max()
        x = xn
        hist.append(dict(it=it, compliance=comp.sum(), **{f"c{j}": c for j, c in enumerate(comp)},
                         volume=xp.mean(), change=change, cg_its=sum(its),
                         grey=4 * (xp * (1 - xp)).mean(), sec=time.perf_counter() - t0))
        snaps.append(xp.astype(np.float32))
        if d == 3 or it % 20 == 0:
            print(f"  {label} it {it:3d}  C = {comp.sum():.5g}  vol = {xp.mean():.4f}  "
                  f"change = {change:.3f}  cg its = {its}  {hist[-1]['sec']:.2f} s", flush=True)
        if change < tol:
            break
    return xp, hist, snaps, Us         # last ANALYSED design, consistent with Us


# ----------------------------------------------------------------------------------------------------
# The showcase: a jet-engine bracket loosely after the GE / GrabCAD 2013 challenge
# ----------------------------------------------------------------------------------------------------
HALF = (176.0, 54.4, 64.0)             # half design box [mm] (y >= 0); the part is mirrored at y = 0
PIN = (88.0, 44.0)                     # pin axis (x, z), runs along y
R_PIN, R_LUG, LUG_Y = 9.6, 19.2, (11.2, 24.0)   # pin-hole and lug radius, lug span in y
BOLTS = [(18.0, 36.0), (158.0, 36.0)]  # (x, y) of the bolts; mirrored at -y -> four bolts
R_HOLE, R_BOSS, T_BOSS = 4.8, 12.8, 9.6
# The four GE challenge load cases at the pin: (direction, magnitude in lbf, or lbf*in for torsion).
# Torsion is a moment about the horizontal x axis: the two lugs are pushed in opposite z directions.
LOADS = {"vertical 8000 lbf": ((0, 0, 1), 8000.0),
         "horizontal 8500 lbf": ((1, 0, 0), 8500.0),
         "42 deg 9500 lbf": ((np.sin(np.radians(42)), 0, np.cos(np.radians(42))), 9500.0),
         "torsion 5000 lbf in": ("torsion", 5000.0)}


def bracket(h):
    """Half model (y >= 0): design box, passive solids (clevis lug, bolt bosses), passive voids
    (holes, clearances), supports and loads.  The part is symmetric about y = 0, so
      - the three force cases are symmetric:      mid-plane u_y = 0,
      - the torsion case is antisymmetric:        mid-plane u_x = u_z = 0,
    and together they are solved exactly on half the mesh (two systems, two AMG hierarchies)."""
    fe = voxel_fem(tuple(int(round(s / h)) for s in HALF), h, E_TI, NU_TI)
    c = (np.indices(fe.shape).reshape(3, -1).T + 0.5) * h           # element centroids
    rp = np.hypot(c[:, 0] - PIN[0], c[:, 2] - PIN[1])
    rb = np.min([np.hypot(c[:, 0] - bx, c[:, 1] - by) for bx, by in BOLTS], axis=0)
    lug = (c[:, 1] > LUG_Y[0]) & (c[:, 1] < LUG_Y[1]) & (rp > R_PIN) & (rp < R_LUG)
    pinhole = rp < R_PIN
    boss = (rb > R_HOLE) & (rb < R_BOSS) & (c[:, 2] < T_BOSS)
    solid = lug | boss
    void = (pinhole | (rb < R_HOLE)
            | ((c[:, 1] < LUG_Y[0]) & (rp < R_LUG + h))            # room for the mating tongue
            | ((rb < R_BOSS - h) & (c[:, 2] > T_BOSS)))             # bolt-head / wrench access
    base = np.unique(fe.t[boss])
    base = base[fe.X[base, 2] == 0]                                  # boss bottom faces: clamped
    base = (3 * base[:, None] + np.arange(3)).ravel()
    mid = np.flatnonzero(fe.X[:, 1] == 0)
    sym = set_supports(fe, np.concatenate([base, 3 * mid + 1]))
    anti = set_supports(fe, np.concatenate([base, 3 * mid, 3 * mid + 2]))
    pin = np.intersect1d(fe.t[lug], fe.t[pinhole])                  # nodes on the pin-hole bore
    Fs, Ft = np.zeros((fe.ndof, 3)), np.zeros((fe.ndof, 1))
    for k, (direction, mag) in enumerate(LOADS.values()):
        if direction == "torsion":        # full part: M = sum y_i f_z,i over both lugs = 2 x this half
            Ft[3 * pin + 2, 0] = mag * LBF * 25.4 / (2 * fe.X[pin, 1].sum())
        else:                             # this half carries half the force
            Fs[3 * pin[:, None] + np.arange(3), k] = 0.5 * mag * LBF / len(pin) * np.array(direction)
    return fe, [(sym, Fs[sym.free]), (anti, Ft[anti.free])], solid, void


# ----------------------------------------------------------------------------------------------------
# Verification: a clamped 3D cantilever vs Euler-Bernoulli and Timoshenko beam theory
# ----------------------------------------------------------------------------------------------------
BEAM = dict(L=50.0, b=10.0, P=1000.0)   # mm, mm (square section), N; L/b = 5 so shear shows


def beam_theory(L, b, P, E=E_TI, nu=NU_TI):
    I, A, G = b ** 4 / 12, b * b, E / (2 * (1 + nu))
    kappa = 10 * (1 + nu) / (12 + 11 * nu)             # Cowper (1966), rectangular section
    eb = P * L ** 3 / (3 * E * I)
    return eb, eb + P * L / (kappa * G * A)


def cantilever_skfem(n, Elem, L, b, P):
    """scikit-fem end to end: mesh -> basis -> bilinear form -> traction -> condense -> solve.
    n elements through the thickness, 5n along the span; uniform shear traction on the tip face.
    Returns the tip deflection (work-conjugate mean of u_z over the tip face) and the dof count."""
    m = MeshHex.init_tensor(np.linspace(0, L, round(L / b) * n + 1), *[np.linspace(0, b, n + 1)] * 2)
    e = ElementVector(Elem())
    basis = Basis(m, e)
    K = asm(linear_elasticity(*lame_parameters(E_TI, NU_TI)), basis)
    tip = FacetBasis(m, e, facets=m.facets_satisfying(lambda x: np.isclose(x[0], L)))
    f = asm(LinearForm(lambda v, w: -P / b ** 2 * v.value[2]), tip)
    D = basis.get_dofs(lambda x: np.isclose(x[0], 0.0)).all()
    A, rhs, u, I = condense(K, f, D=D)
    u[I] = splu(A.tocsc()).solve(rhs)
    return f @ u / P, K.shape[0]


def cantilever_voxel(n, L, b, P, rtol=1e-10):
    """The SAME problem through the topology-optimisation code path (own mesh, Numba assembly,
    AMG-CG).  Consistent nodal loads for a uniform traction: weight = number of tip-face elements
    touching the node / 4."""
    h = b / n
    fe = voxel_fem((round(L / b) * n, n, n), h, E_TI, NU_TI)
    root = np.flatnonzero(np.isclose(fe.X[:, 0], 0))
    fe = set_supports(fe, (3 * root[:, None] + np.arange(3)).ravel())
    tipn = np.flatnonzero(np.isclose(fe.X[:, 0], L))
    edge = lambda v: np.isclose(v, 0) | np.isclose(v, b)
    wgt = 1.0 / (1 + edge(fe.X[tipn, 1])) / (1 + edge(fe.X[tipn, 2]))  # 1, 1/2 edge, 1/4 corner
    F = np.zeros(fe.ndof)
    F[3 * tipn + 2] = -P * wgt / wgt.sum()
    U, its = solve(fe, assemble(fe, np.ones(fe.nel)), F[fe.free][:, None], rtol=rtol)
    return F[fe.free] @ U[:, 0] / P, fe.ndof, its[0]


def verify_cantilever():
    """Hex27 at n = 1, 2 and Hex8 at n = 2, 4 through scikit-fem's own (slow, generic) assembly;
    Hex8 up to n = 16 through the fast voxel path.  scikit-fem Hex27 at n = 4 is the best
    reference but takes ~2 min in its Python-level assembly, so it is left out of the default run."""
    L, b, P = BEAM["L"], BEAM["b"], BEAM["P"]
    eb, timo = beam_theory(L, b, P)
    rows = [("scikit-fem Hex27 (triquadratic)", n, *cantilever_skfem(n, ElementHex2, L, b, P), 0)
            for n in (1, 2)]
    rows += [("scikit-fem Hex8 (trilinear)", n, *cantilever_skfem(n, ElementHex1, L, b, P), 0)
             for n in (2, 4)]
    rows += [("voxel code path (Hex8, AMG-CG)", n, *cantilever_voxel(n, L, b, P)) for n in (2, 4, 8, 16)]
    df = pd.DataFrame(rows, columns=["model", "n_thick", "tip_mm", "dofs", "cg_its"])
    df["vs_timoshenko_pct"] = 100 * (df.tip_mm / timo - 1)
    df["vs_euler_bernoulli_pct"] = 100 * (df.tip_mm / eb - 1)
    return df, eb, timo


# ----------------------------------------------------------------------------------------------------
# 3D visualisation (PyVista / VTK) and STL export
# ----------------------------------------------------------------------------------------------------
def to_grid(h, **cell_fields):
    """Mirror the half model about y = 0, pad with one layer of void (so every isosurface is
    closed) and wrap as a VTK ImageData with cell data (VTK wants x fastest = Fortran order)."""
    grid = None
    for name, v in cell_fields.items():
        v = np.pad(np.concatenate([v[:, ::-1, :], v], axis=1), 1)
        if grid is None:
            grid = pv.ImageData(dimensions=np.array(v.shape) + 1, spacing=(h, h, h),
                                origin=(-h, -v.shape[1] / 2 * h, -h))
        grid.cell_data[name] = v.ravel(order="F")
    return grid


def part_surface(x3, h, level=0.5, smooth=60, vm=None, largest=True):
    """Isosurface rho = level of the (cell -> point averaged) density, Taubin-smoothed (smoothing
    without shrinkage), one Loop subdivision for round highlights, largest connected piece only.
    Optional von Mises field rides along as a point field (density-weighted, so the void cells
    next to the surface do not drag its value down)."""
    fields = dict(rho=x3) if vm is None else dict(rho=x3, rvm=x3 * vm)
    g = to_grid(h, **fields).cell_data_to_point_data()
    if vm is not None:
        g.point_data["vm"] = g.point_data["rvm"] / np.maximum(g.point_data["rho"], 1e-3)
    s = g.contour([level], scalars="rho")
    s = (s.connectivity("largest") if largest else s).extract_surface(algorithm="dataset_surface")
    s = s.triangulate().clean()
    if smooth:
        s = s.smooth_taubin(n_iter=smooth, pass_band=0.05).subdivide(1, subfilter="loop")
    return s.compute_normals(auto_orient_normals=True)


def look(p, center, azim, elev, dist):
    """Camera on a sphere around `center` (degrees; z is up)."""
    a, e = np.radians(azim), np.radians(elev)
    p.camera.focal_point = center
    p.camera.position = np.asarray(center) + dist * np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
    p.camera.up = (0, 0, 1)


def studio(size):
    """Dark gradient background, a camera-attached three-point rig (warm key, cool fill, rim) and
    screen-space ambient occlusion for contact shading in the creases."""
    p = pv.Plotter(off_screen=True, window_size=size, lighting="none")
    p.set_background("#040509", top="#1a2233")
    for pos, colour, i in [((-1.0, 1.2, 1.4), "#fff1e0", 0.95), ((1.5, -0.2, 0.8), "#a8c0ff", 0.45),
                           ((0.3, 1.5, -1.2), "#ffffff", 0.55)]:
        p.add_light(pv.Light(position=pos, focal_point=(0, 0, 0), color=colour, intensity=i,
                             light_type="camera light"))
    p.enable_ssao(radius=8, bias=0.5)
    return p


def envelope():
    """Outline of the full design box."""
    return pv.Box(bounds=(0, HALF[0], -HALF[1], HALF[1], 0, HALF[2])).outline()


def load_arrows(length=38.0):
    """The three force cases as arrows at the +y end of the pin (torsion is not drawn)."""
    start = np.array([PIN[0], LUG_Y[1] + 9, PIN[1]])
    return [pv.Arrow(start=start, direction=d, scale=length, tip_length=0.3, tip_radius=0.09,
                     shaft_radius=0.035)
            for d, _ in list(LOADS.values())[:3]]


def hardware():
    """Pin and bolts as context (not part of the design)."""
    pin = pv.Cylinder(center=(PIN[0], 0, PIN[1]), direction=(0, 1, 0), radius=0.97 * R_PIN,
                      height=2 * LUG_Y[1] + 14, resolution=48)
    bolts = [pv.Cylinder(center=(bx, s * by, T_BOSS / 2 - 3), direction=(0, 0, 1), radius=0.97 * R_HOLE,
                         height=T_BOSS + 10, resolution=32) for bx, by in BOLTS for s in (-1, 1)]
    heads = [pv.Cylinder(center=(bx, s * by, T_BOSS + 2.5), direction=(0, 0, 1), radius=8.0, height=5.0,
                         resolution=6) for bx, by in BOLTS for s in (-1, 1)]
    return pin.merge(bolts + heads)


PART = dict(color="#c9ced8", smooth_shading=True, specular=0.55, specular_power=28, diffuse=0.85, ambient=0.12)
HW = dict(color="#d9822b", smooth_shading=True, specular=0.4, specular_power=20, diffuse=0.8, ambient=0.1)


def write_mp4(path, frames, fps):
    """Pipe RGB frames (a list of HxWx3 uint8) into ffmpeg: H.264, yuv420p so every browser plays it."""
    h, w = frames[0].shape[:2]
    out = imageio_ffmpeg.write_frames(str(path), (w, h), fps=fps, codec="libx264", pix_fmt_out="yuv420p",
                                      output_params=["-crf", "21"], macro_block_size=8)
    out.send(None)
    for f in frames:
        out.send(np.ascontiguousarray(f))
    out.close()


# ----------------------------------------------------------------------------------------------------
# Why AMG: sparse direct vs Jacobi-CG vs AMG-CG on clamped n x n x n cubes
# ----------------------------------------------------------------------------------------------------
def solver_benchmark(ns=(8, 12, 16, 20, 26, 32, 40), n_direct_max=20):
    """Clamped n x n x n cube of solid material, tip shear, three solvers to rtol 1e-8."""
    rows = []
    for n in ns:
        fe = voxel_fem((n, n, n), 1.0, 1.0, 0.3)
        root = np.flatnonzero(fe.X[:, 0] == 0)
        fe = set_supports(fe, (3 * root[:, None] + np.arange(3)).ravel())
        F = np.zeros(fe.ndof)
        F[3 * np.flatnonzero(fe.X[:, 0] == n) + 2] = -1.0
        f = F[fe.free]
        K = assemble(fe, np.ones(fe.nel))
        row = dict(n=n, dofs=fe.nf, nnz=K.nnz)
        t0 = time.perf_counter()
        ml = amg(K, fe.B)
        t1 = time.perf_counter()
        u, its = pcg(K, f, ml.aspreconditioner())
        row.update(amg_setup_s=t1 - t0, amg_s=time.perf_counter() - t0, amg_its=its)
        t0 = time.perf_counter()
        _, its = pcg(K, f, jacobi(K), maxiter=20000)
        row.update(jacobi_s=time.perf_counter() - t0, jacobi_its=its)
        if n <= n_direct_max:
            t0 = time.perf_counter()
            lu = splu(K.tocsc(), permc_spec="MMD_AT_PLUS_A")
            ud = lu.solve(f)
            row.update(lu_s=time.perf_counter() - t0, lu_fill=(lu.L.nnz + lu.U.nnz) / K.nnz,
                       amg_vs_lu_err=np.linalg.norm(u - ud) / np.linalg.norm(ud))
        rows.append(row)
        print(f"  n = {n:2d}  {fe.nf:6d} dofs  " + "  ".join(f"{k} {v:.3g}" for k, v in row.items()
                                                          if k not in ("n", "dofs")), flush=True)
    return pd.DataFrame(rows)


# ----------------------------------------------------------------------------------------------------
# SIMP and the filter in 2D: the MBB beam, three ways
# ----------------------------------------------------------------------------------------------------
def mbb(penal, rmin, nelx=120, nely=40, volfrac=0.5, iters=60):
    """Half MBB beam: symmetry on the left edge, roller at the bottom right, load at the top left."""
    fe = voxel_fem((nelx, nely), 1.0, 1.0, 0.3)
    left = np.flatnonzero(fe.X[:, 0] == 0)
    corner = np.flatnonzero((fe.X[:, 0] == nelx) & (fe.X[:, 1] == 0))
    sy = set_supports(fe, np.concatenate([2 * left, 2 * corner + 1]))
    F = np.zeros(fe.ndof)
    F[2 * np.flatnonzero((fe.X[:, 0] == 0) & (fe.X[:, 1] == nely)) + 1] = -1.0
    xp, hist, _, _ = topopt(fe, [(sy, F[sy.free][:, None])], volfrac, rmin, penal=penal, iters=iters,
                            label=f"MBB p={penal:g} rmin={rmin:g}")
    return xp.reshape(nelx, nely), hist


def render_bracket(fe, xp, snaps, hist, vm):
    """hero.png, turntable.mp4, evolution.mp4, von_mises.png and the printable bracket.stl."""
    x3, h = xp.reshape(fe.shape), fe.h
    surf = part_surface(x3, h, vm=vm.reshape(fe.shape))
    Lx, Hz = fe.shape[0] * h, fe.shape[2] * h
    c, dist = np.array([Lx / 2, 0.0, 0.4 * Hz]), 2.05 * Lx
    info = {"stl_triangles": surf.n_cells, "stl_volume_mm3": surf.volume,
            "stl_closed": surf.n_open_edges == 0}
    stl = surf.copy()
    stl.clear_data()
    stl.save(OUT / "bracket.stl")

    hw, box = hardware(), envelope()
    p = studio((1600, 900))
    p.add_mesh(surf, **PART)
    p.add_mesh(hw, **HW)
    p.add_mesh(box, color="#50607f", line_width=1.5, opacity=0.8)
    for arrow in load_arrows():
        p.add_mesh(arrow, color="#5fd0ff", smooth_shading=True, ambient=0.3)
    look(p, c, -38, 26, 0.9 * dist)
    p.screenshot(OUT / "hero.png")
    p.close()

    p = studio((1280, 720))
    p.add_mesh(surf, **PART)
    p.add_mesh(hw, **HW)
    p.add_text("SIMP topology optimisation  /  Ti-6Al-4V  /  4 GE load cases", position="lower_left",
               font_size=10, color="#8a93a6")
    frames = []
    for k in range(120):                                      # 4 s loop at 30 fps
        look(p, c, -38 + 3 * k, 24 + 8 * np.sin(2 * np.pi * k / 120), 0.95 * dist)
        frames.append(p.screenshot(return_img=True))
    p.close()
    write_mp4(OUT / "turntable.mp4", frames, 30)

    p = studio((1280, 720))
    p.add_mesh(box, color="#50607f", line_width=1.2, opacity=0.6)
    frames, actors = [], []
    for k, (xs, row) in enumerate(zip(snaps, hist)):
        for a in actors:
            p.remove_actor(a)
        x3k = xs.reshape(fe.shape)
        actors = [p.add_mesh(part_surface(x3k, h, level=0.15, smooth=0, largest=False),
                             color="#6fa8ff", opacity=0.12, smooth_shading=True),   # the grey cloud
                  p.add_mesh(part_surface(x3k, h, smooth=20, largest=False), **PART),
                  p.add_text(f"iteration {row['it']:3d}\ncompliance {row['compliance'] / 1e3:8.2f} J\n"
                             f"volume     {100 * row['volume']:6.2f} %", position="upper_left",
                             font_size=11, color="#dfe4ee", font="courier")]
        look(p, c, -60 + 1.0 * k, 26, dist)
        frames += [p.screenshot(return_img=True)] * (3 if k < 10 else 2)
    frames += frames[-1:] * 30
    p.close()
    write_mp4(OUT / "evolution.mp4", frames, 20)

    vmax = np.percentile(surf.point_data["vm"], 99)
    p = pv.Plotter(off_screen=True, window_size=(1600, 800), shape=(1, 2), border=False)
    for j, (az, el) in enumerate([(-38, 26), (-90, 4)]):
        p.subplot(0, j)
        p.set_background("#040509", top="#1a2233")
        p.add_mesh(surf, scalars="vm", cmap="inferno", clim=(0, vmax), smooth_shading=True, specular=0.3,
                   show_scalar_bar=(j == 1), scalar_bar_args=dict(
                       title="von Mises envelope [MPa]", color="white", n_labels=5, fmt="%.0f",
                       position_x=0.2, position_y=0.04, width=0.6, height=0.07))
        p.add_mesh(hw, color="#3b4252", smooth_shading=True)
        look(p, c, az, el, 0.95 * dist)
    p.screenshot(OUT / "von_mises.png")
    p.close()
    info.update(vm_p99_MPa=vmax, vm_max_MPa=surf.point_data["vm"].max())
    return info


# ----------------------------------------------------------------------------------------------------
# Matplotlib figures (dark theme shared with the other levels)
# ----------------------------------------------------------------------------------------------------
BG, INK, INK2, MUTED, GRID = "#1a1a19", "#ffffff", "#c3c2b7", "#898781", "#2c2c2a"
C1, C2, C3, C4 = "#4a9eed", "#f59a45", "#8fd694", "#e06c9f"
plt.rcParams.update({
    "figure.facecolor": BG, "axes.facecolor": BG, "savefig.facecolor": BG,
    "axes.edgecolor": "#383835", "axes.labelcolor": INK2, "text.color": INK, "axes.titlecolor": INK,
    "xtick.color": MUTED, "ytick.color": MUTED, "axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.7,
    "axes.spines.top": False, "axes.spines.right": False, "legend.frameon": False,
    "font.size": 11, "axes.titlesize": 12.5, "axes.titleweight": "bold", "axes.titlelocation": "left",
    "lines.linewidth": 2, "lines.markersize": 6,
})


def plot_verification(ver, eb, timo):
    fig, (a, b) = plt.subplots(1, 2, figsize=(13, 4.8))
    styles = dict(zip(ver.model.unique(), [(C3, "s", "-"), (C1, "o", "-"), (C2, "x", ":")]))
    for model, g in ver.groupby("model", sort=False):
        col, mk, ls = styles[model]
        a.plot(g.dofs, g.tip_mm, marker=mk, color=col, ls=ls, label=model)
        if mk != "o":                                          # skfem Hex8 == voxel path, skip dup
            b.plot(g.n_thick.values[1:], np.abs(np.diff(g.tip_mm.values)), marker=mk, color=col, ls=ls,
                   label=model)
    a.axhline(eb, color=INK2, ls="--", lw=1.2)
    a.axhline(timo, color=C4, ls="-.", lw=1.2)
    a.text(ver.dofs.min(), eb * 0.997, f"Euler-Bernoulli {eb:.4f} mm", color=INK2, va="top", fontsize=10)
    a.text(ver.dofs.min(), timo * 1.002, f"Timoshenko {timo:.4f} mm", color=C4, va="bottom", fontsize=10)
    a.set(xscale="log", xlabel="degrees of freedom", ylabel="tip deflection [mm]",
          title="A  Cantilever 50 x 10 x 10 mm, 1 kN tip shear")
    a.legend(loc="lower right", fontsize=9.5)
    b.set_xticks([2, 4, 8, 16], ["2", "4", "8", "16"])
    b.xaxis.set_minor_formatter(matplotlib.ticker.NullFormatter())
    nn = np.array([4.0, 16.0])
    b.plot(nn, 0.05 * (nn / 4) ** -2, color=MUTED, ls="--", lw=1)
    b.text(8, 0.05 * 2 ** -2 * 1.15, "slope -2", color=MUTED, fontsize=10)
    b.set(xscale="log", yscale="log", xlabel="elements through the thickness  n",
          ylabel=r"$|\delta_n - \delta_{n/2}|$  [mm]", title="B  Successive refinement")
    b.legend(fontsize=9.5)
    fig.tight_layout()
    fig.savefig(OUT / "verification.png", dpi=130)
    plt.close(fig)


def plot_benchmark(bench, chk):
    fig, ((a, b), (c, e)) = plt.subplots(2, 2, figsize=(13, 9))
    d = bench.dofs
    a.plot(d, bench.lu_s, "o-", color=C2, label="sparse LU (SuperLU)")
    a.plot(d, bench.jacobi_s, "^-", color=C3, label="CG + Jacobi")
    a.plot(d, bench.amg_s, "s-", color=C1, label="CG + smoothed-aggregation AMG")
    x = np.array([d.iloc[1], d.max()])
    for p_, y0, lab in [(1, bench.amg_s.iloc[1], "O(N)"), (2, bench.lu_s.iloc[1], r"O(N$^2$)")]:
        a.plot(x, y0 * (x / x[0]) ** p_, color=MUTED, ls="--", lw=1)
        a.text(x[1], y0 * (x[1] / x[0]) ** p_, " " + lab, color=MUTED, fontsize=10, va="center")
    a.set(xscale="log", yscale="log", xlabel="unknowns N", ylabel="wall time [s]",
          title="A  Solve time, clamped solid cube")
    a.legend(fontsize=9.5)
    b.plot(d, bench.jacobi_its, "^-", color=C3, label="CG + Jacobi")
    b.plot(d, bench.amg_its, "s-", color=C1, label="CG + AMG")
    b.plot(x, bench.jacobi_its.iloc[1] * (x / x[0]) ** (1 / 3), color=MUTED, ls="--", lw=1)
    b.text(x[1], bench.jacobi_its.iloc[1] * (x[1] / x[0]) ** (1 / 3), r" N$^{1/3}$", color=MUTED,
           fontsize=10, va="center")
    b.set(xscale="log", yscale="log", xlabel="unknowns N", ylabel="CG iterations to rtol 1e-8",
          title="B  Iterations: Jacobi grows, AMG stays flat")
    b.legend(fontsize=9.5)
    c.plot(d, bench.lu_fill, "o-", color=C2)
    c.set(xscale="log", xlabel="unknowns N", ylabel="nnz(L+U) / nnz(K)",
          title="C  Sparse LU fill-in (memory)")
    c.xaxis.set_minor_formatter(matplotlib.ticker.NullFormatter())
    e.semilogy(chk["res_jac"], color=C3, label=f"CG + Jacobi ({chk['jacobi_s']:.1f} s)")
    e.semilogy(chk["res_amg"], color=C1, label=f"CG + AMG ({chk['amg_s']:.1f} s incl. setup)")
    e.axhline(1e-5, color=MUTED, ls=":", lw=1)
    e.text(len(chk["res_jac"]) * 0.98, 1.3e-5, "rtol used inside the optimiser", color=MUTED,
           ha="right", fontsize=9.5)
    e.set(xlabel="CG iteration", ylabel=r"$\|r\| / \|f\|$",
          title="D  Final SIMP bracket matrix (stiffness contrast 1e9)")
    e.legend(fontsize=9.5)
    fig.tight_layout()
    fig.savefig(OUT / "solver_benchmark.png", dpi=130)
    plt.close(fig)


def plot_mbb(runs):
    fig, axes = plt.subplots(3, 1, figsize=(10, 6.9))
    for ax, (title, x, hist) in zip(axes, runs):
        ax.imshow(np.concatenate([x[::-1], x]).T, cmap="gray", vmin=0, vmax=1, origin="lower",
                  interpolation="nearest")                          # mirrored: the full beam
        ax.set_title(f"{title}     compliance {hist[-1]['compliance']:.1f},  grey {100 * hist[-1]['grey']:.0f} %",
                     fontsize=11.5)
        ax.set_xticks([]), ax.set_yticks([]), ax.grid(False)
    fig.suptitle("MBB beam, 240 x 40 elements, 50 % volume: what penalisation and the filter each do",
                 color=INK, fontsize=13, fontweight="bold", x=0.02, ha="left")
    fig.tight_layout()
    fig.savefig(OUT / "simp_filter_2d.png", dpi=130)
    plt.close(fig)


def plot_history(df, names):
    fig, (a, b, c) = plt.subplots(1, 3, figsize=(15, 4.4))
    for j, (n, col) in enumerate(zip(names, (C1, C2, C3, C4))):
        a.plot(df.it, df[f"c{j}"] / 1e3, color=col, label=n)
    a.plot(df.it, df.compliance / 1e3, color=INK, lw=2.5, label="sum (objective)")
    a.set(yscale="log", xlabel="iteration", ylabel="compliance [J]", title="A  Objective per load case")
    a.legend(fontsize=9.5)
    b.plot(df.it, 100 * df.volume, color=C1, label="volume fraction")
    b.plot(df.it, 100 * df.grey, color=C2, label="greyness  mean 4x(1-x)")
    b.plot(df.it, 100 * df.change, color=MUTED, lw=1.2, label="max |dx|")
    b.set(xlabel="iteration", ylabel="%", title="B  Constraint, greyness, design change")
    b.legend(fontsize=9.5)
    c.plot(df.it, df.cg_its / len(names), color=C1, label="AMG-CG iterations per load case")
    c2 = c.twinx()
    c2.plot(df.it, df.sec, color=C2, lw=1.2)
    c2.set_ylabel("seconds per design iteration", color=C2)
    c2.tick_params(colors=C2)
    c2.grid(False)
    c.set(xlabel="iteration", ylabel="CG iterations (warm start, rtol 1e-5)", title="C  Solver effort")
    c.legend(fontsize=9.5, loc="upper right")
    fig.tight_layout()
    fig.savefig(OUT / "convergence.png", dpi=130)
    plt.close(fig)


# ----------------------------------------------------------------------------------------------------
# Checks and main
# ----------------------------------------------------------------------------------------------------
H_VOX, VOLFRAC, RMIN, ITERS = 3.2, 0.08, 1.5, 45     # voxel size [mm], volume, filter [voxels]


def design_matrix_check(cases, xp, void):
    """The matrix TO actually solves: the final SIMP design (stiffness contrast 1e9), load case 1.
    AMG-CG vs Jacobi-CG, with residual histories; plus the solid design box's compliance."""
    fe, F = cases[0]
    K = assemble(fe, 1e-9 + xp ** 3 * (1 - 1e-9))
    f = F[:, 0]
    t0 = time.perf_counter()
    M = amg(K, fe.B).aspreconditioner()
    u, res_amg = pcg(K, f, M, history=True)
    out = dict(amg_s=time.perf_counter() - t0, amg_its=len(res_amg) - 1, res_amg=res_amg)
    t0 = time.perf_counter()
    uj, res_jac = pcg(K, f, jacobi(K), maxiter=1000, history=True)
    out.update(jacobi_s=time.perf_counter() - t0, jacobi_its=len(res_jac) - 1, res_jac=res_jac,
               jacobi_err=np.linalg.norm(uj - u) / np.linalg.norm(u))
    comp = 0.0
    for sy, Fk in cases:                                      # solid box (passive voids kept void)
        us, _ = solve(sy, assemble(sy, np.where(void, 1e-9, 1.0)), Fk, rtol=1e-8)
        comp += np.einsum("il,il->", Fk, us)
    out["solid_box_compliance"] = 2 * comp                    # half model -> full part
    return out


def checkerboard(x):
    """0 for smooth fields, 1 for a perfect 0/1 checkerboard (mixed second difference)."""
    return np.abs(x[:-1, :-1] - x[1:, :-1] - x[:-1, 1:] + x[1:, 1:]).mean() / 2


def verify(ver, eb, timo, bench, runs, df, info):
    results = []

    def check(name, ok, detail):
        print(f"  [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
        results.append(ok)

    sk = ver[ver.model.str.startswith("scikit-fem Hex8")].set_index("n_thick").tip_mm
    vx = ver[ver.model.str.startswith("voxel")].set_index("n_thick").tip_mm
    q2 = ver[ver.model.str.startswith("scikit-fem Hex27")].tip_mm.iloc[-1]
    err = max(abs(vx[n] / sk[n] - 1) for n in sk.index)
    check("voxel/Numba/AMG path reproduces scikit-fem", err < 1e-6, f"max rel. diff {err:.1e} (tol 1e-6)")
    for name, v in [("Hex8, n = 16", vx.iloc[-1]), ("Hex27, n = 2", q2)]:
        check(f"{name} vs Timoshenko", abs(v / timo - 1) < 0.03,
              f"{v:.4f} mm vs {timo:.4f} mm ({100 * (v / timo - 1):+.2f} %, tol 3 %)")
        check(f"{name} vs Euler-Bernoulli", abs(v / eb - 1) < 0.03,
              f"{v:.4f} mm vs {eb:.4f} mm ({100 * (v / eb - 1):+.2f} %, tol 3 %)")
    d = np.diff(vx.values)
    order = np.log2(d[:-1] / d[1:])
    check("Hex8 converges monotonically from below", (d > 0).all() and order[-1] > 1.5,
          f"observed order {np.round(order, 2)} (-> 2 for trilinear hexes)")
    e = bench.amg_vs_lu_err.max()
    check("AMG-CG agrees with sparse LU", e < 1e-6, f"max rel. diff {e:.1e}")
    v = df.volume.iloc[-1]
    check("volume constraint met", abs(v - VOLFRAC) < 1e-3, f"{v:.4f} vs {VOLFRAC}")
    c = df.compliance.values
    rel = abs(c[-1] / c[-11] - 1)
    check("compliance history converged", rel < 0.01, f"{100 * rel:.2f} % change over the last 10 iterations")
    check("STL is watertight", info["stl_closed"], f"{info['stl_triangles']} triangles, no open edges")
    cb = [checkerboard(x) for _, x, _ in runs]
    check("density filter kills checkerboards", cb[1] > 5 * cb[2],
          f"checkerboard index {cb[1]:.3f} unfiltered vs {cb[2]:.3f} filtered")
    g = [h[-1]["grey"] for _, _, h in runs]
    check("penalisation p = 3 removes grey", g[1] < 0.02 and g[0] > 2 * g[2],
          f"greyness {100 * g[0]:.0f} % (p=1) vs {100 * g[1]:.1f} % (p=3 raw) and {100 * g[2]:.0f} % "
          f"(p=3 filtered: the filter's own blur band)")
    assert all(results), "verification failed"


def main():
    t_start = time.time()
    OUT.mkdir(exist_ok=True)
    pd.set_option("display.width", 160)

    print("1/5 Verification: clamped 3D cantilever vs beam theory")
    ver, eb, timo = verify_cantilever()
    print(ver.round(5).to_string(index=False))
    plot_verification(ver, eb, timo)

    print(f"2/5 Solver benchmark: sparse LU vs Jacobi-CG vs AMG-CG     ({time.time() - t_start:.0f} s)")
    bench = solver_benchmark()

    print(f"3/5 SIMP and the density filter on the 2D MBB beam     ({time.time() - t_start:.0f} s)")
    runs = [("p = 1, density filter r = 2.4", *mbb(1, 2.4)),
            ("p = 3, no filter", *mbb(3, 1.0)),
            ("p = 3, density filter r = 2.4", *mbb(3, 2.4))]
    plot_mbb(runs)

    print(f"4/5 Bracket topology optimisation     ({time.time() - t_start:.0f} s)")
    fe, cases, solid, void = bracket(H_VOX)
    print(f"  half model: {fe.shape} voxels of {H_VOX} mm = {fe.nel} elements; symmetric system "
          f"{cases[0][0].nf} dofs x 3 load cases + antisymmetric system {cases[1][0].nf} dofs x 1")
    xp, hist, snaps, Us = topopt(fe, cases, VOLFRAC, RMIN, solid=solid, void=void, iters=ITERS,
                                 label="bracket")
    df = pd.DataFrame(hist)
    df[["compliance"] + [f"c{j}" for j in range(len(LOADS))]] *= 2    # half model -> full part
    df.to_csv(OUT / "history.csv", index=False)
    plot_history(df, list(LOADS))
    Uf = np.concatenate([full(sy, U) for (sy, _), U in zip(cases, Us)], axis=1)
    vm = np.max([von_mises(fe, Uf[:, k]) for k in range(Uf.shape[1])], axis=0)
    chk = design_matrix_check(cases, xp, void)
    plot_benchmark(bench, chk)
    print("  final SIMP matrix: AMG-CG {amg_its} its / {amg_s:.1f} s;  Jacobi-CG {jacobi_its} its / "
          "{jacobi_s:.1f} s, final residual {r:.1e}, error vs AMG {jacobi_err:.1e}".format(
              r=chk["res_jac"][-1], **chk))
    print(f"  compliance: optimised {df.compliance.iloc[-1]:.0f} N mm vs solid box "
          f"{chk['solid_box_compliance']:.0f} N mm (x{df.compliance.iloc[-1] / chk['solid_box_compliance']:.2f}"
          f" for {100 * VOLFRAC:.0f} % of the material)")

    print(f"5/5 Rendering (PyVista) and STL     ({time.time() - t_start:.0f} s)")
    info = render_bracket(fe, xp, snaps, df.to_dict("records"), vm)
    box = 2 * fe.nel * H_VOX ** 3
    print(f"  STL: {info['stl_triangles']} triangles, {info['stl_volume_mm3'] / 1e3:.1f} cm^3 "
          f"({100 * info['stl_volume_mm3'] / box:.1f} % of the {box / 1e3:.0f} cm^3 box) = "
          f"{info['stl_volume_mm3'] * RHO_TI:.0f} g of Ti-6Al-4V")
    print(f"  von Mises envelope on the surface: 99th pct {info['vm_p99_MPa']:.0f} MPa, "
          f"max {info['vm_max_MPa']:.0f} MPa")

    print("Verification")
    verify(ver, eb, timo, bench, runs, df, info)
    print(f"Total runtime {time.time() - t_start:.0f} s")


if __name__ == "__main__":
    main()