375 lines
17 KiB
TypeScript
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)
|
|
}
|