Skip to content

FEA-Driven Structural Optimization

FEA-Driven Structural Optimization hero image
Modified:
Published:

Close the loop between parametric CAD and finite element analysis in this capstone project. You will build an automated pipeline where CadQuery generates bracket geometry, FreeCAD FEM meshes and solves with CalculiX, and Python sweeps a 3D parameter space to produce a Pareto front of mass versus stress for optimal design selection. #FEA #StructuralOptimization #Capstone

Learning Objectives

By the end of this lesson, you will be able to:

  1. Design a parametric L-bracket in CadQuery with configurable wall thickness, rib count, fillet radius, and mounting holes
  2. Automate the CadQuery-to-STEP-to-FreeCAD pipeline using Python scripting
  3. Configure FreeCAD FEM analysis: mesh generation, boundary conditions, loads, and CalculiX solver settings
  4. Execute a systematic parameter sweep across a 3D design space (wall thickness, rib count, fillet radius)
  5. Analyze results on a Pareto front to identify the optimal mass-vs-stress trade-off

The Optimization Loop



The core idea is a closed-loop pipeline where geometry generation, simulation, and result extraction are all automated:

  1. CadQuery generates geometry: a parametric bracket with design variables (wall thickness , rib count , fillet radius )

  2. Export to STEP: CadQuery writes a STEP file that preserves exact geometry for FEM import

  3. FreeCAD FEM imports and meshes: Python scripting in FreeCAD creates the FEM analysis: mesh, materials, constraints, loads

  4. CalculiX solves: the open-source FEA solver computes displacements, stresses, and reaction forces

  5. Python extracts results: read the .frd output file to get maximum von Mises stress and compute mass from volume

  6. Record and repeat: store (parameters, mass, stress) for each configuration, then move to the next parameter combination

  7. Pareto front analysis: plot mass vs. stress for all configurations and identify the non-dominated (optimal) designs

The Driver Loop

For each combination of wall thickness, rib count, and fillet radius:

  1. CadQuery generates the bracket and exports a STEP file
  2. FreeCAD FEM imports the STEP, meshes it, applies loads, and solves with CalculiX
  3. Python reads the results file to extract mass and maximum von Mises stress
  4. Results are stored in a table for post-processing

After all configurations are evaluated, matplotlib plots the Pareto front and highlights the optimal design.

Parametric Bracket Design in CadQuery



The design subject is an L-bracket, a common structural element used for mounting motors, sensors, and structural connections. The bracket has:

  • A horizontal base plate with mounting holes (bolted to a surface)
  • A vertical web plate (carries the load)
  • Optional stiffening ribs connecting the base to the web
  • Fillet radii at the base-web junction (stress reduction)

Design Parameters

Wall Thickness (t)

Range: 2-6 mm. Controls the thickness of both the base plate and the vertical web. Primary driver of both mass and stiffness.

Rib Count (n_r)

Range: 0-4 ribs. Triangular stiffening ribs connecting the base to the web. Each rib adds mass but significantly reduces stress at the junction.

Fillet Radius (r_f)

Range: 1-8 mm. Radius at the base-web junction. Larger fillets reduce stress concentration but add mass and may interfere with mounting.

Fixed Parameters

Base: 60x40 mm, Web height: 50 mm, Material: Aluminum 6061-T6. These remain constant across all configurations to keep the parameter sweep manageable.

