/** * @medieval-kit/forge-hearth * * A smith's forge: a raised stone hearth with a coursed chimney hood over its * back end and a bellows on a ground post blowing through the side wall. * * The kit has had an anvil since early on and nothing to heat anything in. An * anvil alone is a lump of iron; what makes a corner a smithy is the fire, and * what makes the fire a forge rather than a hearth is the bellows blowing * through the side of it. * * - `hearth` — the stone trough, waist high, coursed all round, with a * single-block coping rim about the fire pan. * - `fire` — charcoal and embers filling the pan. `setLit` puts it out * without taking the charcoal with it, as the cauldron does. * - `chimney` — a HOLLOW hood of block courses over the rear third, with a * mouth opening at the bottom of its front face and an open * flue through the cap ring. * - `bellows` — teardrop boards and a pleated leather bag, nose into the * wall through an iron nozzle, tail carried by a brace off a * post that stands on its own foot clear of the masonry. * * Lessons this model has already paid for, in the order they were bought: * * MASONRY. A slab mottled to stone colour is a slab, and that goes for the * chimney too: pass three scored 70 with a solid smooth pyramid standing on * obviously blocky walls. Everything mineral here is now the same module: a * ring of proud, jittered blocks per course. The walls keep a backing slab * behind their blocks (it plays mortar and keeps the box closed); the hood * carries no backing at all, its blocks overlap along the ring and course to * course, which is what lets it be hollow. * * THE CHIMNEY IS A HOOD, NOT A MONUMENT. The critic called the closed pyramid * a "filled solid sitting on the rear rim". The shaft is now open top and * bottom: a cap ring with a visible flue hole, a one-course mouth in the * front face at hearth-top level (corner blocks kept so the sides reach the * ground), and the char bed running back underneath so the throat shows dark * with an ember in it. * * THE BELLOWS IS PART OF THE FORGE, NOT FURNITURE BESIDE IT. Version two * stood it on a bench (read as a stool with a bag on it); version three hung * it off a post flat against the wall, which read as a stripe painted on the * masonry, with the nozzle lost and the tail plank in free air. The machine * is now: nose buried in the wall through a fat tapering nozzle, post * standing on a plinth foot with daylight between it and the stone, a pivot * peg through the post head, and a shallow diagonal brace from the peg that * LIES ON the top board near the tail, its end cap finishing inside the * board's thickness. Post carries the tail, wall carries the nose. * * PLEATS GO ALL THE WAY ROUND. The bag is a stack of four alternating * wide/narrow teardrop plates between the boards, every plate with its own * width, length, thickness and nose offset so no two caps share a plane; * pass three's five plates included two WIDER than the boards, which put * near-coincident slivers on the silhouette. Wide now means wide among the * leather, still inside the board outline. * * ROTATION. Twice a two-rotation chain put a bar on the wrong axis. Anything * diagonal is built along one axis and turned ONCE: the brace lives in the * x = postX plane and only rotates about X, which works because the teardrop * board is wider than the post offset where the brace lands on it. */ import { type BufferGeometry } from 'three' import { createKitModel, createTinter, dishedSheetGeometry, jitter, latheGeometry, mergeColoured, prismGeometry, roughenGeometry, taperedBoxGeometry, type Level, type SheetLevel, } from '../core/index.ts' export interface ForgeHearthConfig { /** Length of the hearth block along the smith's side (metres). */ readonly length: number /** Depth of the hearth block (metres). */ readonly depth: number /** Height of the block to the top of its kerb (metres). */ readonly height: number /** Height of the chimney above the hearth's top (metres). */ readonly chimney: number /** Masonry density: blocks around the kerb, wall courses scale with it. */ readonly kerbBlocks: number /** Whether the fire is lit (0/1). */ readonly lit: number readonly seed: number } export const forgeHearthDefaults: ForgeHearthConfig = { length: 1.32, depth: 0.82, height: 0.78, chimney: 1.1, kerbBlocks: 16, lit: 1, seed: 37, } export type ForgeHearthParts = 'hearth' | 'fire' | 'chimney' | 'bellows' export interface ForgeHearthActions { setLit(on: boolean): void isLit(): boolean } const lerp = (a: number, b: number, t: number): number => a + (b - a) * t export function createModel(overrides: Partial = {}) { let lit = true return createKitModel({ id: 'forge-hearth', defaults: forgeHearthDefaults, slots: ['stone', 'char', 'ember', 'oak', 'leather', 'iron'], build: ({ config, random }) => { const tint = createTinter(random) const L = config.length const D = config.depth const H = config.height // The forge stands on the ground rather than about its own centre: it // has one face that matters and that face is the top, so measuring from // the floor is what keeps the working height honest. const floor = 0 const wall = Math.min(L, D) * 0.17 const kerbH = H * 0.13 const bodyTop = H - kerbH const kerbCount = Math.max(8, Math.round(config.kerbBlocks)) const density = kerbCount / 16 // One ring of blocks walked around a rectangle. The whole model's // masonry is this module: the coping rim uses it once, the hood uses it // per course. Blocks straddle the rectangle's edge; `skip` lets a run // be left out (the coping's back, the hood's mouth). Runs overlap by a // few percent and every block gets its own outward nudge, so a ring is // closed without any two faces sharing a plane. const ringCourse = ( into: BufferGeometry[], cx: number, halfX: number, halfZ: number, count: number, cy: number, blockH: number, blockD: number, lift: number, overlap: number, phase: number, skip?: (onFront: boolean, onBack: boolean, frac: number) => boolean, ): void => { const per = (halfX + halfZ) * 4 const run = per / count for (let i = 0; i < count; i += 1) { let s = (((i + 0.5 + phase) % count) / count) * per let x = 0 let z = 0 let along: 'x' | 'z' = 'x' let onFront = false let onBack = false let frac = 0 if (s < halfX * 2) { x = -halfX + s; z = -halfZ; along = 'x'; frac = s / (halfX * 2) } else if ((s -= halfX * 2) < halfZ * 2) { x = halfX; z = -halfZ + s; along = 'z'; onFront = true; frac = s / (halfZ * 2) } else if ((s -= halfZ * 2) < halfX * 2) { x = halfX - s; z = halfZ; along = 'x'; frac = s / (halfX * 2) } else { s -= halfX * 2; x = -halfX; z = halfZ - s; along = 'z'; onBack = true; frac = s / (halfZ * 2) } if (skip && skip(onFront, onBack, frac)) continue const d = blockD * (1 + jitter(random, 0.08)) const r = run * (overlap + jitter(random, 0.04)) const nudge = jitter(random, blockD * 0.14) const size: [number, number] = along === 'x' ? [r, d] : [d, r] const centre: [number, number, number] = along === 'x' ? [cx + x, cy, z + Math.sign(z) * Math.abs(nudge)] : [cx + x + Math.sign(x) * Math.abs(nudge), cy, z] into.push(taperedBoxGeometry( size, [size[0] * 0.92, size[1] * 0.92], blockH * (1 + jitter(random, 0.05)), centre, tint('stone', lift + jitter(random, 0.09)), )) } } // --- Hearth trough --------------------------------------------------- const masonry: BufferGeometry[] = [] // Four backing slabs: they close the box and play mortar in the joints // between the course blocks that carry the whole masonry read. for (const [dx, dz, sx, sz] of [ [0, -(D - wall) / 2, L, wall], [0, (D - wall) / 2, L, wall], [-(L - wall) / 2, 0, wall, D - wall * 2], [(L - wall) / 2, 0, wall, D - wall * 2], ] as const) { masonry.push(taperedBoxGeometry( [sx, sz], [sx * 0.99, sz * 0.99], bodyTop, [dx, floor + bodyTop / 2, dz], tint('stone', -0.06 + jitter(random, 0.03)), )) } // Wall courses: three chunky bands of blocks proud of the slabs. Joints // stagger by alternating the block count per course; a half-block phase // shift on an OPEN run would leave its last block past the corner. const courses = 3 const courseH = bodyTop / courses const blockD = wall * 0.5 const proud = wall * 0.12 const nAlong = Math.max(4, Math.round(kerbCount * 0.26)) const nAcross = Math.max(3, Math.round(kerbCount * 0.19)) for (let c = 0; c < courses; c += 1) { const cy = floor + (c + 0.5) * courseH for (const [along, sign] of [['x', 1], ['x', -1], ['z', 1], ['z', -1]] as const) { const faceLen = along === 'x' ? L : D const n = (along === 'x' ? nAlong : nAcross) + (c % 2) const run = faceLen / n const plane = along === 'x' ? sign * (D / 2 - blockD / 2 + proud) : sign * (L / 2 - blockD / 2 + proud) for (let i = 0; i < n; i += 1) { const at = -faceLen / 2 + (i + 0.5) * run const size: [number, number] = along === 'x' ? [run * (0.92 + jitter(random, 0.05)), blockD] : [blockD, run * (0.92 + jitter(random, 0.05))] masonry.push(taperedBoxGeometry( size, [size[0] * 0.9, size[1] * 0.9], courseH * (0.9 + jitter(random, 0.06)), along === 'x' ? [at, cy, plane] : [plane, cy, at], tint('stone', 0.02 + jitter(random, 0.09)), )) } } } // The hood's footprint: rear third of the length, full width. const xb = -L / 2 + 0.015 const hoodHalfX0 = L * 0.18 const hoodFront = xb + hoodHalfX0 * 2 // Coping rim: a single-block ring around the fire pan on three sides; // the fourth side of the pan is the hood's mouth, so the rim would only // hide the throat there. The side runs end inside the hood's bottom // corner blocks. const rimD = 0.115 const copFront = L / 2 - rimD * 0.48 const copBack = hoodFront + 0.03 const copHalfX = (copFront - copBack) / 2 const copHalfZ = D / 2 - rimD * 0.48 ringCourse( masonry, (copFront + copBack) / 2, copHalfX, copHalfZ, kerbCount, floor + bodyTop + kerbH * 0.45, kerbH * 1.05, rimD, 0.04, 0.97, 0, (_f, onBack) => onBack, ) const hearth = mergeColoured(masonry) roughenGeometry(hearth, wall * 0.05, { salt: 23 }) // --- Fire ------------------------------------------------------------ // The pan fills the open top inside the rim, sunk about a course below // it, and its floor runs back UNDER the hood so the mouth shows char // instead of a stone wall. The bed is LUMPS, not a plate: irregular // charcoal with the glow coming up between the lumps at the middle. const floorTop = floor + bodyTop - 0.025 const panMaxX = copFront - rimD const panHalfZ = copHalfZ - rimD const coals: BufferGeometry[] = [] const embers: BufferGeometry[] = [] // Floor slab: its edges bury themselves inside the wall slabs, which is // what keeps it counted as carried rather than floating in the shell. coals.push(taperedBoxGeometry( [panMaxX + 0.05 - (xb + 0.05), (panHalfZ + 0.06) * 2], [panMaxX - xb, panHalfZ * 2 + 0.1], 0.1, [(panMaxX + 0.05 + xb + 0.05) / 2, floorTop - 0.05, 0], tint('char', 0.02), )) const bcx = (hoodFront + panMaxX) / 2 - 0.02 const radX = (panMaxX - hoodFront) / 2 * 0.92 const radZ = panHalfZ * 0.85 const lumps = 21 for (let i = 0; i < lumps; i += 1) { // Golden-angle spiral over an ellipse, so the bed fills evenly rather // than ringing. The middle of a working fire is where the air // arrives, so that is where it glows; the edges are burnt out. const a = i * 2.399963 const ring = Math.sqrt((i + 0.35) / lumps) const size = 0.05 + random() * 0.042 const hot = ring < 0.55 && i % 2 === 0 const lump = prismGeometry( size, size * 0.5, size * 0.8, 5, [0, 0, 0], hot ? tint(i % 3 === 0 ? 'emberTip' : 'ember', jitter(random, 0.05), 0.4) : tint('char', 0.05 + jitter(random, 0.08)), { capBottom: false }, ) lump.rotateX(jitter(random, 0.5)) lump.rotateZ(jitter(random, 0.5)) lump.rotateY(random() * Math.PI * 2) lump.translate( bcx + Math.sin(a) * radX * ring, floorTop + size * (hot ? 0.42 : 0.33), Math.cos(a) * radZ * ring, ) ;(hot ? embers : coals).push(lump) } // Two lumps in the throat, one of them glowing, so the mouth reads as // the fire's back rather than an empty niche. for (const [mz, hot] of [[0.09, true], [-0.1, false]] as const) { const size = 0.055 + random() * 0.02 const lump = prismGeometry( size, size * 0.5, size * 0.8, 5, [0, 0, 0], hot ? tint('ember', jitter(random, 0.05), 0.4) : tint('char', 0.04), { capBottom: false }, ) lump.rotateY(random() * Math.PI * 2) lump.translate(hoodFront - 0.05, floorTop + size * 0.35, mz) ;(hot ? embers : coals).push(lump) } const coal = mergeColoured(coals) // --- Chimney hood ---------------------------------------------------- // Six shrinking rings of the same blocks the walls wear, no core at // all: the shaft is hollow, the cap is a ring around an open flue, and // the bottom course skips its mid-front blocks to leave a mouth over // the char (the corner blocks stay so the sides reach the hearth). Ring // runs overlap and courses interpenetrate vertically, which is what // keeps a coreless stack closed everywhere but the two openings. const hood: BufferGeometry[] = [] const hoodBase = floor + bodyTop - 0.03 const hoodH = Math.max(0.3, floor + H + config.chimney - hoodBase) const hoodCourses = 6 const hCourseH = hoodH / hoodCourses for (let c = 0; c < hoodCourses; c += 1) { const t = (c + 0.5) / hoodCourses const hx = lerp(hoodHalfX0, L * 0.078, t) const hz = lerp(D * 0.49, D * 0.2, t) const count = Math.max(6, Math.round(((hx + hz) * 4 / 0.21) * density)) ringCourse( hood, xb + hx, hx, hz, count, hoodBase + (c + 0.5) * hCourseH, hCourseH * 1.08, 0.085, 0.02, 1.08, (c % 2) * 0.5, c === 0 ? (onFront, _b, frac) => onFront && frac > 0.18 && frac < 0.82 : undefined, ) } // Cap ring: four slabs proud of the top course with a hole between // them, the reference's cap slab opened into a flue. Heights and spans // jitter so no two slabs share a plane where they interpenetrate. const capY = hoodBase + hoodH + 0.008 const capCx = xb + lerp(hoodHalfX0, L * 0.078, 1) const capHx = L * 0.115 const capHz = D * 0.27 const capW = 0.08 for (const [dx, dz, sx, sz] of [ [capHx - capW / 2, 0, capW, capHz * 2], [-(capHx - capW / 2), 0, capW, capHz * 2 * 0.98], [0, capHz - capW / 2, (capHx - capW) * 2.2, capW], [0, -(capHz - capW / 2), (capHx - capW) * 2.2 * 0.97, capW], ] as const) { hood.push(taperedBoxGeometry( [sx, sz], [sx * 0.96, sz * 0.96], 0.055 * (1 + jitter(random, 0.08)), [capCx + dx, capY + jitter(random, 0.004), dz], tint('stone', 0.06 + jitter(random, 0.05)), )) } const chimney = mergeColoured(hood) roughenGeometry(chimney, wall * 0.045, { salt: 29 }) // --- Bellows --------------------------------------------------------- // Nose toward the hearth on -Z, tail hanging out over open ground. // Nothing here floats: the nose hangs on the nozzle buried in the wall, // the tail hangs on the brace, the brace hangs on the peg through the // post head, and the post stands on its own plinth foot with clear // daylight between it and the masonry. const bx0 = -0.1 * (L / 1.32) const zn = D / 2 + 0.09 const bl = H * 0.8 const bw = D * 0.54 const by = floor + H * 0.74 const timber: BufferGeometry[] = [] const hide: BufferGeometry[] = [] const metal: BufferGeometry[] = [] // Teardrop plate: narrow nose, widest past the middle, rounded tail. // Width and LENGTH scale separately, because they mean opposite things. // `dishedSheetGeometry` builds in the XY plane, thickness along Z; ONE // rotation about X lays it flat with its length pointing +Z, away from // the hearth (length +Z, width X, thickness Y). const teardrop = (w: number, t: number, len: number): SheetLevel[] => [ { y: 0, halfWidth: bw * 0.125 * w, thickness: t, curve: 0 }, { y: bl * 0.24 * len, halfWidth: bw * 0.42 * w, thickness: t, curve: 0 }, { y: bl * 0.68 * len, halfWidth: bw * 0.5 * w, thickness: t * 0.95, curve: 0 }, { y: bl * len, halfWidth: bw * 0.33 * w, thickness: t * 0.9, curve: 0 }, ] const plate = (w: number, t: number, len: number, off: number, nose: number, colour: Parameters[2]): BufferGeometry => { const g = dishedSheetGeometry(teardrop(w, t, len), 5, colour) g.rotateX(Math.PI / 2) g.translate(bx0, by + off, zn + nose) return g } const boardOff = bw * 0.195 timber.push(plate(1, 0.035, 1, -boardOff, 0, tint('oak', 0.03 + jitter(random, 0.03)))) timber.push(plate(0.965, 0.035, 0.975, boardOff, 0.014, tint('oak', -0.04 + jitter(random, 0.03)))) // The bag: four stepped teardrop rings, wide fold / narrow valley // alternating, every one with its own width, thickness, length and // nose offset. Wide stays inside the board outline; two plates wider // than the boards is a sliver on the silhouette, already paid for. const pleats = [ { off: -boardOff * 0.66, w: 0.94, t: 0.052, len: 0.9, nose: 0.012 }, { off: -boardOff * 0.24, w: 0.74, t: 0.056, len: 0.84, nose: 0.028 }, { off: boardOff * 0.19, w: 0.92, t: 0.05, len: 0.88, nose: 0.006 }, { off: boardOff * 0.61, w: 0.72, t: 0.054, len: 0.82, nose: 0.022 }, ] as const for (const p of pleats) { hide.push(plate(p.w, p.t, p.len, p.off, p.nose, tint('leather', -0.04 + jitter(random, 0.03), 0.9))) } // The nozzle, the joint of the whole model: a fat iron taper built // along +Y and turned once about X so it runs along Z. Its narrow tip // ends INSIDE the wall slab, its wide base inside the leather nose, and // the visible span between wall face and nose is what reads as tuyere. const nozzle = latheGeometry( [ { y: D / 2 - wall * 0.62, radius: 0.027 }, { y: D / 2 - 0.02, radius: 0.045 }, { y: zn + 0.06, radius: 0.062 }, ] as Level[], 8, [0, 0, 0], tint('iron', 0.02, 0.7), ) nozzle.rotateX(Math.PI / 2) nozzle.translate(bx0, by, 0) metal.push(nozzle) // --- Post, foot, peg, brace ------------------------------------------ const postX = bx0 - bw * 0.3 const postZ = D / 2 + wall * 0.28 + 0.09 const postTop = by + H * 0.47 timber.push(taperedBoxGeometry( [0.17, 0.17], [0.15, 0.15], 0.07, [postX, floor + 0.035, postZ], tint('oak', -0.09), )) timber.push(taperedBoxGeometry( [0.09, 0.09], [0.078, 0.078], postTop - floor - 0.02, [postX, (floor + 0.02 + postTop) / 2, postZ], tint('oak', -0.06), )) // Pivot peg through the post head; the brace lies over it, so the two // read as a hinge rather than a bar glued to a bar. const peg = taperedBoxGeometry( [0.05, 0.05], [0.046, 0.046], 0.24, [0, 0, 0], tint('oak', 0.05), ) peg.rotateZ(Math.PI / 2) peg.translate(postX, postTop - 0.055, postZ) timber.push(peg) // The brace: one rotation about X, in the x = postX plane, from inside // the post head down at a shallow angle to LIE ON the top board near // the tail. Its lower end cap finishes inside the board's thickness; // the board is wider than the post offset there, which is what makes // the constant-X plane land on it. const boardTopY = by + boardOff + 0.0175 const p1y = postTop - 0.065 const p1z = postZ const p2y = boardTopY + 0.012 const p2z = zn + bl * 0.78 const dy = p2y - p1y const dz = p2z - p1z const dl = Math.hypot(dy, dz) const e1 = 0.03 / dl const e2 = 0.05 / dl const a1y = p1y - dy * e1 const a1z = p1z - dz * e1 const a2y = p2y + dy * e2 const a2z = p2z + dz * e2 const brace = taperedBoxGeometry( [0.055, 0.048], [0.05, 0.043], Math.hypot(a2y - a1y, a2z - a1z), [0, 0, 0], tint('oak', 0.02), ) brace.rotateX(Math.atan2(dz, dy)) brace.translate(postX, (a1y + a2y) / 2, (a1z + a2z) / 2) timber.push(brace) return { hearth: { slot: 'stone' as const, geometry: hearth }, fire: { slot: 'char' as const, geometry: coal, extras: [{ slot: 'ember' as const, geometry: mergeColoured(embers) }], }, chimney: { slot: 'stone' as const, geometry: chimney }, bellows: { slot: 'oak' as const, geometry: mergeColoured(timber), extras: [ { slot: 'leather' as const, geometry: mergeColoured(hide) }, { slot: 'iron' as const, geometry: mergeColoured(metal) }, ], }, } }, actions: ({ parts, getConfig }) => { lit = getConfig().lit >= 0.5 // The embers are the fire part's SECOND body; hiding the part itself // would take the charcoal with it and leave an empty stone box. const glow = parts.fire.anchor.children[1] if (glow) glow.visible = lit return { setLit: (on) => { lit = on const body = parts.fire.anchor.children[1] if (body) body.visible = on }, isLit: () => lit, } }, }, overrides) }