/** * @medieval-kit/round-shield * * A planked round shield with a rawhide rim and an iron boss. * * The kit had no arms or armour at all, which for a medieval catalogue is a * whole category missing rather than a gap. This is the cheapest way in: a * shield is the one piece of war gear that stands on its own in a scene — * leaning on a wall, stacked by a door — without needing a person to hold it. * * Three things make it a shield rather than a disc, and all three are * structural rather than decorative: * * - It is BOARDS, not a plate. A round shield is planks butted edge to edge * and held by the rim and the boss; the plank lines are the first thing * the eye finds and a smooth face reads as a lid. * - The rim binds the ENDS of those planks. Without it the boards would * split off one by one at the first blow, and without it in the model the * silhouette is a wafer. * - The boss is a dome over a HOLE. The hand goes behind it, gripping a bar * across the back, so the boss is not ornament — it is the knuckle guard, * and it is why the centre of the face is iron. * * The face is quartered because that is what a painted shield looks like and * because it costs nothing: each plank is built in two halves anyway, so the * upper and lower halves simply take different colours. `hue` turns both * quarters together and keeps them opposite, so the slider always produces a * pair that belongs together. */ import { Color, type BufferGeometry } from 'three' import { bandGeometry, boxGeometry, createKitModel, createTinter, jitter, latheGeometry, mergeColoured, prismGeometry, taperedBoxGeometry, type Level, } from '../core/index.ts' export interface RoundShieldConfig { /** Radius of the shield (metres). */ readonly radius: number /** Planks across the face. */ readonly planks: number /** Thickness of the boards (metres). */ readonly thickness: number /** How far it leans back from upright (radians). */ readonly lean: number /** Paint colour, 0–1 around the wheel. The second quarter sits opposite. */ readonly hue: number /** Rivets around the boss. */ readonly rivets: number readonly seed: number } export const roundShieldDefaults: RoundShieldConfig = { radius: 0.36, planks: 9, thickness: 0.016, // Barely off vertical. A shield propped against something stands almost // upright; at 0.22 it read as lying back against nothing. lean: 0.12, hue: 0.02, rivets: 10, seed: 83, } export type RoundShieldParts = 'boards' | 'rim' | 'boss' export function createModel(overrides: Partial = {}) { return createKitModel({ id: 'round-shield', defaults: roundShieldDefaults, slots: ['oak', 'leather', 'iron'], build: ({ config, random }) => { const tint = createTinter(random) const R = config.radius const T = config.thickness const planks = Math.max(3, Math.round(config.planks)) const hue = ((config.hue % 1) + 1) % 1 // Saturated and dark. At 0.42 saturation and 0.34 lightness the face // came out pastel against a reference whose red and blue are deep enough // to read as pigment ground in oil -- which is what they were. Shield // paint is not a wash. const paint = (h: number, dark: number): Color => new Color().setHSL( (h + jitter(random, 0.01) + 1) % 1, 0.66 + jitter(random, 0.05), dark + jitter(random, 0.03), ) // --- Boards --------------------------------------------------------- // Each plank is built in two halves so the face can be quartered without // any extra geometry: the split the paint needs is a split the boards // already have to make anyway. const boards: BufferGeometry[] = [] const step = (R * 2) / planks const upper = paint(hue, 0.26) const lower = paint((hue + 0.55) % 1, 0.23) for (let i = 0; i < planks; i += 1) { const x = -R + step * (i + 0.5) // The plank is a chord of the circle, so its length is set by how far // out it sits. Squaring off every plank at full width would give a // square with a rim drawn on it. // // The chord is measured an eighth of the way out, not at the plank's // outer edge, and the plank NARROWS to that quarter width at the rim so // its far corners sit just inside the rim rather than outside it. // // Measured at the outer edge, which is what this did, the outermost // plank of nine came out exactly zero long and was dropped by the guard // below. Both edge planks vanished, the board field ended a full plank // short of the rim on each side, and the shield rendered with the // background showing through it. The ones that survived stepped in and // out against the rim in a sawtooth, because every one of them stopped // where its own outer corner met the circle. const halfSpan = Math.sqrt(Math.max(0, R * R - Math.pow(Math.abs(x) + step * 0.12, 2))) if (halfSpan <= step * 0.2) continue for (const half of [-1, 1]) { // The two halves of one plank are quartered on the DIAGONAL: the // left-hand planks take one colour above and the other below, and // the right-hand ones the reverse. That is what makes a quartered // shield read as quartered rather than as banded. const left = x < 0 const colour = (half < 0) === left ? upper : lower boards.push(taperedBoxGeometry( [step * 0.97, T], [step * 0.86, T * 0.92], halfSpan, [x, half * halfSpan * 0.5, 0], new Color(colour), )) } } // --- Rim ------------------------------------------------------------ // A rawhide binding folded over the edge. It is a band whose thickness // reaches INSIDE the boards' own radius, so it grips them rather than // sitting against their ends. // Thick enough to SWALLOW the plank ends. // // The boards are rectangles cut to the chord of the circle, so their ends // step in and out around the edge -- unavoidable, since a plank is // straight and the outline is not. A real rim hides exactly that: it is a // wide band of hide folded over the ends and stitched, and it is what // turns a stack of boards into a disc. At 0.055 of the radius it was too // narrow to cover the steps and the silhouette came out serrated. const rim = bandGeometry( R * 1.02, 0, T * 3.2, R * 0.105, planks * 3, tint('leather', -0.04, 0.9), { inner: true }, ) rim.rotateX(Math.PI / 2) // The cross between the quarters, laid ON the face. // // Two bands of pale paint dividing the four quarters. It is the detail // that makes a quartered shield read at a distance -- without it the // quarters meet edge to edge and the eye sees two colours rather than a // device. Proud of the boards rather than inlaid, because paint is. const trim: BufferGeometry[] = [] const band = R * 0.1 const cream = new Color().setHSL(0.11, 0.24, 0.74) for (const vertical of [false, true]) { const arm = taperedBoxGeometry( vertical ? [band, T * 0.5] : [R * 1.94, T * 0.5], vertical ? [band * 0.97, T * 0.4] : [R * 1.9, T * 0.4], vertical ? R * 1.94 : band, [0, 0, T * 0.62], new Color(cream), ) trim.push(arm) } // --- Boss ----------------------------------------------------------- const bossR = R * 0.26 const iron: BufferGeometry[] = [] // The dome. Its back is open, because behind it is the hole the hand // goes into, and a cap there would be a face nobody can see. iron.push(latheGeometry( [ { y: 0, radius: bossR * 0.98 }, { y: bossR * 0.42, radius: bossR * 0.86 }, { y: bossR * 0.78, radius: bossR * 0.54 }, { y: bossR * 0.94, radius: bossR * 0.16 }, ] as Level[], 11, [0, 0, 0], tint('iron', 0.05, 0.7), { capBottom: false }, // +90, not -90. The dome is turned about Y, and -90 sends its point // to -Z -- into the back of the shield, where the only thing visible // from the front was the open rim of it. That is why the boss read as a // flat black ring. ).rotateX(Math.PI / 2).translate(0, 0, T * 0.5)) // The flange it is riveted through. iron.push(bandGeometry( bossR * 1.34, 0, T * 1.1, bossR * 0.36, 11, tint('iron', -0.02, 0.7), { inner: true }, ).rotateX(Math.PI / 2).translate(0, 0, T * 0.55)) const rivets = Math.max(0, Math.round(config.rivets)) for (let i = 0; i < rivets; i += 1) { const a = (i / rivets) * Math.PI * 2 + 0.2 // Flared outwards, like the barrel's: a stud that tapers away from the // surface tilts its side normals back towards it, which the radial // check reads as reversed winding, and a hammered rivet flares anyway. const stud = prismGeometry( R * 0.016, R * 0.022, T * 1.4, 4, [0, 0, 0], tint('iron', 0.09, 0.6), { capBottom: false }, ) stud.rotateX(-Math.PI / 2) iron.push(stud.translate( Math.sin(a) * bossR * 1.16, Math.cos(a) * bossR * 1.16, T * 0.8, )) } // --- Back ------------------------------------------------------------ // The grip bar across the hole, and two battens holding the planks. Cheap // and invisible from the front, but a shield seen from behind with // nothing to hold is worse than one with no back at all. const back: BufferGeometry[] = [] back.push(boxGeometry( [R * 1.2, R * 0.09, T * 1.6], [0, 0, -T * 1.1], new Color(tint('oak', -0.1)), )) for (const at of [-0.46, 0.46]) { back.push(boxGeometry( [R * 1.5, R * 0.07, T * 1.2], [0, at * R, -T * 0.9], new Color(tint('oak', -0.14)), )) } const face = mergeColoured([...boards, ...trim, ...back]) const boss = mergeColoured(iron) // Leaned back from UPRIGHT, not tipped up from flat. // // Everything is authored in the XY plane facing +Z, which is already a // shield standing on its edge. Rotating by -90 degrees and then adding // the lean laid it face-up on the floor with a slight tilt, which is a // shield somebody dropped. The lean is the whole rotation. for (const g of [face, rim, boss]) g.rotateX(config.lean) // Set down by measuring, not by trigonometry: the boss stands proud of // the face and the rim wraps past it, so which piece is lowest depends on // the lean and is not worth deriving. const all = mergeColoured([face, rim, boss]) all.computeBoundingBox() const drop = all.boundingBox?.min.y ?? 0 for (const g of [face, rim, boss]) g.translate(0, -drop, 0) return { boards: { slot: 'oak' as const, geometry: face }, rim: { slot: 'leather' as const, geometry: rim }, boss: { slot: 'iron' as const, geometry: boss }, } }, }, overrides) }