medieval-kit/my-registry/models/leather-book/model.ts

375 lines
17 KiB
TypeScript

/**
* @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<LeatherBookConfig> = {}) {
return createKitModel<LeatherBookConfig, 'leather' | 'cloth' | 'brass', LeatherBookParts>({
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)
}