/** * @medieval-kit/wooden-crate * * A crate is not a box: it is rows of horizontal boards nailed to four corner * posts. The thin gaps between them, and the posts standing proud of them, are * what give the silhouette its "assembled" reading. This model builds it that * way. * * It shares the same core as the barrel: the same oak tone, the same * deterministic randomness, the same vertex-colour technique. That is why they * look as if they came from the same catalogue when you stand them side by * side. */ import { Color, type BufferGeometry } from 'three' import { chamferedBoxGeometry, createKitModel, createRandom, jitter, MEDIEVAL_PALETTE, mergeColoured, type Vec3, } from '../core/index.ts' export interface WoodenCrateConfig { /** Width (X axis, metres). */ readonly width: number /** Height (metres). */ readonly height: number /** Depth (Z axis, metres). */ readonly depth: number /** Number of horizontal board rows on each face. */ readonly plankRows: number /** Iron strap count. 0 = plain wooden crate. */ readonly strapCount: number /** Variation seed. */ readonly seed: number } export const woodenCrateDefaults: WoodenCrateConfig = { width: 0.66, height: 0.52, depth: 0.52, // Four rows, not three. The reference reads as a stack of boards and at // three courses each one is deep enough to look like a panel with a line // scored across it. plankRows: 4, strapCount: 2, seed: 3, } export type WoodenCrateParts = 'posts' | 'planks' | 'straps' const SLOTS = ['oak', 'iron'] as const type Slot = (typeof SLOTS)[number] export function createModel(overrides: Partial = {}) { return createKitModel({ id: 'wooden-crate', defaults: woodenCrateDefaults, slots: SLOTS, build: ({ config, random }) => { /** * The dimension contract. * * Z-FIGHTING RULE: no two surfaces may overlap in the same plane facing the * same way. In real joinery the parts interlock, and we do the same here. The * posts stand proud of the boards, the lid and the floor overhang the frame a * little, the boards are butt-jointed to one another. That way every surface * is alone in its own plane. */ const dims = () => { const post = Math.min(config.width, config.depth) * 0.11 const board = post * 0.5 return { post, board, /** How far the posts stand proud of the board surface. */ postProud: board * 0.45, /** Overhang of the lid and the floor beyond the frame. */ overhang: board * 0.6, half: config.height / 2, } } function buildPosts(random: () => number): BufferGeometry { const { post, board, half } = dims() const tint = new Color() const pieces: BufferGeometry[] = [] const x = config.width / 2 - post / 2 const z = config.depth / 2 - post / 2 // The posts run INTO the lid and the floor; because their ends stay // inside those solid pieces they are invisible and align with nothing. const reach = half - board * 0.3 for (const [sx, sz] of [[-1, -1], [1, -1], [1, 1], [-1, 1]] as const) { tint.copy(MEDIEVAL_PALETTE.oak) // Posts are a bit darker than the body: a different cut, more wear. tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), -0.045 + jitter(random, 0.02)) pieces.push(chamferedBoxGeometry( [post, post], [post, post], reach * 2, board * 0.16, [sx * x, 0, sz * z], tint, )) } return mergeColoured(pieces) } function buildPlanks(random: () => number): BufferGeometry { const { board, postProud, overhang, half } = dims() const tint = new Color() const pieces: BufferGeometry[] = [] // The side boards are pulled BEHIND the posts: their outer faces are not at // ±width/2 but at ±(width/2 - postProud). That is why they never share a plane // with the posts. const faceX = config.width / 2 - postProud - board / 2 const faceZ = config.depth / 2 - postProud - board / 2 // Butt joint: the front/back boards bear against the inner face of the side // boards. They touch but do not overlap — edge contact produces no z-fighting. const spanX = (config.width / 2 - postProud - board) * 2 const spanZ = (config.depth / 2 - postProud - board) * 2 // The rows reach far enough to enter the lid and the floor, but they end // at a DIFFERENT height than the ends of the posts. const wallTop = half - board * 0.65 const rows = Math.max(1, config.plankRows) const gap = config.height * 0.015 const rowHeight = (wallTop * 2 - gap * (rows - 1)) / rows for (let row = 0; row < rows; row += 1) { const y = -wallTop + rowHeight / 2 + row * (rowHeight + gap) for (const side of [-1, 1] as const) { tint.copy(MEDIEVAL_PALETTE.oak) tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), jitter(random, 0.06)) pieces.push(chamferedBoxGeometry( [spanX, board], [spanX, board], rowHeight, board * 0.16, [0, y, side * faceZ], tint, )) tint.copy(MEDIEVAL_PALETTE.oak) tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), jitter(random, 0.06)) pieces.push(chamferedBoxGeometry( [board, spanZ], [board, spanZ], rowHeight, board * 0.16, [side * faceX, y, 0], tint, )) } } /** * The diagonal brace, which is the thing the reference leads with. * * One board corner post to corner post across each long face, standing * proud of the courses by half its own thickness so it crosses them * instead of joining them. Without it the long face is a field of * parallel horizontals and the crate reads as a panelled box; with it * the face reads as framed boarding, which is what it is. */ const diagLen = Math.hypot(spanX, wallTop * 2) * 0.99 const diagAngle = Math.atan2(spanX, wallTop * 2) for (const side of [-1, 1] as const) { tint.copy(MEDIEVAL_PALETTE.oak) tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), 0.03 + jitter(random, 0.04)) const brace = chamferedBoxGeometry( [board * 0.95, board * 0.8], [board * 0.95, board * 0.8], diagLen, board * 0.14, [0, 0, 0], tint, ) // Built standing; laid over onto the diagonal. rotateZ sends +Y to // (-sin, cos), so the angle is negated to send it toward +X. brace.rotateZ(-side * diagAngle) brace.translate(0, 0, side * (faceZ + board * 0.45)) pieces.push(brace) } // Lid and floor: board sheets that sit on top of and under the frame and // overhang it a little. The overhang keeps their side faces from aligning // with the faces of the posts. const slabWidth = config.width + overhang * 2 const slabDepth = config.depth + overhang * 2 const slabBoards = 3 const slabGap = slabDepth * 0.014 const boardDepth = (slabDepth - slabGap * (slabBoards - 1)) / slabBoards for (const [y, shade] of [[half - board / 2, 0.03], [-half + board / 2, -0.05]] as const) { for (let i = 0; i < slabBoards; i += 1) { tint.copy(MEDIEVAL_PALETTE.oakEnd) tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), shade + jitter(random, 0.05)) pieces.push(chamferedBoxGeometry( [slabWidth, boardDepth], [slabWidth, boardDepth], board, board * 0.16, [0, y, -slabDepth / 2 + boardDepth / 2 + i * (boardDepth + slabGap)], tint, )) } } return mergeColoured(pieces) } function buildStraps(random: () => number): BufferGeometry | undefined { if (config.strapCount <= 0) return undefined const { post } = dims() const tint = new Color() const pieces: BufferGeometry[] = [] const bandHeight = config.height * 0.07 const proud = post * 0.3 for (let i = 0; i < config.strapCount; i += 1) { // The straps sit symmetrically from the top and the bottom inwards. const t = config.strapCount === 1 ? 0 : 0.6 - (1.2 * i) / (config.strapCount - 1) const y = (t * config.height) / 2 tint.copy(MEDIEVAL_PALETTE.iron) tint.offsetHSL(0, jitter(random, 0.02), jitter(random, 0.06)) // The front/back straps stand proud at the corners; the side straps end // BEFORE reaching them. That way the top faces of the four pieces do not // sit on top of each other at the corner. pieces.push( chamferedBoxGeometry([config.width + proud * 2, proud], [config.width + proud * 2, proud], bandHeight, proud * 0.22, [0, y, config.depth / 2], tint), chamferedBoxGeometry([config.width + proud * 2, proud], [config.width + proud * 2, proud], bandHeight, proud * 0.22, [0, y, -config.depth / 2], tint), chamferedBoxGeometry([proud, config.depth - proud], [proud, config.depth - proud], bandHeight, proud * 0.22, [config.width / 2, y, 0], tint), chamferedBoxGeometry([proud, config.depth - proud], [proud, config.depth - proud], bandHeight, proud * 0.22, [-config.width / 2, y, 0], tint), ) } 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 postsPart = buildPosts(random) const planksPart = buildPlanks(random) const strapsPart = buildStraps(random) return { posts: { slot: 'oak' as const, geometry: postsPart }, planks: { slot: 'oak' as const, geometry: planksPart }, straps: strapsPart ? { slot: 'iron' as const, geometry: strapsPart } : undefined, } }, }, overrides) }