Three.js r160 is the rendering engine — a WebGL wrapper. Loaded as an ES module from a CDN via an import map; no build step, no bundler.
One WebGLRenderer with antialiasing, ACES Filmic tone mapping (film-like highlight rolloff so metal doesn't clip to white), and pixel ratio capped at 2 for retina without wasting GPU.
PBR pipeline: every surface is a MeshStandardMaterial — physically based, so metalness/roughness behave like real material properties instead of fake shininess.
Environment reflections: a procedural RoomEnvironment is prefiltered through PMREMGenerator into a blurred mipmap chain. This is what makes metal read as metal — it needs something to reflect.
OrbitControls with damping for the camera (drag / zoom / pan).
Color management: image textures are tagged sRGB; data textures (bump, displacement heights) stay linear. Mixing these up is the classic "washed out texture" bug.
What's unusual for a Three.js app
Almost no stock geometry is used. Faces and edges are hand-built BufferGeometry (raw vertex arrays: position, normal, uv, index) because the coin's shape comes from the user's image at runtime.
Nearly every texture is a CanvasTexture generated in code — edge materials, height maps, extended faces — not loaded assets. The app ships zero texture files.
The whole coin rebuilds in a few milliseconds whenever a slider changes: dispose old geometry, regenerate from the current state object, reattach shared materials.
One idea unifies every shape
Circle, square, and traced coins are all represented the same way: a CCW outline — an array of points with outward normals. Everything downstream (faces, edge, bevel, smear) consumes that outline and doesn't care which shape produced it.
Faces are "star grids": rings of vertices shrinking from the outline toward the center. Interior vertices exist so relief displacement has something to move — a flat 2-triangle face can't deform.
The edge (extrusion) is a swept profile: a 2D cross-section (offset, z, normal) extruded along the outline. Straight band when bevel = 0.
Bevel is a half-ellipse profile: it leaves the front face tangentially (ellipse normal at ψ=0 equals the face normal, so shading is continuous — no visible seam), bulges outward, and rolls to the back. The faces are never shrunk, so the image silhouette is always exact.
Square corners: sharp mode duplicates corner vertices with each side's normal (crisp flat shading); bevel mode uses a single 45° mitered normal so the roll chamfers around the corner continuously.
Traced coins
The outline is the union of both sides' traced silhouettes (back mirrored in X, as it physically is). Whichever side sticks out defines the solid, so background can never peek between face and edge.
The back face is built directly in world space with normal −Z, reversed winding, and its own image mapping — flipping the coin shows an un-mirrored back, like a real coin.
Rim seal: the relief height map is multiplied by a falloff that reaches zero inside the rim, so displaced faces stay welded to the extrusion at any depth instead of lifting off.
Every upload goes through a pipeline
Analysis canvas: the image is downscaled to 256×256 and its pixels read once — all masks, colors, and outlines come from this cheap copy; the full-resolution image is only used for display textures.
Coin mask: if the image has real transparency, the alpha channel is the mask. Otherwise (a photo), the background color is estimated from the four corners and keyed out by color distance — this is how a coin on a white backdrop gets isolated.
Auto-fit: the bounding box of the mask maps the coin content exactly onto the face, so images with margins still meet the edge flush.
Contour trace: 256 rays cast from the mask centroid; the furthest masked pixel per ray is the outline. Two smoothing passes remove pixel jaggies while keeping real dents of hand-struck coins.
Texture baking
Extension bake (the "Extend fill" control): the coin is cut from its background with a slightly eroded mask (so no background-tinted rim pixels leak in), then rim pixels are dilated outward in growing steps with the coin's average color underneath — any area beyond the silhouette shows continued coin material.
Blend blur: the bake is blurred, then the sharp cut-out is composited back on top — only the extended area is softened; the coin stays pixel-sharp.
Relief height map: grayscale + contrast + slight blur of the face texture; brightness = raised metal. Multiplied by the rim falloff before use.
Bakes are cached against the original image, so toggling options re-bakes in ~100 ms without re-analysis.
Faces
Baked face texture as map, user-controlled metalness/roughness, and — when Pressed 3D is on — the height map as both displacementMap (real vertex movement) and bumpMap (fine normal detail the mesh is too coarse to carry).
The edge library — all procedural canvases
Image smear (default): for each angle around the rim, a patch of rim pixels from both faces (11 neighboring angles × 3 depths) is averaged into a vertical gradient — front color → darkened waist → back color. The averaging is what makes it smooth instead of stripey.
Reeded: a sine-wave bump map, ~180 grooves — the milled edge of a real coin, geometry-free.
Brushed: ~2200 random horizontal streaks running around the circumference, like lathe-finished metal.
Stone: base color sampled from your coin's own pixels (48 angles × 3 radii × both sides), plus radial-gradient blotches and ~5000 grains in both color and bump; metalness 0.05, roughness 0.92 — reads as carved rock.
Holographic: three full hue rotations around the rim at metalness 1 / roughness 0.15.
Gold / Copper / Dark / Custom: color presets; Custom exposes the color picker and follows the material sliders.
Textures are cached per mode and invalidated only when their inputs change (images, shape).
The rig
Key light: one strong directional light positioned by azimuth/elevation on a sphere. Default is a raking angle (low elevation) — light skimming across the surface is what makes engraving cast micro-shadows and read as 3D. Even frontal light flattens everything.
Ambient is a single slider driving three things at once: environment reflection intensity (envMapIntensity per material) and two soft fill lights, so the unlit side stays visible without stealing contrast.
Shimmer: a fourth directional light whose intensity follows the coin's angular velocity and whose position sweeps as a function of rotation — so highlights travel across the face while it spins or flips, and fade when still.
The light dot
A small emissive sphere (tone-mapping disabled so it stays bright) marks the key light's direction, with a larger invisible sphere as an easy hit target.
Dragging raycasts from the pointer into the scene and intersects a virtual orbit sphere around the coin — the intersection becomes the new light direction; azimuth/elevation are derived and pushed back into the panel sliders live.
While dragging, orbit controls are disabled so the camera doesn't fight the gesture; the panel toggle hides the dot entirely.
The animation core
Everything runs in one requestAnimationFrame loop with a clamped clock delta — animations are time-based, not frame-based, so they run at the same speed on any refresh rate.
A flip is a tiny state machine: style, elapsed time, duration, start/target rotations. Progress p = t/dur goes through an easing function and interpolates rotations.
Landing on the right face: heads means rotation ≡ 0 (mod 2π), tails means ≡ π. The target is computed as the current angle advanced by several whole turns plus whatever remainder lands on the chosen face — so every flip ends exactly face-on, never mid-turn.
The three flip styles
Pump & spin: 4 full Y-turns with ease-in-out-cubic, plus a scale pulse of 1 + 0.18·sin(πp) — grows to 118% mid-flip, returns to 100%.
Coin toss: height follows a true projectile parabolay = h·4p(1−p) (zero at start/end, peak mid-air) while the coin tumbles 5 turns end-over-end around X.
The settle trick: an X-tumble landing on tails shows the back upside-down. The fix is a rotation identity — Rz(π)·Rx(π) = Ry(π) — so a short in-plane half-twist after landing (animated via quaternion premultiply) rights the text with no visible jump.
Twirl & settle: 6 turns with ease-out-quint (fast launch, long coast) plus a decaying wobble 0.3·sin(5πp)·(1−p) on the Z axis.
Click-to-turn: clicking the coin raycasts the meshes and runs a single eased half-turn to the opposite face. A click is distinguished from an orbit drag by movement (<6px) and duration (<500ms).
After any flip the auto-spin holds for ~1s so you can see the result before rotation resumes.
Architecture
Vanilla JS, one module, no framework. A single state object is the source of truth; controls write to it and call the narrowest applicable update — material tweak, lighting pass, or full geometry rebuild.
Textures and geometries are explicitly dispose()d on rebuild — WebGL resources don't garbage-collect themselves.
Heavy work (texture re-bakes) is debounced behind slider drags; light work (material uniforms) runs per-input-event.
The panel
Every control is a pill row; sliders fill the row itself. Dragging is implemented with pointer events on the row, not the native range input — Safari gives an unstyled invisible range input a zero-size thumb, which made native dragging dead. The hidden input is kept in sync for programmatic use.
Sections collapse, dropdowns and color swatches render inline, and a slider registry lets 3D-side interactions (like dragging the light dot) push values back into the panel.
Serving & reliability
A tiny threaded Python server sends Cache-Control: no-store on everything, and the script URL carries a version stamp — both ends of the stale-cache problem closed after it bit twice.
Every feature was verified headlessly with Playwright driving real WebKit (Safari's engine) — real mouse drags and clicks, then screenshot inspection, not just "no errors thrown".
Stack in one line: Three.js r160 + raw BufferGeometry + Canvas 2D bakes + vanilla JS state/UI, served statically — no build, no frameworks, no asset files.