import cadquery as cq
import numpy as np
from typing import Tuple
def parametric_bracket(
base_width: float = 60.0, # mm, base plate width (X)
base_depth: float = 40.0, # mm, base plate depth (Y)
web_height: float = 50.0, # mm, vertical web height (Z)
wall_thickness: float = 4.0, # mm, plate thickness
fillet_radius: float = 4.0, # mm, base-web junction fillet
n_ribs: int = 2, # number of stiffening ribs
rib_thickness: float = None, # mm, defaults to wall_thickness
hole_diameter: float = 6.5, # mm, mounting hole diameter (M6 clearance)
hole_inset: float = 10.0, # mm, hole center distance from edges
) -> cq.Workplane:
"""Generate a parametric L-bracket with ribs and fillets.
The bracket sits at the origin with the base plate on the
XY plane and the web extending upward along Z.
Args:
base_width: Width of the base plate (X direction)
base_depth: Depth of the base plate (Y direction)
web_height: Height of the vertical web (Z direction)
wall_thickness: Thickness of plates
fillet_radius: Fillet radius at base-web junction
n_ribs: Number of triangular stiffening ribs
rib_thickness: Rib thickness (defaults to wall_thickness)
hole_diameter: Mounting hole diameter
hole_inset: Distance from edge to hole center
Returns:
CadQuery solid of the bracket
"""
t = wall_thickness
if rib_thickness is None:
rib_thickness = t
# === Base plate ===
base = (
cq.Workplane("XY")
.box(base_width, base_depth, t,
centered=(True, True, False))
)
# === Vertical web ===
# Web sits at the back edge of the base (Y = base_depth/2)
web = (
cq.Workplane("XY")
.transformed(offset=(0, base_depth/2 - t/2, t))
.box(base_width, t, web_height,
centered=(True, True, False))
)
# Union base and web
bracket = base.union(web)
# === Fillet at base-web junction ===
if fillet_radius > 0:
# Select the interior edge where base meets web
# Use NearestToPointSelector to target the concave junction edge
try:
bracket = (
bracket
.edges("|X")
.edges(
cq.selectors.NearestToPointSelector(
(0, base_depth/2 - t, t)
)
)
.fillet(fillet_radius)
)
except Exception:
pass # Skip if fillet fails on complex geometry
# === Stiffening ribs ===
if n_ribs > 0:
# Distribute ribs evenly across the width
if n_ribs == 1:
rib_positions = [0.0]
else:
margin = hole_inset + hole_diameter
rib_span = base_width - 2 * margin
rib_positions = np.linspace(
-rib_span/2, rib_span/2, n_ribs
).tolist()
rib_height = web_height * 0.7 # Ribs are 70% of web height
rib_depth = base_depth * 0.6 # Ribs extend 60% of base depth
for x_pos in rib_positions:
# Triangular rib profile
rib = (
cq.Workplane("XY")
.transformed(offset=(
x_pos, base_depth/2 - t, t
))
.moveTo(0, 0)
.lineTo(-rib_depth, 0)
.lineTo(0, rib_height)
.close()
.extrude(rib_thickness)
.translate((0, 0, 0))
)
# Center the rib extrusion on the x_pos
rib = rib.translate((-rib_thickness/2, 0, 0))
# Swap: extrude along X, so use workplane "YZ"
rib = (
cq.Workplane("YZ")
.transformed(offset=(
x_pos - rib_thickness/2,
base_depth/2 - t,
t
))
.moveTo(0, 0)
.lineTo(-rib_depth, 0)
.lineTo(0, rib_height)
.close()
.extrude(rib_thickness)
)
bracket = bracket.union(rib)
# Fillet rib edges if radius permits
if fillet_radius > 1:
try:
bracket = bracket.edges(
cq.selectors.BoxSelector(
(-base_width/2, -base_depth/2, t),
(base_width/2, base_depth/2, t + web_height)
)
).fillet(min(fillet_radius, rib_thickness * 0.4))
except Exception:
pass # Skip if fillet fails on complex geometry
# === Mounting holes in the base plate ===
hole_positions = [
(base_width/2 - hole_inset, base_depth/2 - hole_inset),
(base_width/2 - hole_inset, -base_depth/2 + hole_inset),
(-base_width/2 + hole_inset, base_depth/2 - hole_inset),
(-base_width/2 + hole_inset, -base_depth/2 + hole_inset),
]
for hx, hy in hole_positions:
bracket = (
bracket
.faces("<Z")
.workplane()
.pushPoints([(hx, hy)])
.hole(hole_diameter, t)
)
# === Load application hole in the web ===
load_hole_z = t + web_height * 0.75 # 75% up the web
bracket = (
bracket
.faces(">Y")
.workplane()
.pushPoints([(0, load_hole_z - t - web_height/2)])
.hole(hole_diameter, t)
)
return bracket
# Generate a bracket with default parameters
bracket = parametric_bracket(
wall_thickness=4.0,
fillet_radius=4.0,
n_ribs=2
)
# Export
cq.exporters.export(bracket, "bracket_t4_r2_f4.step")

FreeCAD FEM Python API



FreeCAD’s FEM workbench can be fully controlled through Python. The key objects are:

