/** * @medieval-kit/coin-pouch * * A drawstring leather pouch with silver pennies spilled beside it. * * What names this object is the TOP, not the body. A closed leather bag reads * as a stone; a lathe that pinches and then flares reads as a BOTTLE; a body * widest at mid-height with a shoulder break reads as THROWN POTTERY, which is * the death pass four is answering. The pouch lives in three landmarks: * * 1. the body is a TEARDROP, not a pot (pass four): widest ring ~35% of the * height up from the base, base slumping out fast from a flat contact * patch about a third of the max width, and above the bulge one * continuous concave sweep to the neck, radius = tieR + span*(1-t)^1.6, * with NO shoulder ring. Pass three's profile put the widest loop near * mid-height with a distinct shoulder crease and scored as a clay pot. * 2. the gathered crown: the SAME hide as the body, bunched. Pass four * replaced the ring of separate square prisms (background showed through * the gaps) with tapered WEDGES that touch at their bases: each fold is * wide tangentially at the collar and pinched at the tip, leans out * 10-18 degrees with a small twist about its own axis, and a dark plug * fills the throat so the mouth is a dark slot, never open sky. * 3. the drawstring is a SINGLE TIE (pass four): two wrapped turns, one * knot cluster on one side, and BOTH tails leaving from that knot with * different lengths, bowing away from the wall. Pass three's two * mirrored hangers on opposite sides read as handles. Tail control * points clamp to radiusAt(y) plus a cord radius so no strand can enter * the body on its way down. * * The side seam is a continuous overlapping braid welt (pass four): bars 1.5x * their spacing so consecutive bars overlap, buried wide*0.035 into the wall * with a radial depth that outreaches the roughen amplitude (dropped to * wide*0.02) in both directions. Pass three's separate half-sunk stitches sat * proud of roughened facets and the lowest one detached entirely. * * Body colour is near-uniform: pass three's lighter colourTop lerped to a * visible horizontal band at mid-height that read as a pot lid seam. * * Coins: silver pennies, cool grey (steel slot), all ON THE FLOOR as one * thinning trail whose first coin touches the base. No coin on the pouch: at * this scale a coin bedded into leather cannot be told from a clipping bug. * Every third floor coin leans on its neighbour with its rim on the ground, * offset DERIVED from the chosen tilt. Flat coins alternate sides of the * trail axis so consecutive coins cannot drift into each other. */ import type { BufferGeometry } from 'three' import { bandGeometry, boxGeometry, createKitModel, createTinter, jitter, latheGeometry, mergeColoured, prismGeometry, roughenGeometry, taperedBoxGeometry, type Level, } from '../core/index.ts' export interface CoinPouchConfig { /** Height of the pouch (metres). */ readonly height: number /** Widest radius of the pouch (metres). */ readonly radius: number /** How full it is. 0.3 is half empty and slumped, 1 is stuffed. */ readonly fill: number /** Number of coins spilled outside. */ readonly coins: number /** Coin radius (metres). */ readonly coinRadius: number readonly seed: number } export const coinPouchDefaults: CoinPouchConfig = { height: 0.115, radius: 0.042, fill: 0.85, coins: 9, coinRadius: 0.011, seed: 89, } export type CoinPouchParts = 'pouch' | 'cord' | 'coins' export function createModel(overrides: Partial = {}) { return createKitModel({ id: 'coin-pouch', defaults: coinPouchDefaults, slots: ['leather', 'cloth', 'steel'], build: ({ config, random }) => { const tint = createTinter(random) const fill = Math.max(0.15, Math.min(1, config.fill)) const h = config.height const wide = config.radius * (0.68 + fill * 0.4) // The pinch sits at about 70% of the height; the crown owns the rest. const tieY = h * (0.6 + fill * 0.12) // The tube at the pinch is 40% of the body width: a fat gathered neck, // not a bottleneck. Thinner than this and the vase comes back. const tieR = wide * 0.4 const tubeTop = tieY + h * 0.07 // --- Body ------------------------------------------------------------- // A teardrop: flat contact patch about a third of the max width, fast // slump out to the widest ring at ~35% of the height, then ONE concave // sweep to the neck. No shoulder ring, no flat wide base disc. const maxY = h * (0.33 + 0.07 * fill) const sweep = (t: number): Level => ({ y: maxY + (tieY - maxY) * t, radius: tieR + (wide - tieR) * Math.pow(1 - t, 1.35), }) const profile: Level[] = [ { y: 0, radius: wide * 0.36 }, { y: h * 0.035, radius: wide * 0.64 }, { y: h * 0.12, radius: wide * 0.88 }, { y: maxY * 0.72, radius: wide * 0.97 }, { y: maxY, radius: wide }, sweep(0.22), sweep(0.45), sweep(0.68), sweep(0.88), { y: tieY, radius: tieR }, { y: tubeTop, radius: tieR * 1.08 }, ] const radiusAt = (y: number): number => { for (let i = 0; i < profile.length - 1; i += 1) { const a = profile[i]! const b = profile[i + 1]! if (y >= a.y && y <= b.y) { const t = (y - a.y) / Math.max(1e-9, b.y - a.y) return a.radius + (b.radius - a.radius) * t } } return profile[profile.length - 1]!.radius } const pouchPieces: BufferGeometry[] = [] // Near-uniform colour: a lighter top lerped across the lathe put a hard // horizontal band at mid-height that read as a pot lid seam. const body = latheGeometry(profile, 13, [0, 0, 0], tint('leather', -0.1, 0.85), { capTop: true, }) roughenGeometry(body, wide * 0.02, { salt: 51, scaleY: 0.6 }) pouchPieces.push(body) // Dark plug filling the throat: the mouth is a small dark slot between // the fold tips, never open sky through to the far side. The leather // palette is already dark: a lift below about -0.12 clamps to black and // reads as missing geometry, not shadow, so this stays at -0.11. // Its cap stays BELOW the fold tips: a taller plug lifted its brightly // lit top cap above the folds, where it read as a pale sliver of // background between the tips. pouchPieces.push(prismGeometry( tieR * 0.95, tieR * 0.55, h * 0.16, 8, [0, tubeTop + h * 0.02, 0], tint('leather', -0.11, 0.6), )) // The gathered crown: seven wedge folds of the BODY'S leather (same // lift; a lighter lift here turned a previous crown terracotta). Each // fold is a tapered wedge, wide tangentially at the collar so the bases // TOUCH their neighbours, pinched at the tip, leaning outward 10-18 // degrees with a small twist about its own axis. const foldCount = 7 const crownBase = tieY + h * 0.005 const ringR = tieR * 0.7 const chord = 2 * ringR * Math.sin(Math.PI / foldCount) for (let i = 0; i < foldCount; i += 1) { const a = (i / foldCount) * Math.PI * 2 + jitter(random, 0.06) const foldH = h * (0.16 + random() * 0.05) const baseW = chord * 1.25 const baseD = tieR * 0.6 const lean = 0.18 + random() * 0.14 const twist = 0.06 + jitter(random, 0.15) const col = tint('leather', -0.1 + jitter(random, 0.02), 0.7) const colTip = tint('leather', -0.08 + jitter(random, 0.02), 0.55) const fold = taperedBoxGeometry( [baseW, baseD], [baseW * 0.38, baseD * 0.6], foldH, [0, foldH / 2, 0], col, colTip, ) fold.rotateY(twist) fold.rotateX(lean) fold.rotateY(a) fold.translate(Math.sin(a) * ringR, crownBase, Math.cos(a) * ringR) pouchPieces.push(fold) } // Side seam: a continuous braid welt, the reference's strongest graphic // landmark. Bars 1.5x their spacing overlap into an unbroken run, each // buried wide*0.035 into the wall with enough radial depth to outreach // the roughen amplitude both ways: no bar can hover proud of a facet or // detach from the surface. const coinA = Math.PI / 2 const seamA = coinA - (Math.PI * 2) / 3 const seamLow = h * 0.13 const seamHigh = tieY - h * 0.03 const seamCount = 10 const seamStep = (seamHigh - seamLow) / (seamCount - 1) for (let i = 0; i < seamCount; i += 1) { const y = seamLow + seamStep * i const dY = h * 0.02 const slope = Math.atan2(radiusAt(y + dY) - radiusAt(y - dY), 2 * dY) // Lighter than the body: the bars sit in their own baked occlusion // and a darker lift on top of that read as a CRACK, not a seam. const bar = boxGeometry( [wide * 0.085, seamStep * 1.3, wide * 0.1], [0, 0, 0], tint('leather', -0.03 + jitter(random, 0.015), 0.3), ) bar.rotateZ((i % 2 === 0 ? 1 : -1) * (0.18 + jitter(random, 0.04))) bar.rotateX(slope) bar.rotateY(seamA) const out = radiusAt(y) - wide * 0.035 bar.translate(Math.sin(seamA) * out, y, Math.cos(seamA) * out) pouchPieces.push(bar) } // --- Drawstring: a single tie ----------------------------------------- const cordA = coinA + (Math.PI * 5) / 6 const cordR = wide * 0.045 const cordPieces: BufferGeometry[] = [] // Two turns wrapped around the pinch. cordPieces.push(bandGeometry( tieR + cordR * 1.2, tieY + cordR * 0.9, cordR * 1.7, cordR * 2.6, 10, tint('cloth', -0.3, 0.5), { inner: true }, )) cordPieces.push(bandGeometry( tieR + cordR * 1.45, tieY - cordR * 1.05, cordR * 1.7, cordR * 2.6, 10, tint('cloth', -0.33, 0.5), { inner: true }, )) // Knot cluster on ONE side of the wrap: two overlapping lumps, buried // back into the wrap, that both tails spring from. const knotOut = tieR + cordR * 1.9 const knotPos: readonly [number, number, number] = [ Math.sin(cordA) * knotOut, tieY, Math.cos(cordA) * knotOut, ] const knotA = prismGeometry( cordR * 2.2, cordR * 1.8, cordR * 3, 6, [0, 0, 0], tint('cloth', -0.28, 0.5), ) knotA.rotateX(0.6) knotA.rotateY(cordA) knotA.translate(knotPos[0], knotPos[1], knotPos[2]) cordPieces.push(knotA) const knotB = prismGeometry( cordR * 1.8, cordR * 1.4, cordR * 2.6, 5, [0, 0, 0], tint('cloth', -0.32, 0.5), ) knotB.rotateZ(0.9) knotB.rotateY(cordA) knotB.translate(knotPos[0], knotPos[1] + cordR * 1.1, knotPos[2]) cordPieces.push(knotB) // A lace segment between two points. Overshoots both ends by a cord // radius so every joint ends inside the next solid, never in air. const lace = ( from: readonly [number, number, number], to: readonly [number, number, number], colour: ReturnType, ): BufferGeometry => { const dx = to[0] - from[0] const dy = to[1] - from[1] const dz = to[2] - from[2] const len = Math.max(1e-6, Math.hypot(dx, dy, dz)) const seg = prismGeometry(cordR, cordR * 0.92, len + cordR * 2, 5, [0, 0, 0], colour) seg.rotateZ(-Math.acos(Math.max(-1, Math.min(1, dy / len)))) seg.rotateY(Math.atan2(-dz, dx)) seg.translate((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, (from[2] + to[2]) / 2) return seg } // Both tails leave from the SAME knot, drift the same way around the // body by different amounts, and end at different heights: one tie, // not two mirrored hangers. Each control point's radial distance is // clamped to the wall radius plus a cord clearance so no strand can // pass through the body, and the whole chain hangs from the knot. const tails: ReadonlyArray<{ drift: number; endY: number; lift: number }> = [ { drift: 0.62, endY: h * 0.4, lift: -0.31 }, { drift: 0.28, endY: h * 0.5, lift: -0.34 }, ] for (const tail of tails) { const steps = 4 const pts: Array = [knotPos] for (let k = 1; k <= steps; k += 1) { const s = k / steps const y = tieY + (tail.endY - tieY) * Math.pow(s, 1.15) const az = cordA + tail.drift * s + jitter(random, 0.03) const bow = knotOut + (wide * 1.06 - knotOut) * Math.pow(s, 0.7) const out = Math.max(bow, radiusAt(y) + cordR * 1.5) pts.push([Math.sin(az) * out, y, Math.cos(az) * out]) } const colour = tint('cloth', tail.lift, 0.5) for (let k = 0; k < pts.length - 1; k += 1) { cordPieces.push(lace(pts[k]!, pts[k + 1]!, colour)) } // Knotted bead near the tip, then a short flared tail end whose top // is buried in the bead. const end = pts[pts.length - 1]! const bead = prismGeometry( cordR * 2.1, cordR * 1.7, cordR * 3, 8, [0, 0, 0], tint('cloth', -0.27, 0.5), ) bead.rotateX(jitter(random, 0.15)) bead.rotateY(cordA + tail.drift) bead.translate(end[0], end[1] - cordR * 1.1, end[2]) cordPieces.push(bead) const tip = prismGeometry( cordR, cordR * 0.5, cordR * 2, 4, [0, 0, 0], tint('cloth', -0.3, 0.5), ) tip.rotateY(cordA + tail.drift) tip.translate(end[0], end[1] - cordR * 3, end[2]) cordPieces.push(tip) } // --- Coin trail ------------------------------------------------------- // One thinning trail across the floor, first coin touching the base. // Cool grey silver, not brass. No coins on the pouch itself: at this // scale a coin bedded into leather cannot be told from a clipping bug. const count = Math.max(0, Math.round(config.coins)) const coinPieces: BufferGeometry[] = [] const rimShrink = Math.cos(Math.PI / 9) const mint = (lower: number, thickness: number): BufferGeometry => prismGeometry( lower, lower * (0.96 + random() * 0.08), thickness, 9, [0, 0, 0], tint('steel', -0.05 + jitter(random, 0.04), 0.5), ) const dirX = Math.sin(coinA) const dirZ = Math.cos(coinA) const perpX = Math.cos(coinA) const perpZ = -Math.sin(coinA) let trail = 0 let prevFlat: { x: number; z: number; thickness: number; lower: number } | undefined for (let j = 0; j < count; j += 1) { const lower = config.coinRadius * (0.9 + random() * 0.16) const thickness = config.coinRadius * (0.13 + random() * 0.06) const coin = mint(lower, thickness) const side = j % 2 === 0 ? 1 : -1 if (j % 3 === 2 && prevFlat !== undefined) { // Leans on the previous coin: tilt chosen first, offset DERIVED // from the contact (rim on the ground, underside touching the // previous coin's top rim). Guessing the offset is what made the // pairs clip through each other. const theta = 0.36 + random() * 0.1 const away = coinA + side * (Math.PI / 2) + jitter(random, 0.3) const offset = prevFlat.lower + (prevFlat.thickness * 0.9) / Math.tan(theta) - lower * Math.cos(theta) const px = prevFlat.x + Math.sin(away) * offset const pz = prevFlat.z + Math.cos(away) * offset coin.rotateX(theta) coin.rotateY(away) const rest = lower * rimShrink * Math.sin(theta) + (thickness / 2) * Math.cos(theta) coin.translate(px, rest, pz) } else { const t = trail / Math.max(1, count - 1) // The trail starts CLEAR of the widest ring: a coin under the // bulge's overhang bakes to a near-black speck that reads as a // stray fragment, not as a penny. const d = wide * 1.15 + config.coinRadius * (0.55 + trail * 2.35) + jitter(random, config.coinRadius * 0.25) // Alternating sides of the trail axis: consecutive flat coins can // never wander onto each other. const lat = side * config.coinRadius * (0.4 + t * 0.9) + jitter(random, config.coinRadius * 0.2) const x = dirX * d + perpX * lat const z = dirZ * d + perpZ * lat const tilt = jitter(random, 0.1) coin.rotateX(tilt) coin.rotateY(random() * Math.PI * 2) const rest = Math.abs(Math.sin(tilt)) * lower * rimShrink + (thickness / 2) * Math.cos(tilt) coin.translate(x, rest, z) prevFlat = { x, z, thickness, lower } trail += 1 } coinPieces.push(coin) } return { pouch: { slot: 'leather' as const, geometry: mergeColoured(pouchPieces) }, cord: { slot: 'cloth' as const, geometry: mergeColoured(cordPieces) }, coins: coinPieces.length > 0 ? { slot: 'steel' as const, geometry: mergeColoured(coinPieces) } : undefined, } }, }, overrides) }