538 lines
23 KiB
TypeScript
538 lines
23 KiB
TypeScript
/**
|
|
* @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<ForgeHearthConfig> = {}) {
|
|
let lit = true
|
|
|
|
return createKitModel<ForgeHearthConfig, 'stone' | 'char' | 'ember' | 'oak' | 'leather' | 'iron', ForgeHearthParts, ForgeHearthActions>({
|
|
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<typeof dishedSheetGeometry>[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)
|
|
}
|