FreeCAD ObjectPurpose
FemAnalysisContainer for all FEM objects
Fem::FemMeshGmshGmsh mesh generation
Fem::ConstraintFixedFixed boundary condition (zero displacement)
Fem::ConstraintForceApplied force load
FemMaterialMaterial properties assignment
Fem::FemSolverCalculixCalculiX solver configuration
"""
FreeCAD FEM setup script.
Run this inside FreeCAD's Python console or via:
freecad --console script.py
This script:
1. Imports a STEP file
2. Creates a FEM analysis
3. Assigns material properties
4. Creates mesh
5. Applies boundary conditions and loads
6. Solves with CalculiX
"""
import FreeCAD
import Part
import Fem
import ObjectsFem
import Mesh
import femmesh.femmesh2mesh
def setup_fem_analysis(
step_file: str,
output_dir: str,
force_magnitude: float = 500.0, # N
mesh_size: float = 2.0, # mm
) -> dict:
"""Set up and run a FEM analysis on an imported STEP bracket.
Args:
step_file: Path to the STEP file from CadQuery
output_dir: Directory for solver output files
force_magnitude: Applied force in Newtons
mesh_size: Maximum mesh element size in mm
Returns:
Dictionary with analysis results
"""
# --- Create new document ---
doc = FreeCAD.newDocument("BracketFEM")
# --- Import STEP geometry ---
Part.insert(step_file, doc.Name)
shape_obj = doc.Objects[0]
doc.recompute()
# --- Create FEM Analysis container ---
analysis = ObjectsFem.makeAnalysis(doc, "Analysis")
# --- Assign material: Aluminum 6061-T6 ---
material = ObjectsFem.makeMaterialSolid(doc, "Aluminum6061T6")
mat_dict = material.Material
mat_dict["Name"] = "Aluminum 6061-T6"
mat_dict["YoungsModulus"] = "68900 MPa"
mat_dict["PoissonRatio"] = "0.33"
mat_dict["Density"] = "2700 kg/m^3"
mat_dict["ThermalExpansionCoefficient"] = "23.6 µm/m/K"
mat_dict["YieldStrength"] = "276 MPa"
mat_dict["UltimateTensileStrength"] = "310 MPa"
material.Material = mat_dict
analysis.addObject(material)
# --- Create mesh with Gmsh ---
femmesh = ObjectsFem.makeMeshGmsh(doc, "FEMMeshGmsh")
femmesh.Part = shape_obj
femmesh.CharacteristicLengthMax = mesh_size
femmesh.CharacteristicLengthMin = mesh_size / 4
femmesh.ElementOrder = "2nd" # Quadratic elements
femmesh.Algorithm2D = "Frontal"
femmesh.Algorithm3D = "Delaunay"
# Run Gmsh meshing
from femmesh.gmshtools import GmshTools
gmsh = GmshTools(femmesh)
gmsh.create_mesh()
analysis.addObject(femmesh)
print(f"Mesh: {femmesh.FemMesh.NodeCount} nodes, "
f"{femmesh.FemMesh.VolumeCount} elements")
# --- Fixed constraint on base plate bottom face ---
fixed = ObjectsFem.makeConstraintFixed(doc, "FixedBase")
# Select the bottom face (Z = 0)
bottom_faces = []
for i, face in enumerate(shape_obj.Shape.Faces):
com = face.CenterOfMass
if abs(com.z) < 0.1: # Bottom face
bottom_faces.append(
("Face" + str(i + 1), face)
)
if bottom_faces:
fixed.References = [
(shape_obj, bottom_faces[0][0])
]
analysis.addObject(fixed)
# --- Force load on the web hole ---
force = ObjectsFem.makeConstraintForce(doc, "AppliedForce")
force.Force = force_magnitude # Newtons
# Select the cylindrical face of the load hole
# (the hole near the top of the web)
load_faces = []
for i, face in enumerate(shape_obj.Shape.Faces):
com = face.CenterOfMass
# Load hole is at ~75% web height, on the far Y face
if com.z > 30 and abs(com.y) > 15:
if face.Surface.TypeId == "Part::GeomCylinder":
load_faces.append(("Face" + str(i + 1), face))
if load_faces:
force.References = [
(shape_obj, load_faces[0][0])
]
force.Direction = (shape_obj, "Face1") # Will be corrected
force.Reversed = False
analysis.addObject(force)
# --- Solver: CalculiX ---
solver = ObjectsFem.makeSolverCalculix(doc, "CalculiX")
solver.WorkingDir = output_dir
solver.AnalysisType = "static"
solver.GeometricalNonlinearity = "linear"
solver.MaterialNonlinearity = "linear"
solver.EigenmodesCount = 0
solver.IterationsMaximum = 2000
analysis.addObject(solver)
# --- Run solver ---
doc.recompute()
from femtools import ccxtools
fea = ccxtools.FemToolsCcx(analysis, solver)
fea.update_objects()
fea.setup_working_dir()
fea.setup_ccx()
message = fea.check_prerequisites()
if message:
print(f"Prerequisites check: {message}")
return {"error": message}
fea.write_inp_file()
fea.ccx_run()
fea.load_results()
doc.recompute()
return doc, analysis, solver
# Example usage (inside FreeCAD):
# doc, analysis, solver = setup_fem_analysis(
# "bracket_t4_r2_f4.step",
# "/tmp/bracket_fem/",
# force_magnitude=500.0,
# mesh_size=2.0
# )

