/** * @medieval-kit/leather-book * * Leather-bound manuscript with a clasp. Table, shelf, inside a chest, lectern. * * The whole point of the model is the PAGE BLOCK. What makes a book a book is * not its cover but the uneven mass of paper spilling past that cover. Give it * as a flat box and it reads like a brick no matter how good the cover is. * * HISTORY OF DEAD ENDS, do not repeat them: * * - v1 had the pages OVERHANGING the boards (1.035 of the cover) and read as * a ream of paper with a leather lid. The boards overhang the pages; the * overhang is called the square. Flush at the spine, inset on the three * free edges. * - v2 gave the spine as a thin dark slab inset between the boards, with * band tabs sitting on its outer face: black void at the joint, floating * tabs, and a colour split from the boards it is one piece of leather * with. * - v3 rebuilt the spine as an elliptical prism but oversized everything * around it and the critic read the whole book as a chest. The failures, * each of which cost points: * - The raised bands were built on radius `half + 4mm` and scaled to * stand 1.8mm proud, but `bandGeometry`'s FIRST argument is the OUTER * radius and the hoop grows INWARD; adding the 8mm wall on top of an * already padded radius put the hoops 11mm proud of the boards, and * with the spine crown at 1.06 * half the whole back read as a black * barrel the boards were embedded in. The spine semi-axis in y is now * EXACTLY `half` (tangent to both cover planes, crown vertex on the * board edge line) and the bands are the same ellipse plus about a * millimetre, dying into the leather just before the cover planes. * - Corner plates with domed bosses sat on BOTH boards; the bottom set * protruded below the board and the book stood on eight gold pads * with daylight under it. Metal furniture now lives on the FRONT * cover only and nothing whatsoever reaches below -half: the book * rests on the whole underside of the back board. * - The metal was tinted straight from `brass` and read as bright * yellow blocks against the reference's tarnished pewter. Fittings * now tint from the `bronze` PALETTE entry (dark weathered metal, * see materials.ts: palette carries colour, slot carries material) * desaturated further toward grey, still on the `brass` slot. * - The clasp strap crossed the page recess and stopped at the page * edge: attached to nothing at the bottom. The riser now spans board * edge to board edge, buried a little into both, with a hinge block * on the back board and a catch plate and pin under the tongue. * - Page slabs were sized `(thickness - bt * 1.9) * (0.985 + r*0.015)`; * at the low end of the jitter that is SHORTER than the board gap and * the critic saw open air between the text block and the back board. * Slab thickness jitter now only ever ADDS overshoot into the boards. * * Two period details drove the geometry: * * - The pages are NOT PAPER but vellum, thick and wavy. The fore edge is * built from vertical slabs each pushed in or out a little, so the block * reads as a stack of leaves rather than a cream brick. * - The book DOES NOT stay shut: vellum swells. That is why manuscripts * have a clasp — function, not ornament. */ import { Color, type BufferGeometry } from 'three' import { bandGeometry, boxGeometry, chamferedBoxGeometry, createKitModel, createTinter, jitter, latheGeometry, mergeColoured, prismGeometry, roughenGeometry, } from '../core/index.ts' export interface LeatherBookConfig { /** Cover width (metres). */ readonly width: number /** Cover length (metres). */ readonly length: number /** Total thickness of the closed book (metres). */ readonly thickness: number /** Number of raised bands on the spine. */ readonly bands: number /** Number of clasps. */ readonly clasps: number readonly seed: number } export const leatherBookDefaults: LeatherBookConfig = { width: 0.19, length: 0.27, thickness: 0.062, bands: 4, clasps: 1, seed: 79, } export type LeatherBookParts = 'cover' | 'pages' | 'clasps' export function createModel(overrides: Partial = {}) { return createKitModel({ id: 'leather-book', defaults: leatherBookDefaults, slots: ['leather', 'cloth', 'brass'], build: ({ config, random }) => { const tint = createTinter(random) /** * The book's own leather, desaturated and dropped from the palette entry. * * Measured against the reference the cover sat 0.21 over on saturation and * 0.12 over on lightness: a reddish tan where the photograph is a dark, * nearly grey brown. The palette entry itself is right where it is, and it * cannot move, because `coin-pouch` shares it and measures within 0.05 of * its own reference. So the correction belongs to the model that is wrong, * not to the constant they both read. */ const leather = (lift = 0, spread = 0.9): Color => { const c = tint('leather', lift, spread) c.offsetHSL(0, -0.24, -0.06) return c } /** * Tarnished pewter for the fittings: the `bronze` palette entry (dark * weathered bell metal) pushed further toward grey. Deliberately NOT * `brass`: v3's fittings tinted from brass and read as bright yellow * blocks where the reference has dull grey plates. */ const pewter = (lift = 0): Color => { const c = tint('bronze', lift, 0.5) c.offsetHSL(0, -0.1, 0.03) return c } const half = config.thickness / 2 const halfWidth = config.width / 2 // About 8% of the total height each: thin boards over a thick vellum // block, matching the reference's proportions. The v2 "boards must be // massive" note is superseded — what made v1 read as a ream was the // pages OVERHANGING, not the boards being thin. const boardThickness = config.thickness * 0.085 // --- Covers ------------------------------------------------------------ const cover: BufferGeometry[] = [] for (const side of [-1, 1]) { cover.push(chamferedBoxGeometry( [config.width, config.length], [config.width * 0.995, config.length * 0.997], boardThickness, boardThickness * 0.28, [0, side * (half - boardThickness / 2), 0], leather(side > 0 ? 0.03 : -0.03), )) } // --- Spine -------------------------------------------------------------- // One piece of leather with the boards, a SHALLOW arc: an elliptical // prism whose y semi-axis is exactly `half`, so the crown vertex lands // on the board edge line and the silhouette is tangent to both cover // planes — it never rises above the top board or below the bottom one, // and the seam at the joint closes without a void. The x semi-axis is // small: the backbone throws only ~12mm past the board edge. const semiX = 0.012 const spine = prismGeometry(half, half, config.length * 0.995, 16, [0, 0, 0], leather(0.045)) // Built upright: rotate at the origin so the axis lies along the book, // squash the circle into the ellipse, THEN translate. spine.rotateX(Math.PI / 2) spine.scale(semiX / half, 1, 1) spine.translate(-halfWidth, 0, 0) cover.push(spine) // Raised bands: the sewing cords under the leather, wrapping ACROSS the // curve of the back. Full hoops around the spine axis; the far half is // buried inside the spine, boards and page block. Scaled to sit about a // millimetre proud of the leather at the backbone and to sink UNDER it // just before the cover planes (y semi-axis a hair below the spine's), // so they never break the top or bottom silhouette and never touch the // ground. const bandCount = Math.max(0, Math.round(config.bands)) for (let i = 0; i < bandCount; i += 1) { const t = (i + 1) / (bandCount + 1) const band = bandGeometry( half, // OUTER radius — the 8mm wall below grows INWARD from here. (t - 0.5) * config.length * 0.85, config.length * 0.035, 0.008, 12, leather(0.085), // The inner face is not optional here. `bandGeometry` leaves it out by // default because a hoop normally wraps a body that hides it, and a // band without one is an open surface: four of them left 96 unbalanced // edges and the book stopped being a closed solid. Its own docstring // says so. The buried half will never be seen, and 24 triangles is // what closing it costs. { inner: true }, ) band.rotateX(Math.PI / 2) band.scale((semiX + 0.0011) / half, (half - 0.0002) / half, 1) band.translate(-halfWidth, 0, 0) cover.push(band) } // --- Page block --------------------------------------------------------- // The BOARDS overhang the pages (the square, ~4% of the width). Flush // into the spine, inset on the three free edges. Built as seven vertical // slabs whose fore edges are each pushed in or out a little, with // chamfered rims, so the fore edge is a faceted stack of leaves and not // a flat plane. Every slab overshoots INTO both boards — the jitter only // ever adds overshoot, so there is never a gap between the block and the // board it sits on — and stops well inside them, so nothing reaches a // cover surface. const square = config.width * 0.04 const slabCount = 7 const blockLen = config.length - square * 2 const dz = blockLen / slabCount const slabs: BufferGeometry[] = [] for (let i = 0; i < slabCount; i += 1) { const xFore = halfWidth - square + jitter(random, square * 0.15) const xBack = -halfWidth + 0.004 + jitter(random, 0.0006) const w = xFore - xBack const slabT = config.thickness - boardThickness * (1.2 + random() * 0.5) const zLen = dz * 1.12 slabs.push(chamferedBoxGeometry( [w, zLen], [w * 0.985, zLen * 0.99], slabT, config.thickness * 0.04, [(xFore + xBack) / 2, 0, -blockLen / 2 + dz * (i + 0.5)], tint('cloth', 0.06 + random() * 0.05, 0.7), )) } const pages = mergeColoured(slabs) // Vellum is not flat: a light ripple on top of the facets. roughenGeometry(pages, config.thickness * 0.012, { salt: 31, scaleY: 0.25 }) // --- Clasp ---------------------------------------------------------------- // Hinged to the BACK board, wrapping the fore edge, lapping a short // tongue onto the front cover where a catch plate and pin receive it. // The riser is buried a little into both board edges so every piece is // held; nothing crosses the page recess unattached and nothing reaches // below the back board's underside. const claspCount = Math.max(0, Math.round(config.clasps)) const strapW = config.width * 0.055 const strapT = 0.0018 const metal: BufferGeometry[] = [] for (let i = 0; i < claspCount; i += 1) { const t = claspCount === 1 ? 0.5 : 0.2 + (i / (claspCount - 1)) * 0.6 const z = (t - 0.5) * config.length * 0.6 // Hinge block on the back board's fore-edge face. Its inner face is // 2 mm into the board rather than a tenth of a millimetre inside the // riser's: two parallel faces that overlap and sit that close are a // z-fight, whichever of them is meant to be in front. // Its underside stops 1.6 mm up the board for the same reason: it used // to end 0.1 mm under the riser's foot, directly beneath it. A knuckle // does not run the whole thickness of a board anyway. const hingeIn = halfWidth - 0.002 const hingeOut = halfWidth + strapT * 1.3 const hingeBot = -half + 0.0016 const hingeTop = -half + boardThickness * 0.85 metal.push(boxGeometry( [hingeOut - hingeIn, hingeTop - hingeBot, strapW * 1.25], [(hingeIn + hingeOut) / 2, (hingeBot + hingeTop) / 2, z], pewter(-0.02), )) // Riser up the fore edge, board edge to board edge, inner face buried // 0.8mm into both boards. const yTop = half + 0.0002 const yBot = -half + 0.0004 metal.push(boxGeometry( [strapT, yTop - yBot, strapW], [halfWidth + strapT / 2 - 0.0008, (yTop + yBot) / 2, z + jitter(random, 0.001)], pewter(0.0), )) // Tongue lapping onto the front cover, bottom face sunk 0.5mm in. const tongueL = config.width * 0.035 + 0.0016 metal.push(boxGeometry( [tongueL, strapT, strapW], [halfWidth + 0.0016 - tongueL / 2, half + strapT / 2 - 0.0005, z + jitter(random, 0.001)], pewter(0.045), )) // The pin the tongue ends against, rooted in the cover rather than // standing on it. // // A catch plate used to sit under the tongue and it was doing nothing: // 1.2 mm thick, seated 0.3 mm inside a 1.8 mm tongue, so it was buried // for the whole length the two shared and the pair z-fought on both // faces. All that showed of it was a 2.4 mm lip past the tongue's tip. // The pin was already the catch. // // It passes THROUGH the tongue rather than ending inside it. The two // overlap by 1.2 mm of the tongue's width, so a pin rooted 0.2 mm under // the tongue and stopping 0.6 mm under its top puts two parallel faces // that close on both sides of it. Rooted 1.8 mm into the cover and // standing 1.3 mm proud of the strap, it is a stud through an eye. const xCatch = halfWidth + 0.0016 - tongueL metal.push(prismGeometry(0.0016, 0.001, 0.0044, 6, [xCatch - 0.0002, half + 0.0004, z], pewter(0.06))) } // --- Corner plates ------------------------------------------------------ // Tarnished pewter furniture on the FRONT cover only: a thin plate at // each corner with a round lobe cusping toward the cover field, thin // flaps turning down over the fore and head/tail edges, and a domed // rivet boss at the centre. The back board carries nothing, so the book // rests flat on its whole underside. const wp = config.width * 0.155 const plateH = 0.0016 const flapT = 0.0016 for (const sx of [-1, 1]) { for (const sz of [-1, 1]) { // The spine-side pair stops clear of the hinge joint; there is no // board edge there to turn a flap over. const px = sx > 0 ? halfWidth - wp / 2 - 0.0012 : -halfWidth + wp / 2 + 0.004 const pz = sz * (config.length / 2 - wp / 2 - 0.0012) const plateColour = pewter(0.01) // Plate, bottom sunk 0.4mm into the board. metal.push(chamferedBoxGeometry( [wp, wp], [wp * 0.9, wp * 0.9], plateH, 0.0004, [px, half + plateH / 2 - 0.0004, pz], plateColour, )) // There is no cusp lobe. It sat 0.32 mm under the plate's own top // face and overlapped it, which is a z-fight rather than a step, and // a plate 1.6 mm thick has no room for a step: the only separate // piece that clears both its faces is a 4 mm knob. The chamfer // already reads as cast furniture at 29 mm square. // // Domed rivet boss. Its base is BELOW the plate, not inside it, for // the same reason: a base seated anywhere within 1.6 mm of stock // lands within a millimetre of one of the two faces. Seated in the // board instead, the flare reads as a rivet head drawn down into the // plate, and it keeps its height above the plate by growing. metal.push(latheGeometry( [ { y: 0, radius: wp * 0.3 }, { y: 0.0034, radius: wp * 0.24 }, { y: 0.0056, radius: wp * 0.1 }, ], 10, [px, half - 0.0018, pz], pewter(0.07), )) const flapTop = half + plateH * 0.5 const flapBot = half - boardThickness * 0.75 const yFlap = (flapTop + flapBot) / 2 const flapH = flapTop - flapBot // Flap over the fore edge, hanging down the board's x face. if (sx > 0) { metal.push(boxGeometry( [flapT, flapH, wp * 0.88], [halfWidth + flapT / 2 - 0.0006, yFlap, pz], plateColour, )) } // Flap over the head/tail edge, hanging down the board's z face. metal.push(boxGeometry( [wp * 0.88, flapH, flapT], [px, yFlap, sz * (config.length / 2 + flapT / 2 - 0.0006)], plateColour, )) } } return { cover: { slot: 'leather' as const, geometry: mergeColoured(cover) }, pages: { slot: 'cloth' as const, geometry: pages }, clasps: metal.length > 0 ? { slot: 'brass' as const, geometry: mergeColoured(metal) } : undefined, } }, }, overrides) }