The most practical tool on a steel supplier's website is a weight calculator. You pick a profile (say an IPE 300 beam), a length and a number of pieces, and you get the kilograms for the truck and the quote. While building Ironline, a website template for steel and metal suppliers, we wanted the 3D on the page to show exactly what the calculator is calculating, not a generic model. So the beams, tubes and angles are extruded in three.js from the same section data the calculator uses.
1. One table for numbers and shapes
Rolled sections come from standard tables. Each IPE row holds the height, flange width, web and flange thickness, and the nominal weight per metre:
export const IPE: Record<number, [number, number, number, number, number]> = { // h, b, tw, tf, kg/m 200: [200, 100, 5.6, 8.5, 22.4], 300: [300, 150, 7.1, 10.7, 42.2], // ... }; For IPE, HEA and UPN the calculator uses the table weight. Everything else (tubes, flat, round and square bar, angles, plate) is computed from geometry at 7.85 kg/dm³:
const RHO = 7.85 / 1000; // kg per mm² per m 'sq-tube': { kgm: (d) => (d.a ** 2 - (d.a - 2 * d.t) ** 2) * RHO, /* ... */ }, IPE 300, 6 m, 12 pieces: 42.2 × 6 × 12 ≈ 3,038 kg.
2. From table row to outline
outline(kind, dims) turns a row into a polygon in millimetres, centred on (0, 0). An I-beam is twelve points, with a radius where the web meets the flanges:
function iBeam(h: number, b: number, tw: number, tf: number): Pt[] { const r = Math.max(tw * 1.6, 5); const y = h / 2, yi = h / 2 - tf; const pts: Pt[] = [[-b / 2, y], [b / 2, y], [b / 2, yi], [tw / 2, yi], [tw / 2, -yi], [b / 2, -yi], [b / 2, -y], [-b / 2, -y], [-b / 2, -yi], [-tw / 2, -yi], [-tw / 2, yi], [-b / 2, yi]]; return rounded(pts, [0, 0, 0, r, r, 0, 0, 0, 0, r, r, 0]); } rounded() replaces a corner with a short arc of a given radius and works for inner and outer corners. Tubes return an outer ring and a hole; angles and UPN channels get their own point lists. The radius is an approximation of the root radius, not the exact table value, but it is enough to read as a real rolled beam.
3. Extruding it
The outline becomes a THREE.Shape, holes become THREE.Paths, and ExtrudeGeometry pulls it to length (1 unit = 100 mm; the calculator scales its single piece to a fixed size instead):
const o = outline(kind, dims); const s = size ? size / Math.max(o.w, o.h) : 0.01; // mm → units const shape = new THREE.Shape(o.outer.map(([x, y]) => new THREE.Vector2(x * s, y * s))); for (const h of o.holes) shape.holes.push(new THREE.Path(h.map(([x, y]) => new THREE.Vector2(x * s, y * s)))); const bevel = Math.min(Math.min(o.w, o.h) * s * 0.02, 0.02); const g = new THREE.ExtrudeGeometry(shape, { depth: len, bevelEnabled: true, bevelSize: bevel, bevelThickness: bevel, bevelSegments: 2, curveSegments: 6, }); g.translate(0, 0, -len / 2); The tiny bevel catches the light on the edges, which sells the metal more than a texture would. Plate is the exception: a 5 mm sheet extruded as a shape buys nothing, so it is a BoxGeometry, tilted to show its face.
The look is one MeshPhysicalMaterial with metalness: 1, a dim RoomEnvironment through PMREMGenerator for reflections, a white key light and two orange point lights as a rim. No textures and no model files.
4. The same outline as SVG
Because the outline is plain data, it also renders as SVG path data:
export function svgPath(o: Outline): string { const ring = (pts: Pt[]) => 'M ' + pts.map(([x, y]) => `${x.toFixed(2)} ${(-y).toFixed(2)}`).join(' L ') + ' Z'; return [ring(o.outer), ...o.holes.map(ring)].join(''); } That one function gives us a dimensioned cross-section drawing next to the 3D view, small icons in the stock catalog, and the fallback when WebGL is missing. With fill-rule="evenodd", tube holes come out right without extra work.
5. Loading three.js only when it can run
three.js never blocks the first paint. The component probes for WebGL first, then imports the scene module:
useEffect(() => { let off = false; const probe = document.createElement('canvas'); if (!(probe.getContext('webgl2') || probe.getContext('webgl'))) { setState('fallback'); return; } const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; import('@/lib/steel3d').then(({ createStage }) => { if (off || !ref.current) return; try { stage.current = createStage(ref.current, scene, { reduced, ...latest.current }); setState('ready'); } catch { setState('fallback'); } }); return () => { off = true; stage.current?.dispose(); stage.current = null; }; }, [scene]); The canvas fades in once the stage is ready. Inside, the render loop stops when the canvas leaves the viewport (IntersectionObserver) or the tab is hidden, and the pixel ratio is capped at 2. With prefers-reduced-motion the scene does not spin and only re-renders when something changes, like a new profile in the calculator or a drag.
Trade-offs
- three.js is the heaviest dependency in the template. Loading it on demand keeps it out of the first paint, but it is still a download on phones.
- The section tables in the template cover common sizes (IPE 80–300, HEA 100–300, UPN 80–200). Adding a size is one more row, and both the calculator and the 3D pick it up.
- For rolled sections the weight comes from the table, not from the polygon area, so the approximate radius in the drawing never changes the number.
The template
Ironline also has a hero with a 3D beam and tubes, a stock catalog with tabs, an inquiry list that fills itself from the calculator, and a live line counter in the header and the CTA band. It's a static Next.js App Router site with Tailwind CSS, Motion and three.js, and all products and texts live in one file.
Demo and download: https://market.designzonic.com/product/steel-ironline

![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)