Automated Optimization Loop



The driver script ties everything together: it iterates through the parameter space, generates geometry, runs FEM analysis, and collects results.

"""
Automated FEA optimization loop.
This script runs outside FreeCAD, calling it as a subprocess
for each FEM analysis. This approach is more robust than running
everything inside a single FreeCAD session (which can leak memory
over hundreds of iterations).
"""
import os
import json
import subprocess
import time
import numpy as np
from itertools import product
from typing import List, Dict, Tuple
# === Configuration ===
WORK_DIR = "/tmp/bracket_optimization/"
FREECAD_PATH = "freecadcmd" # FreeCAD command-line executable
FORCE_N = 500.0
MESH_SIZE = 2.0 # mm
# === Parameter space ===
THICKNESSES = [2.0, 3.0, 4.0, 5.0, 6.0]
RIB_COUNTS = [0, 1, 2, 3, 4]
FILLET_RADII = [1.0, 2.0, 4.0, 6.0, 8.0]
def generate_and_export_bracket(
t: float, n_ribs: int, r_f: float, output_dir: str
) -> Tuple[str, float]:
"""Generate a bracket and export to STEP.
Returns:
(step_file_path, mass_grams)
"""
import cadquery as cq
bracket = parametric_bracket(
wall_thickness=t,
fillet_radius=r_f,
n_ribs=n_ribs,
)
# Calculate mass
volume_mm3 = bracket.val().Volume()
mass_g = volume_mm3 * 2.70e-6 * 1000 # Al 6061 density
# Export STEP
step_path = os.path.join(output_dir, "bracket.step")
cq.exporters.export(bracket, step_path)
return step_path, mass_g
def write_fem_script(
step_path: str, output_dir: str,
force_n: float, mesh_size: float
) -> str:
"""Write a FreeCAD Python script for FEM analysis.
Returns the path to the generated script.
"""
script_content = f'''
import sys
import os
import json
# Add FreeCAD paths
import FreeCAD
import Part
import Fem
import ObjectsFem
# --- Configuration ---
STEP_FILE = "{step_path}"
OUTPUT_DIR = "{output_dir}"
FORCE_N = {force_n}
MESH_SIZE = {mesh_size}
RESULTS_FILE = os.path.join(OUTPUT_DIR, "results.json")
try:
# Create document
doc = FreeCAD.newDocument("BracketFEM")
# Import geometry
Part.insert(STEP_FILE, doc.Name)
shape = doc.Objects[0]
doc.recompute()
# Create analysis
analysis = ObjectsFem.makeAnalysis(doc, "Analysis")
# Material
material = ObjectsFem.makeMaterialSolid(doc, "Al6061T6")
mat = material.Material
mat["Name"] = "Aluminum 6061-T6"
mat["YoungsModulus"] = "68900 MPa"
mat["PoissonRatio"] = "0.33"
mat["Density"] = "2700 kg/m^3"
material.Material = mat
analysis.addObject(material)
# Mesh
femmesh = ObjectsFem.makeMeshGmsh(doc, "FEMMesh")
femmesh.Part = shape
femmesh.CharacteristicLengthMax = MESH_SIZE
femmesh.CharacteristicLengthMin = MESH_SIZE / 4
femmesh.ElementOrder = "2nd"
from femmesh.gmshtools import GmshTools
gmsh_tools = GmshTools(femmesh)
gmsh_tools.create_mesh()
analysis.addObject(femmesh)
# Fixed constraint (bottom face)
fixed = ObjectsFem.makeConstraintFixed(doc, "Fixed")
for i, face in enumerate(shape.Shape.Faces):
if abs(face.CenterOfMass.z) < 0.1:
fixed.References = [(shape, "Face" + str(i+1))]
break
analysis.addObject(fixed)
# Force on web hole
force = ObjectsFem.makeConstraintForce(doc, "Force")
force.Force = FORCE_N
for i, face in enumerate(shape.Shape.Faces):
com = face.CenterOfMass
if (com.z > 30 and
hasattr(face.Surface, 'TypeId') and
'Cylinder' in str(face.Surface)):
force.References = [(shape, "Face" + str(i+1))]
break
analysis.addObject(force)
# Solver
solver = ObjectsFem.makeSolverCalculix(doc, "Solver")
solver.WorkingDir = OUTPUT_DIR
solver.AnalysisType = "static"
solver.GeometricalNonlinearity = "linear"
analysis.addObject(solver)
doc.recompute()
# Run solver
from femtools import ccxtools
fea = ccxtools.FemToolsCcx(analysis, solver)
fea.update_objects()
fea.setup_working_dir()
fea.setup_ccx()
msg = fea.check_prerequisites()
if not msg:
fea.write_inp_file()
fea.ccx_run()
fea.load_results()
doc.recompute()
# Extract results
import numpy as np_fc
result_obj = None
for obj in doc.Objects:
if hasattr(obj, "vonMises"):
result_obj = obj
break
if result_obj:
vm = list(result_obj.vonMises)
results = {{
"max_von_mises_MPa": max(vm),
"mean_von_mises_MPa": sum(vm)/len(vm),
"n_nodes": len(vm),
"status": "success"
}}
else:
results = {{"status": "no_results"}}
else:
results = {{"status": "prereq_fail", "message": msg}}
except Exception as e:
results = {{"status": "error", "message": str(e)}}
# Write results
with open(RESULTS_FILE, 'w') as f:
json.dump(results, f, indent=2)
FreeCAD.closeDocument(doc.Name)
'''
script_path = os.path.join(output_dir, "fem_script.py")
with open(script_path, 'w') as f:
f.write(script_content)
return script_path
def run_single_analysis(
t: float, n_ribs: int, r_f: float
) -> Dict:
"""Run one complete generate → mesh → solve cycle.
Returns:
Dictionary with parameters and results
"""
# Create output directory for this configuration
config_name = f"t{t:.0f}_r{n_ribs}_f{r_f:.0f}"
config_dir = os.path.join(WORK_DIR, config_name)
os.makedirs(config_dir, exist_ok=True)
# Step 1: Generate geometry and export STEP
step_path, mass_g = generate_and_export_bracket(
t, n_ribs, r_f, config_dir
)
# Step 2: Write FEM script
script_path = write_fem_script(
step_path, config_dir, FORCE_N, MESH_SIZE
)
# Step 3: Run FreeCAD as subprocess
try:
result = subprocess.run(
[FREECAD_PATH, "--console", script_path],
capture_output=True, text=True,
timeout=300, # 5 minute timeout per analysis
)
except subprocess.TimeoutExpired:
return {
"t": t, "n_ribs": n_ribs, "r_f": r_f,
"mass_g": mass_g,
"status": "timeout"
}
# Step 4: Read results
results_path = os.path.join(config_dir, "results.json")
if os.path.exists(results_path):
with open(results_path, 'r') as f:
fem_results = json.load(f)
else:
fem_results = {"status": "no_output"}
return {
"t": t,
"n_ribs": n_ribs,
"r_f": r_f,
"mass_g": mass_g,
**fem_results,
}
def run_optimization():
"""Execute the full parameter sweep."""
os.makedirs(WORK_DIR, exist_ok=True)
all_results = []
total = len(THICKNESSES) * len(RIB_COUNTS) * len(FILLET_RADII)
print(f"Starting optimization: {total} configurations")
print("=" * 60)
for i, (t, n_r, r_f) in enumerate(
product(THICKNESSES, RIB_COUNTS, FILLET_RADII)
):
print(f"[{i+1}/{total}] t={t}mm, ribs={n_r}, "
f"fillet={r_f}mm ... ", end="", flush=True)
start = time.time()
result = run_single_analysis(t, n_r, r_f)
elapsed = time.time() - start
status = result.get("status", "unknown")
if status == "success":
sigma = result["max_von_mises_MPa"]
print(f"OK ({elapsed:.1f}s) — "
f"mass={result['mass_g']:.1f}g, "
f"σ_max={sigma:.1f} MPa")
else:
print(f"FAILED ({status})")
all_results.append(result)
# Save all results
output_path = os.path.join(WORK_DIR, "all_results.json")
with open(output_path, 'w') as f:
json.dump(all_results, f, indent=2)
print(f"\nResults saved to: {output_path}")
return all_results
# Run the optimization
# all_results = run_optimization()

