Devlog #16 — Building the Bridge Designer: FEM in the Browser

No external library, no WebAssembly, no server — just JavaScript assembling a global stiffness matrix, solving it with Gaussian elimination, and rendering stress as colour in real time. Here is exactly how it works.

What is the Finite Element Method?

FEM breaks a continuous structure into simple elements. For a 2D truss (the type used in bridges), each element is a bar that can only carry axial load — tension or compression. The analysis finds how much each bar deforms under external loads, then computes the internal stress.

For a truss with $n$ nodes, the system of equations is: $\mathbf{K} \cdot \mathbf{u} = \mathbf{f}$, where $\mathbf{K}$ is the global stiffness matrix ($2n \times 2n$), $\mathbf{u}$ is the displacement vector, and $\mathbf{f}$ is the applied force vector.

Step 1: Building the Element Stiffness Matrix

Each truss bar contributes a 4×4 local stiffness matrix. It depends on the bar's orientation angle $\theta$, its cross-sectional area $A$, Young's modulus $E$, and length $L$:

function elementStiffness(nodeA, nodeB, E, A) { const dx = nodeB.x - nodeA.x; const dy = nodeB.y - nodeA.y; const L = Math.hypot(dx, dy); const c = dx / L; // cos θ const s = dy / L; // sin θ const k = E * A / L; // Local 4×4 matrix [ux_i, uy_i, ux_j, uy_j] return [ [ c*c, c*s, -c*c, -c*s], [ c*s, s*s, -c*s, -s*s], [-c*c, -c*s, c*c, c*s], [-c*s, -s*s, c*s, s*s], ].map(row => row.map(v => v * k)); }

Step 2: Assembling the Global Stiffness Matrix

Each element's 4×4 matrix is "stamped" into the correct positions in the global $2n \times 2n$ matrix. Node $i$ occupies DOFs $[2i, 2i+1]$ (x and y):

function assembleK(nodes, elements, E, A) { const n = nodes.length; const dof = n * 2; const K = Array.from({ length: dof }, () => new Float64Array(dof)); for (const el of elements) { const ke = elementStiffness(nodes[el.i], nodes[el.j], E, A); const idx = [el.i*2, el.i*2+1, el.j*2, el.j*2+1]; for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) K[idx[r]][idx[c]] += ke[r][c]; } return K; }

Step 3: Applying Boundary Conditions

Fixed nodes (supports) have known displacement (zero). We enforce this by zeroing the row and column for each fixed DOF and setting the diagonal to 1:

for (const dof of fixedDofs) { for (let i = 0; i < K.length; i++) { K[dof][i] = 0; K[i][dof] = 0; } K[dof][dof] = 1; f[dof] = 0; // no displacement at support }

Step 4: Gaussian Elimination Solver

With boundary conditions applied, solve $\mathbf{K} \cdot \mathbf{u} = \mathbf{f}$ using Gaussian elimination with partial pivoting. For a typical bridge (50 nodes), this runs in under 1 ms in JavaScript:

function gaussianElimination(A, b) { const n = b.length; // Forward elimination with partial pivot for (let col = 0; col < n; col++) { // Find row with max absolute value in this column let maxRow = col; for (let r = col+1; r < n; r++) if (Math.abs(A[r][col]) > Math.abs(A[maxRow][col])) maxRow = r; [A[col], A[maxRow]] = [A[maxRow], A[col]]; [b[col], b[maxRow]] = [b[maxRow], b[col]]; for (let r = col+1; r < n; r++) { const factor = A[r][col] / A[col][col]; b[r] -= factor * b[col]; for (let c = col; c < n; c++) A[r][c] -= factor * A[col][c]; } } // Back substitution const x = new Float64Array(n); for (let i = n-1; i >= 0; i--) { x[i] = b[i]; for (let j = i+1; j < n; j++) x[i] -= A[i][j] * x[j]; x[i] /= A[i][i]; } return x; }

Step 5: Computing Member Stress

With displacement $\mathbf{u}$ known, the axial strain in each bar is: $\varepsilon = (du_x \cdot \cos\theta + du_y \cdot \sin\theta) / L$, and stress is $\sigma = E \cdot \varepsilon$. Positive = tension (bar is being stretched), negative = compression.

function memberStress(el, nodes, u, E) { const { i, j } = el; const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const L = Math.hypot(dx, dy); const c = dx / L, s = dy / L; const du = (u[j*2] - u[i*2]) * c + (u[j*2+1] - u[i*2+1]) * s; return E * du / L; // σ = E·ε }

Step 6: Colour-Coding Stress in Three.js

Each bar's stress value is mapped to a colour: blue (max compression) → white (zero load) → red (max tension).

0
+
CompressionZeroTension
function stressToColor(sigma, maxSigma) { const t = clamp(sigma / maxSigma, -1, 1); if (t < 0) return new THREE.Color().lerpColors(white, blue, -t); else return new THREE.Color().lerpColors(white, red, t); }

Deformation Visualisation (50× Scale)

Real steel bridge deflections under load are millimetres — invisible at normal scale. We apply a configurable amplification factor (default 50×) to the node displacements so the deformation is visible without misrepresenting the geometry. The bar stays connected to the (scaled) displaced nodes:

const DEFORM_SCALE = 50; nodes.forEach((node, i) => { node.displayX = node.x + u[i*2] * DEFORM_SCALE; node.displayY = node.y + u[i*2+1] * DEFORM_SCALE; });

Bridge Type Presets

Warren Truss
Alternating diagonal members at 60°. Efficient use of material, equal distribution of stress.
Most even stress distribution
Pratt Truss
Vertical members in compression, diagonal members in tension. Preferred for long spans.
Great for heavy centre loads
Howe Truss
Opposite to Pratt — diagonals in compression. Less efficient for steel but suited for wood.
Classic 19th century form

Accuracy note: Our implementation handles 2D truss elements only. Real bridges need 3D beam elements (Euler-Bernoulli) to capture bending moments. For the educational purpose — demonstrating stiffness assembly and FEM solving — 2D truss analysis is accurate and fast in the browser.

Try it now

Open the Bridge Designer, select a preset, apply loads at different nodes and watch the colour-coded stress and deformation update in real time. The safety factor panel turns red when any member exceeds its yield stress.