medieval-kit/my-registry/models/wooden-barrel/model.ts

275 lines
12 KiB
TypeScript

/**
* @medieval-kit/wooden-barrel
*
* A real barrel is not a single piece: it is built from separate boards
* (staves) that narrow towards the ends, squeezed together by iron hoops, its
* head sunk into the body, and the staves leave a collar (the chime) above the
* head. This model builds it that way — not as an inflated cylinder.
*
* Dependencies: plain `three` and `@medieval-kit/core`. It never touches
* scifi-kit's primitive/wear pipeline; WebGL is enough.
*/
import { Color, type BufferGeometry } from 'three'
import {
MEDIEVAL_PALETTE,
bandGeometry,
createKitModel,
createRandom,
headGeometry,
jitter,
mergeColoured,
prismGeometry,
staveGeometry,
type Level,
} from '../core/index.ts'
export interface WoodenBarrelConfig {
/** Total height (metres). */
readonly height: number
/** Outer radius at the belly (bilge), in metres. */
readonly radius: number
/** How far the ends narrow against the belly. 0.16 = ends at 84% width. */
readonly taper: number
/** Stave count. Odd by default, because it breaks perfect symmetry. */
readonly staveCount: number
/** Iron hoop count. */
readonly hoopCount: number
/**
* Rivets per hoop.
*
* A hoop is a strip of iron bent into a circle and riveted where its ends
* overlap; a cooper then adds more along its length to stop it springing.
* They are small, but they are the detail that makes a dark band read as
* FORGED IRON rather than as a painted stripe, so they earn their triangles.
*/
readonly rivets: number
/** Variation seed. The same seed always gives the same barrel. */
readonly seed: number
}
export const woodenBarrelDefaults: WoodenBarrelConfig = {
height: 1.04,
radius: 0.41,
// 0.22, not 0.17. A cask is noticeably bellied and this one was close to a
// drum; at 0.22 the belly is 1.28 times the ends, which is what the reference
// measures.
taper: 0.22,
// Stave and hoop counts come from a reference photograph of a real cask,
// not from taste. Thirteen staves left a visibly polygonal silhouette, so
// seventeen it is.
//
// Six hoops was wrong, and the note that used to sit here saying four read as
// too few was written against a different photograph. Our reference carries
// four wide bands, and six put them in doubled pairs at each end. Worse, the
// chime hoops stand proud of the staves they wrap, so a pair of them at each
// end fills in the very taper the belly is supposed to show: measured off the
// silhouette the cask read as 1.14 wide at the belly against its ends where
// the staves alone are 1.20, and a critic looking at the render called it a
// straight drum. Cutting to four both matches the reference and lets the
// profile be seen.
staveCount: 17,
hoopCount: 4,
rivets: 4,
seed: 7,
}
export type WoodenBarrelParts = 'staves' | 'heads' | 'hoops'
const SLOTS = ['oak', 'iron'] as const
type Slot = (typeof SLOTS)[number]
/** Barrel profile: t ∈ [-1,1], narrow at the ends, wide at the belly. */
function profileAt(t: number, taper: number): number {
return 1 - taper * t * t
}
/**
* The hoops sit symmetrically from the ends inwards: the outermost ones are the
* "chime" (end) hoops, the inner ones the "bilge" (belly) hoops.
*/
function hoopPositions(count: number): number[] {
if (count <= 0) return []
if (count === 1) return [0]
const outer = 0.87
const inner = count <= 2 ? 0.87 : 0.33
const pairs = Math.floor(count / 2)
const positions: number[] = []
for (let i = 0; i < pairs; i += 1) {
const t = pairs === 1 ? outer : outer - (outer - inner) * (i / (pairs - 1))
positions.push(t, -t)
}
if (count % 2 === 1) positions.push(0)
return positions
}
export function createModel(overrides: Partial<WoodenBarrelConfig> = {}) {
return createKitModel<WoodenBarrelConfig, 'oak' | 'iron', WoodenBarrelParts>({
id: 'wooden-barrel',
defaults: woodenBarrelDefaults,
slots: SLOTS,
build: ({ config, random }) => {
const half = config.height / 2
// Five levels: ends, quarters and belly. Enough for a lowpoly barrel
// curve; a sixth level adds nothing measurable to the silhouette.
const levels = [-1, -0.58, 0, 0.58, 1]
function buildStaves(random: () => number, half: number, levels: number[]): BufferGeometry {
const wallThickness = config.radius * 0.13
const step = (Math.PI * 2) / config.staveCount
// A thin gap between the staves — the one detail that makes this read
// as "assembled" instead of "a single piece".
const gap = step * 0.055
const tint = new Color()
const pieces: BufferGeometry[] = []
for (let index = 0; index < config.staveCount; index += 1) {
// Every stave carries its own small deviations: radius, end height,
// tone. Perfect repetition reads "manufactured"; rule: break mirrors.
const radiusBias = 1 + jitter(random, 0.014)
const topBias = jitter(random, 0.006)
const bottomBias = jitter(random, 0.006)
const shaped: Level[] = levels.map((t, level) => {
const edge = level === 0 ? bottomBias : level === levels.length - 1 ? topBias : 0
return {
y: t * half + edge,
radius: config.radius * profileAt(t, config.taper) * radiusBias,
}
})
tint.copy(MEDIEVAL_PALETTE.oak)
tint.offsetHSL(jitter(random, 0.014), jitter(random, 0.05), jitter(random, 0.055))
pieces.push(staveGeometry(
shaped,
index * step + gap / 2,
(index + 1) * step - gap / 2,
wallThickness,
tint,
))
}
return mergeColoured(pieces)
}
function buildHeads(random: () => number, half: number): BufferGeometry {
const wallThickness = config.radius * 0.13
const endRadius = config.radius * profileAt(1, config.taper)
// The head seats INSIDE the body, a little back from the end; the
// collar (chime) the staves leave above it makes a barrel a barrel.
const seatRadius = endRadius - wallThickness * 0.9
// Deep enough to SEE. At 0.055 the collar existed in the geometry
// but was too shallow to read at any normal viewing distance, so the
// head looked flush with the stave ends — the one silhouette detail
// that distinguishes a cask from a bucket.
const inset = config.height * 0.07
const tint = new Color(MEDIEVAL_PALETTE.oakEnd)
tint.offsetHSL(0, jitter(random, 0.03), jitter(random, 0.03))
return mergeColoured([
headGeometry(seatRadius, half - inset, config.staveCount, 'up', tint, 3, 0.06),
headGeometry(seatRadius, -half + inset, config.staveCount, 'down', tint, 3, 0.06),
])
}
function buildHoops(random: () => number, half: number): BufferGeometry | undefined {
const positions = hoopPositions(config.hoopCount)
if (positions.length === 0) return undefined
const tint = new Color()
const pieces: BufferGeometry[] = []
for (const t of positions) {
const seat = config.radius * profileAt(t, config.taper)
// The end hoops are wider: that is where the strain is greatest.
const bandHeight = config.height * (0.045 + 0.03 * Math.abs(t))
tint.copy(MEDIEVAL_PALETTE.iron)
tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.05))
const bandRadius = seat + config.radius * 0.022
pieces.push(bandGeometry(
bandRadius,
t * half,
bandHeight,
config.radius * 0.05,
config.staveCount,
tint,
))
// Rivets. Centred ON the band's outer face, so the inner half is
// buried in the iron rather than resting against it — the same
// deliberate interpenetration the rest of the kit uses instead of
// coplanar faces.
// Wide and shallow. The first attempt was long and thin and read
// as a row of nails driven through the hoop; a rivet is a domed
// button that barely stands off the iron. Only a quarter of the
// length shows — the rest is buried in the band.
const studLength = config.radius * 0.05
const studRadius = config.radius * 0.042
// Each hoop's rivets start at their own angle. Aligning them into
// vertical columns is the giveaway that a thing was generated.
const phase = random() * Math.PI * 2
// Rivets are struck iron: their domes are polished by the hammer
// and by every hand that has ever moved the cask, so they catch
// light the flat band does not. Without this they were the right
// shape in the right place and still invisible against the hoop.
// DARKER than the band, not lighter. A rivet head is a small dome
// and the light that reaches its underside is what you see of it;
// lifting it turned four pale diamonds loose on every hoop, and at
// the silhouette they read as detached geometry rather than as part
// of the iron.
const studTint = tint.clone()
studTint.offsetHSL(0, 0, -0.05)
for (let i = 0; i < config.rivets; i += 1) {
const angle = phase + (i / config.rivets) * Math.PI * 2
// The head FLARES outward, it does not taper. That is what a
// hammered rivet actually looks like — the smith spreads the end
// over the iron — and it is also the only version that keeps the
// radial winding check honest: a stud narrowing outwards tilts
// its side normals ~9.5 degrees back towards the barrel axis,
// which the check correctly reports as faces pointing inward.
const stud = prismGeometry(
studRadius * 0.72,
studRadius,
studLength,
4,
[0, 0, 0],
studTint,
// No back face. The rivet's inner end is buried in the band, so
// that cap is never visible — the same saving `bandGeometry`
// makes on the inside of a hoop. It is not only free: once the
// stud is seated deep enough to look right, the buried cap
// falls inside the 94% shell the radial check inspects, and a
// cap pointing at the axis is exactly what that check hunts.
{ capBottom: false },
)
stud.rotateX(Math.PI / 2)
stud.rotateY(angle)
const seatDepth = bandRadius - studLength * 0.25
stud.translate(
Math.sin(angle) * seatDepth,
t * half,
Math.cos(angle) * seatDepth,
)
pieces.push(stud)
}
}
return mergeColoured(pieces)
}
// The call ORDER must be kept: the seeded randomness advances as a
// stream, and if the order changes so does the geometry.
const stavesPart = buildStaves(random, half, levels)
const headsPart = buildHeads(random, half)
const hoopsPart = buildHoops(random, half)
return {
staves: { slot: 'oak' as const, geometry: stavesPart },
heads: { slot: 'oak' as const, geometry: headsPart },
hoops: hoopsPart ? { slot: 'iron' as const, geometry: hoopsPart } : undefined,
}
},
}, overrides)
}