Pareto Front Analysis



The Pareto front identifies the non-dominated designs, configurations where no other design is simultaneously lighter AND lower stress. Any improvement in one objective requires a sacrifice in the other.

Pareto Optimality

A design dominates design if:

  • has less mass than , AND
  • has less (or equal) maximum stress than

A design is Pareto-optimal if no other design in the set dominates it. The collection of all Pareto-optimal designs forms the Pareto front.

import numpy as np
from typing import List, Dict, Tuple
def find_pareto_front(
results: List[Dict],
obj1_key: str = "mass_g",
obj2_key: str = "max_von_mises_MPa",
minimize_both: bool = True,
) -> List[int]:
"""Identify Pareto-optimal designs from a set of results.
Args:
results: List of result dictionaries
obj1_key: Key for objective 1 (mass)
obj2_key: Key for objective 2 (stress)
minimize_both: Whether both objectives should be minimized
Returns:
List of indices into 'results' that are Pareto-optimal
"""
# Filter to successful results only
valid = [
(i, r) for i, r in enumerate(results)
if r.get("status") == "success"
]
if not valid:
return []
n = len(valid)
is_dominated = [False] * n
for i in range(n):
if is_dominated[i]:
continue
idx_i, ri = valid[i]
m_i = ri[obj1_key]
s_i = ri[obj2_key]
for j in range(n):
if i == j or is_dominated[j]:
continue
idx_j, rj = valid[j]
m_j = rj[obj1_key]
s_j = rj[obj2_key]
# Does j dominate i?
if minimize_both:
if m_j <= m_i and s_j <= s_i and (
m_j < m_i or s_j < s_i
):
is_dominated[i] = True
break
pareto_indices = [
valid[i][0] for i in range(n) if not is_dominated[i]
]
return pareto_indices
def select_best_design(
results: List[Dict],
pareto_indices: List[int],
max_stress_MPa: float = 138.0, # σ_y / 2.0
max_displacement_mm: float = 0.5,
) -> int:
"""Select the best design from the Pareto front.
Strategy: among Pareto-optimal designs that meet all
constraints, select the one with minimum mass.
Args:
results: All results
pareto_indices: Indices of Pareto-optimal designs
max_stress_MPa: Maximum allowable stress
max_displacement_mm: Maximum allowable displacement
Returns:
Index of the best design
"""
feasible = []
for idx in pareto_indices:
r = results[idx]
if r["max_von_mises_MPa"] <= max_stress_MPa:
feasible.append(idx)
if not feasible:
print("WARNING: No Pareto-optimal design meets all "
"constraints. Returning lowest-stress design.")
return min(pareto_indices,
key=lambda i: results[i]["max_von_mises_MPa"])
# Among feasible Pareto designs, pick lightest
best = min(feasible, key=lambda i: results[i]["mass_g"])
return best

