/** * @medieval-kit/cart-wheel * * The most iconic medieval object after the barrel. It works alone too — a * wheel leaning against a wall turns a scene into a village instantly. * * The real thing is four layers from the outside in: iron tyre, wooden felloe, * spokes, hub. The felloe is not one piece, it is built from straight pieces — * so a lowpoly polygonal rim is not a simplification here, it is the correct * construction. * * The wheel stands in the XY plane; its axis is Z. Whoever wants it lying down * turns it with `root.rotation.x`. */ import { Color, type BufferGeometry } from 'three' import { bandGeometry, chamferedBoxGeometry, createKitModel, createTinter, jitter, latheGeometry, mergeColoured, type Level, } from '../core/index.ts' export interface CartWheelConfig { /** Outer radius, iron tyre included (metres). */ readonly radius: number /** Number of spokes. The felloe piece count follows from it. */ readonly spokeCount: number /** Wheel thickness (metres). */ readonly width: number /** Hub length, as a multiple of the thickness. */ readonly hubLength: number /** Iron tyre thickness, as a fraction of the radius. */ readonly tyre: number readonly seed: number } export const cartWheelDefaults: CartWheelConfig = { radius: 0.52, spokeCount: 10, width: 0.09, hubLength: 2.1, // An iron tyre is a HOOP: 8 to 12 mm of bar shrunk onto the felloe. At // 0.045 of a 0.52 m radius it was 23 mm of iron, and with an axial width // greater than the wheel's own it read as a pneumatic tyre rather than as // the band that holds a wooden rim together. tyre: 0.022, seed: 27, } export type CartWheelParts = 'hub' | 'spokes' | 'felloe' | 'tyre' export function createModel(overrides: Partial = {}) { return createKitModel({ id: 'cart-wheel', defaults: cartWheelDefaults, slots: ['oak', 'iron'], build: ({ config, random }) => { // Through the shared tinter. The two helpers this replaces copied a // palette entry into ONE shared Color and handed it back, so two tints in // the same call resolved to the same value -- and the hub was doing // exactly that, asking for a body colour and a lighter end colour in one // expression and getting the second one twice. The tinter returns a new // Color every time, and it floors lightness so a lift cannot take a part // through black. const tinter = createTinter(random) const oak = (lift = 0): Color => tinter('oak', lift) const iron = (lift = 0): Color => tinter('iron', lift, 0.6) const spokes = Math.max(4, config.spokeCount) const tyreThickness = config.radius * config.tyre const felloeOuter = config.radius - tyreThickness const felloeInner = felloeOuter * 0.82 // 0.20 of the wheel RADIUS, so the nave is 0.20 of the wheel diameter // across. It is the landmark that names the object and at 0.17 it was a // flat disc lost in the spoke plane. const hubRadius = config.radius * 0.2 // A nave is bored for the axle, and the bore is the reason it reads as a // nave rather than as a knob. const boreRadius = hubRadius * 0.3 const hubLength = config.width * config.hubLength // --- nave: a stepped, bored hub standing proud of the spoke plane --- // // It used to be a turned log: one lathe, symmetric, sitting flush in the // plane of the spokes with no hole through it. That is a bobbin, not a // nave. A nave projects well forward of the wheel so the linchpin clears // the spokes, carries a shorter step behind, and is bored end to end. // // Built from bands rather than lathes because a band with `inner` set is // already a closed tube: outer wall, two end annuli and a bore. Three of // them stacked give the steps for nothing. const bodyLen = hubLength * 0.45 const frontLen = hubLength * 0.33 const rearLen = hubLength * 0.22 const frontRadius = hubRadius * 0.72 const rearRadius = hubRadius * 0.78 // Each step sinks a quarter of its length into the body. Butted end to // end their annuli would be coplanar, and two faces in one plane are a // flicker whichever way they point. const frontY = bodyLen / 2 + frontLen / 2 - frontLen * 0.25 const rearY = -bodyLen / 2 - rearLen / 2 + rearLen * 0.25 // The steps are bored slightly wider than the body, which is true of a // real nave and, here, keeps their bore walls off the body's own. const hubPieces: BufferGeometry[] = [ bandGeometry(hubRadius, 0, bodyLen, hubRadius - boreRadius, 10, oak(-0.05), { inner: true }), bandGeometry(frontRadius, frontY, frontLen, frontRadius - boreRadius * 1.1, 10, oak(-0.01), { inner: true }), bandGeometry(rearRadius, rearY, rearLen, rearRadius - boreRadius * 1.06, 10, oak(-0.03), { inner: true }), // Two narrow iron rings girdling the steps, the way a nave is hooped to // stop the timber splitting along the grain from the bore outward. bandGeometry(frontRadius * 1.05, frontY + frontLen * 0.18, frontLen * 0.26, frontRadius * 0.16, 10, iron()), bandGeometry(rearRadius * 1.05, rearY - rearLen * 0.16, rearLen * 0.3, rearRadius * 0.16, 10, iron(-0.03)), ] const hub = mergeColoured(hubPieces) // The hub's axis has to be Z: the wheel stands upright. hub.rotateX(Math.PI / 2) // --- spokes: from hub to felloe, tapering outward --- const spokePieces: BufferGeometry[] = [] // The spoke reaches PAST both ends it joins: it starts inside the hub and // finishes inside the felloe. Sizing it to the exact gap left it merely // touching, and at the extreme ends of the sliders — small radius, thick // tyre — not even that, so the hub and spokes came away from the rim as a // separate floating island. const spokeInner = hubRadius * 0.45 const spokeLength = (felloeInner - spokeInner) + config.width * 0.35 for (let i = 0; i < spokes; i += 1) { const angle = (i / spokes) * Math.PI * 2 const spoke = chamferedBoxGeometry( [config.width * 0.42, config.width * 0.5], [config.width * 0.3, config.width * 0.38], spokeLength, config.width * 0.07, [0, spokeLength / 2 + spokeInner, 0], oak(), ) // A hand-carved spoke is never exactly centred; we add a small offset. spoke.rotateZ(angle + jitter(random, 0.012)) spokePieces.push(spoke) } // --- felloe: polygonal rim built from straight wooden pieces --- const felloePieces: BufferGeometry[] = [] const segments = spokes const step = (Math.PI * 2) / segments // Chord length: the distance between two neighbouring corners. A little // too long so the pieces BITE into one another, not just meet end to end. const chord = 2 * felloeOuter * Math.sin(step / 2) * 1.03 // The flat outer face sits at the INSCRIBED radius, not at `felloeOuter`. // // A felloe piece is a straight box, so its outer face is a chord. Put // that chord at `felloeOuter` and its two corners are further out than // its middle: at ten segments they reached 0.5338 on a wheel whose iron // tyre finishes at 0.52, so the corners came through the tyre and the rim // rendered as alternating bands of tan wood and dark iron. Pulling the // face in by cos(step/2) lands the CORNERS on `felloeOuter`, which is // where the tyre's inner face is, so every piece bites into the iron and // none of it breaks the outside. const felloeFace = felloeOuter * Math.cos(step / 2) const midRadius = (felloeFace + felloeInner) / 2 for (let i = 0; i < segments; i += 1) { const angle = (i + 0.5) * step // Every piece has its own thickness. That is both correct (hand-cut // felloe pieces are never equal) and required: if they were all exactly // the same thickness their side faces would be coplanar and would // z-fight wherever they overlap. const thickness = config.width * (1 + jitter(random, 0.07)) const piece = chamferedBoxGeometry( [chord, thickness], [chord, thickness], felloeFace - felloeInner, config.width * 0.09, [0, 0, 0], oak(0.03), ) // The piece is built at the origin: X = tangent (chord length), Y = // radial thickness, Z = wheel thickness. So for angle=0 it already // faces the right way. Moving it out to the radius and taking it into // place with a single rotation is enough — an extra rotation in between // made some of the pieces parallel and their end faces overlapped. piece.translate(0, midRadius, 0) piece.rotateZ(angle) felloePieces.push(piece) } // --- iron tyre: single-piece hoop wrapping the felloe --- // // A real BAND, not a surface. It used to be a lathe sitting at // `config.radius` with no inner wall, while the felloe stopped a whole // `tyreThickness` further in — so the two never touched. At the default // thickness the gap was small enough that nothing noticed; at the top of // the slider it was 6 cm and an arc of the tyre came away from the wheel // entirely and sat on the ground as its own object. // // `bandGeometry` spans the radius properly: outer face at `config.radius`, // inner face at `config.radius - tyreThickness`, which is exactly where // the felloe's outer face is. It also gets `inner: true`, because unlike // a barrel hoop this band is not wrapped tight around a body — the felloe // is a POLYGON inside a circle, so the gap at the middle of each facet is // visible and needs a surface. const tyre = bandGeometry( config.radius, 0, // NARROWER than the narrowest felloe piece can jitter to. // // The felloe's axial thickness jitters by ±7%, so anything between // 0.93 and 1.07 of the width lands inside that range and will sooner // or later share a plane with some piece; a tyre at exactly the // felloe's width flickered against whichever piece came out near // zero jitter. The first answer was to go wider than the whole range, // at 1.1 -- which stopped the flicker and made the wheel look shod in // a pneumatic tyre, a black band standing proud of its own rim. // // Going under the range instead solves it the same way and is the // truer shape: the hoop is set into the tread, and on a worn wheel a // sliver of felloe shows at each edge where the iron has bedded in. config.width * 0.9, tyreThickness * 1.06, segments * 2, iron(), { inner: true }, ) // A single closed band now, so the separate inner shell that used to // sit against the felloe is gone: the band's own inner face is already // inside `felloeOuter`. const tyreRings = mergeColoured([tyre]) tyreRings.rotateX(Math.PI / 2) return { hub: { slot: 'oak', geometry: hub }, spokes: { slot: 'oak', geometry: mergeColoured(spokePieces) }, felloe: { slot: 'oak', geometry: mergeColoured(felloePieces) }, tyre: { slot: 'iron', geometry: tyreRings }, } }, }, overrides) }