Interpreting the Pareto Front



The Pareto front reveals fundamental engineering trade-offs:

  1. Light designs with high stress (left side of the Pareto front): thin walls, no ribs, small fillets. These are mass-optimal but may not meet strength requirements.

  2. Heavy designs with low stress (right side of the Pareto front): thick walls, many ribs, large fillets. These are over-designed and wasteful.

  3. The “knee” of the Pareto front: the region of diminishing returns where adding more mass yields progressively less stress reduction. The best designs are often near this knee.

  4. Infeasible designs: configurations above the stress constraint line fail the strength requirement regardless of mass. These are eliminated from consideration.

  5. Dominated designs: configurations that are neither on the Pareto front nor infeasible. Another design exists that is both lighter and stronger.

Design Sensitivity Insights

The parameter sweep also reveals which design variables have the strongest influence:

import matplotlib.pyplot as plt
import numpy as np
def sensitivity_analysis(results: List[Dict]):
"""Analyze how each parameter affects mass and stress.
Creates box plots showing the distribution of stress
for each value of each design parameter.
"""
valid = [r for r in results if r.get("status") == "success"]
fig, axes = plt.subplots(2, 3, figsize=(14, 8))
params = [
("t", "Wall Thickness (mm)", [2, 3, 4, 5, 6]),
("n_ribs", "Rib Count", [0, 1, 2, 3, 4]),
("r_f", "Fillet Radius (mm)", [1, 2, 4, 6, 8]),
]
for col, (key, label, values) in enumerate(params):
# Stress sensitivity
stress_data = []
for v in values:
stresses = [
r["max_von_mises_MPa"] for r in valid
if r[key] == v
]
stress_data.append(stresses)
axes[0, col].boxplot(stress_data, labels=values)
axes[0, col].set_xlabel(label)
axes[0, col].set_ylabel("Max σ_vM (MPa)")
axes[0, col].set_title(f"Stress vs. {label}")
axes[0, col].grid(True, alpha=0.3)
# Mass sensitivity
mass_data = []
for v in values:
masses = [
r["mass_g"] for r in valid
if r[key] == v
]
mass_data.append(masses)
axes[1, col].boxplot(mass_data, labels=values)
axes[1, col].set_xlabel(label)
axes[1, col].set_ylabel("Mass (g)")
axes[1, col].set_title(f"Mass vs. {label}")
axes[1, col].grid(True, alpha=0.3)
plt.suptitle("Parameter Sensitivity Analysis", fontsize=14,
fontweight='bold')
plt.tight_layout()
plt.savefig("sensitivity_analysis.png", dpi=150)
plt.show()
# sensitivity_analysis(all_results)

Typical findings for the L-bracket problem:

ParameterEffect on StressEffect on MassSensitivity
Wall thickness Strong reduction (inverse square)Strong increase (linear)Highest
Rib count Moderate reduction (ribs brace the junction)Small increase per ribMedium
Fillet radius Moderate reduction (stress concentration relief)Negligible increaseMedium (stress only)

Complete Capstone Workflow



Here is the end-to-end sequence you would run to complete this capstone project:

  1. Set up the environment

    Terminal window
    pip install cadquery numpy scipy matplotlib pandas
    # Ensure FreeCAD is installed with FEM workbench
    # Ensure CalculiX (ccx) is on the system PATH
    which ccx # Should return the CalculiX solver path
  2. Define the parametric bracket in CadQuery (code above) and test with a single configuration

  3. Verify FEM setup by running a single analysis manually in FreeCAD to confirm mesh quality, boundary conditions, and load application are correct

  4. Run the parameter sweep: start with a coarse grid (3 values per parameter = 27 configurations) to verify the pipeline, then expand to the full grid (125 configurations)

  5. Compute the Pareto front and generate the visualization

  6. Select the optimal design from the Pareto front and regenerate the final bracket in CadQuery

  7. Final verification: run one last FEM analysis at finer mesh size (1mm) to confirm the stress result converges

  8. Export production files: STEP for CNC machining, STL for 3D printing

Final Design Export

def export_final_design(
results: List[Dict],
best_index: int,
output_dir: str = "./final_bracket/"
):
"""Generate and export the final optimized bracket.
Creates STEP, STL, and a design report.
"""
import cadquery as cq
import json
import os
os.makedirs(output_dir, exist_ok=True)
r = results[best_index]
# Regenerate the optimal bracket
bracket = parametric_bracket(
wall_thickness=r["t"],
fillet_radius=r["r_f"],
n_ribs=r["n_ribs"],
)
# Export geometry
step_path = os.path.join(output_dir, "bracket_optimized.step")
stl_path = os.path.join(output_dir, "bracket_optimized.stl")
cq.exporters.export(bracket, step_path)
cq.exporters.export(bracket, stl_path)
# Design report
report = {
"project": "L-Bracket Structural Optimization",
"material": "Aluminum 6061-T6",
"design_parameters": {
"wall_thickness_mm": r["t"],
"rib_count": r["n_ribs"],
"fillet_radius_mm": r["r_f"],
},
"performance": {
"mass_g": round(r["mass_g"], 2),
"max_von_mises_stress_MPa": round(
r["max_von_mises_MPa"], 1
),
"allowable_stress_MPa": 138.0,
"safety_factor": round(
138.0 / r["max_von_mises_MPa"], 2
),
},
"load_case": {
"applied_force_N": 500,
"direction": "Downward (-Z) on web hole",
"boundary_condition": "Fixed base plate (4 bolt holes)",
},
"optimization": {
"total_configurations_evaluated": len(results),
"pareto_optimal_count": len([
i for i in range(len(results))
if i in find_pareto_front(results)
]),
"solver": "CalculiX",
"mesh_type": "2nd order tetrahedral",
},
"files": {
"step": os.path.basename(step_path),
"stl": os.path.basename(stl_path),
}
}
report_path = os.path.join(output_dir, "design_report.json")
with open(report_path, 'w') as f:
json.dump(report, f, indent=2)
print(f"\n{'='*50}")
print(f"FINAL OPTIMIZED BRACKET")
print(f"{'='*50}")
print(f"Wall thickness: {r['t']} mm")
print(f"Rib count: {r['n_ribs']}")
print(f"Fillet radius: {r['r_f']} mm")
print(f"Mass: {r['mass_g']:.1f} g")
print(f"Max stress: {r['max_von_mises_MPa']:.1f} MPa")
print(f"Safety factor: "
f"{138.0 / r['max_von_mises_MPa']:.2f}")
print(f"{'='*50}")
print(f"STEP: {step_path}")
print(f"STL: {stl_path}")
print(f"Report: {report_path}")
return bracket
# Export the final design
# final_bracket = export_final_design(all_results, best_idx)

Course Summary: What You Have Built



This capstone brings together every technique from the course:

Lesson 1: Hardware Library

Standards-driven parametric design. You built reusable functions that generate geometry from lookup tables and equations, the same pattern used for the bracket’s parametric dimensions.

Lesson 2: Involute Gears

Mathematical curve generation. The involute tooth profile, contact ratio, and Lewis stress equations demonstrated how engineering math becomes CadQuery geometry, the same approach used for helix generation in springs and implicit surfaces in lattices.

Lesson 3: PCB Enclosures

Data-driven geometry. Parsing external data (KiCad PCB files) to auto-generate geometry taught the pattern of reading inputs and producing parametric output, the same pattern the optimization loop uses when reading FEA results.

Lesson 4: Heat Sinks

Parameter sweeps and visualization. Sweeping fin count, height, and spacing while plotting thermal resistance maps established the optimization workflow that this capstone extends to full FEA-in-the-loop.

Lesson 5: Lattice Structures

Advanced geometry generation. TPMS implicit surfaces and voxel-based modeling showed that CadQuery + NumPy can handle geometry far beyond traditional CAD, the same computational approach that makes large parameter sweeps feasible.

Lesson 6: Spring Design

Stress verification. Wahl correction, Goodman fatigue analysis, and material selection from standards demonstrated rigorous engineering verification, the same mindset applied to FEA-based stress checking in this capstone.

Key Takeaways



Automation is the Point

The manual version of this workflow (model a bracket in GUI CAD, import to FEM, set up analysis, run, record stress, repeat 125 times) would take weeks. The automated version runs overnight.

Pareto > Single Optimum

Real engineering rarely has one “best” answer. The Pareto front shows the full trade-off landscape, letting you make informed decisions based on which constraint matters most for your application.

Code = Reproducibility

Every aspect of this optimization (geometry, loads, constraints, solver settings, post-processing) is captured in version-controlled Python scripts. Anyone can reproduce, verify, and extend the results.

Start Simple, Then Automate

Always verify the FEM setup manually on one configuration before automating. A bug in the load application that runs silently through 125 configurations wastes hours and produces meaningless results.

Comments

Loading comments...


© 2021-2026 SiliconWit®. All rights reserved.