medieval-kit/my-registry/models/hay-bale/model.ts

280 lines
12 KiB
TypeScript

/**
* @medieval-kit/hay-bale
*
* A bound bundle of straw, standing on its flat end.
*
* A warning is in order: the rectangular block that comes to mind today when
* you say "bale" is machine work and belongs to the 19th century. In the Middle
* Ages straw was either heaped loose or bound by hand into a bundle. This model
* is that bundle, stood upright the way the reference shows it.
*
* The FOURTH attempt. The dead ends so far:
*
* - The first was slice after slice of boxes and rendered as A PALE WOODEN
* CHEST. Rectangular section + flat faces = joinery. A bound bundle's
* section is round, because the cord that pulls it rounds it.
* - The second fixed the section but laid the cylinder DOWN, near 3:1 long,
* with flat grey strips for the ties. The critic read it as a bedroll with
* steel strapping.
* - The third stood the drum up and swept real arcBar tori for the ropes,
* but scored 61 as "a barrel or a wheel of cheese", and every reason
* traced to three choices:
* 1. colourTop was strawPale and latheGeometry lerps wall colour from
* base to top ACROSS ALL LEVELS, so the whole upper wall drifted to
* pale butter and the top disc went nearly white. Reference wall is
* mid-brown (102, 75, 46). Both tints are now plain `straw`, the
* gradient is a few percent, and an extra mottle pass breaks the
* flatness (reference luminance sd 37 vs our 16).
* 2. the rope tori barely cleared the wall (outer face ~6 mm proud
* against 12 mm of bump), so with the cinch groove they rendered as
* RECESSED dark bands = barrel hoops. The cord centre now sits ON the
* wall radius, outer face ~19 mm proud of the worst bump, in light
* hemp (cloth) so the cords read raised, not cut in.
* 3. per-level radius jitter at 1% stepped the silhouette like stacked
* drums. Now 0.5%, and the profile is one smooth barrel: max
* diameter in the upper third, slight taper to the base, a real
* shoulder easing to a top disc of 0.80 diameter, and a low crown
* instead of a dead-flat full-width cap meeting the wall at 90
* degrees (that hard rim is what said "tub").
* - Modelling errors called out on the third, all placement: base wisps
* drooping through the floor (droop is now clamped so the tip stays above
* y=0), wisps lying flat on or half-sunk in the top disc (top-disc wisps
* now tilt at least 0.5 rad and root under the crown surface), and wisps
* with a visible gap to the wall (roots now sit 7-8% of R inside the
* surface, 28% of the length buried).
*
* The `wisps` config field now means BUNDLES of stray stalks, not single
* stalks: each unit spawns ~2.2 thinner straws so the silhouette breaks
* continuously at the top rim and the base ring, which is where the reference
* breaks. The field's default is unchanged.
*/
import type { BufferGeometry } from 'three'
import {
arcBarGeometry,
boxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
mottleGeometry,
roughenGeometry,
type Level,
} from '../core/index.ts'
export interface HayBaleConfig {
/** Footprint diameter along X (metres). */
readonly length: number
/** Height (metres). The bale stands on its flat end. */
readonly height: number
/** Footprint diameter along Z (metres). */
readonly depth: number
/** How many rope ties around the wall. */
readonly ropeCount: number
/** Number of loose stalks sticking out of the surface. */
readonly wisps: number
/** Surface irregularity. 0 = smooth body. */
readonly rough: number
readonly seed: number
}
export const hayBaleDefaults: HayBaleConfig = {
length: 1.0,
height: 0.74,
depth: 1.0,
ropeCount: 3,
wisps: 40,
rough: 1,
seed: 47,
}
export type HayBaleParts = 'bale' | 'wisps' | 'ropes'
export function createModel(overrides: Partial<HayBaleConfig> = {}) {
return createKitModel<HayBaleConfig, 'straw' | 'cloth', HayBaleParts>({
id: 'hay-bale',
defaults: hayBaleDefaults,
slots: ['straw', 'cloth'],
build: ({ config, random }) => {
const tint = createTinter(random)
const ropes = Math.max(0, Math.round(config.ropeCount))
// Floored: a zero patch on any of these would put NaN into every vertex
// via the squash division, and the occlusion bake spreads one NaN to the
// whole model.
const H = Math.max(0.1, config.height)
const R = Math.max(0.05, config.length / 2)
const squashZ = Math.max(0.1, config.depth) / (R * 2)
// Ropes at 22 / 50 / 78 percent of the height for the default three,
// which is where the reference has them: the outer pair sit nearer the
// rims than an even 25/75 split.
const ropeYs = ropes === 1
? [H * 0.5]
: Array.from({ length: ropes }, (_, i) => H * (0.22 + (0.56 * i) / Math.max(1, ropes - 1)))
// The wall pinches a little where each rope bites. The rope itself
// stands proud; the pinch only has to hint.
const cinch = (y: number): number => {
let tightest = 1
for (const ry of ropeYs) {
const distance = Math.abs(y - ry) / (H * 0.12)
if (distance < 1) tightest = Math.min(tightest, 1 - 0.02 * (1 - distance * distance))
}
return tightest
}
// One smooth barrel: slight taper to the base, maximum diameter at 68%
// of the height, and a shoulder over the last 12% easing in to a top
// disc of 0.80 of the diameter. No steps.
const profile = (t: number): number => {
const swell = 1 - 0.05 * Math.pow(Math.abs(t - 0.68) / 0.68, 1.7)
const s = t <= 0.88 ? 0 : (t - 0.88) / 0.12
return swell * (1 - 0.2 * s * s)
}
const wallR = (y: number): number => R * profile(Math.min(1, Math.max(0, y / H))) * cinch(y)
// --- Body -----------------------------------------------------------
const rings = 16
const levels: Level[] = Array.from({ length: rings }, (_, i) => {
const t = i / (rings - 1)
const y = H * t
// 0.5% jitter. At 1% every level stood out as its own ridge and the
// silhouette stepped like stacked drums.
return { y, radius: wallR(y) * (1 + jitter(random, 0.005)) }
})
// A low crown instead of a flat cap: the top disc rises to a blunt apex
// ~2.5 cm above the rim, closed by the zero-radius level (latheGeometry
// collapses that edge to a point, so there is no separate flat cap).
const rimR = levels[rings - 1]!.radius
levels.push({ y: H + R * 0.03, radius: rimR * 0.62 })
levels.push({ y: H + R * 0.05, radius: 0 })
// Both tints are STRAW. The third attempt used strawPale as colourTop
// and the lathe's base-to-top lerp bleached the whole upper wall; the
// reference wall is mid-brown and its top only a shade lighter.
const body = latheGeometry(levels, 18, [0, 0, 0], tint('straw', -0.06, 0.8), {
colourTop: tint('straw', 0.04, 0.8),
})
body.scale(1, 1, squashZ)
// 6 mm of grain: the rope has to clear every bump and reach below every
// hollow, and the ring maths below assumes this amplitude.
const grain = R * 0.012 * config.rough
roughenGeometry(body, grain, { salt: 11, scaleY: 0.45 })
body.translate(0, -grain * 0.45, 0)
// Straw texture on top of the kit's own mottle pass: the reference wall
// has twice our luminance spread, and facet steps alone do not carry it.
// Measured after the first render of THIS version: wall luminance sd was
// 16 against the reference's 40, so the amount went from 0.22 to 0.34.
mottleGeometry(body, 0.34, { cell: 0.05, salt: 5, hue: 0.6 })
// Crown surface height at a given radius, for seating things ON the top.
const crownY = (r: number): number => {
if (r >= rimR) return H
if (r >= rimR * 0.62) return H + R * 0.03 * ((rimR - r) / (rimR * 0.38))
return H + R * 0.03 + R * 0.02 * (1 - r / (rimR * 0.62))
}
// --- Ropes -----------------------------------------------------------
// Raised cords, not grooves. Cord is ~3% of the diameter across; its
// centre sits ON the wall radius, so the outer face stands ~19 mm proud
// of even the highest bump (jitter 0.5% + grain 6 mm ~ 9 mm) and the
// inner face is buried below the deepest hollow. Light hemp against the
// darkened body: the cord must read as a rope lying ON the straw.
const ropePieces: BufferGeometry[] = []
const cord = R * 0.064
const hempBase = tint('cloth', -0.03, 0.5)
for (const y of ropeYs) {
const centreRadius = wallR(y) + cord * 0.1
const ring = arcBarGeometry(centreRadius, cord, 0, Math.PI * 2, 20,
[0, 0, 0], hempBase.clone())
ring.rotateX(Math.PI / 2)
ring.scale(1, 1, squashZ)
ring.translate(0, y, 0)
ropePieces.push(ring)
}
// The reference top face carries the coil of the binding as circles of
// cord lying on the crown, half-sunk. Thin, and the SAME light hemp as
// the wall cords: a first pass tinted them darker and they rendered as
// charcoal hoops on a lid, which said "wheel", not "binding". Occlusion
// already shades their contact line; the cord itself must stay cord
// coloured.
for (const rr of [rimR * 0.36, rimR * 0.64]) {
const topCord = R * 0.028
const ring = arcBarGeometry(rr, topCord, 0, Math.PI * 2, 16,
[0, 0, 0], tint('cloth', -0.02, 0.5))
ring.rotateX(Math.PI / 2)
ring.scale(1, 1, squashZ)
ring.translate(0, crownY(rr) + topCord * 0.05, 0)
ropePieces.push(ring)
}
// --- Stray stalks ----------------------------------------------------
// Each `wisps` unit spawns ~2.2 thin stalks. Clustered where the
// reference silhouette actually breaks: the top rim and the base ring.
// Every root sits 28% of the stalk length INSIDE the body; base stalks
// clamp their droop so no tip reaches the floor.
const wispPieces: BufferGeometry[] = []
const wispCount = Math.max(0, Math.round(config.wisps * 2.2))
const thickness = R * 0.013
for (let i = 0; i < wispCount; i += 1) {
const wl = R * 2 * (0.07 + random() * 0.09) // 7% to 16% of the diameter
// All stalk tints stay in the pale straw family: a darker minority
// rendered as twigs or debris wherever they crossed the lighter top.
const pale = random() < 0.55
const wisp = boxGeometry(
[wl, thickness * (0.5 + random() * 0.7), thickness],
[wl * 0.22, 0, 0], // spans -0.28..+0.72 of wl: root quarter buried
pale ? tint('strawPale', -0.04, 1.0) : tint('straw', 0.1, 1.0),
)
const a = random() * Math.PI * 2
const lane = i % 10
let rr: number
let y: number
let tilt: number
if (lane < 4) {
// Top rim: outward and upward, breaking the shoulder line. Minimum
// tilt 0.15: at 0.05 a stalk near the shoulder lay almost tangent
// to the crown and read as debris dropped on the top.
tilt = 0.15 + random() * 0.5
y = H * (0.9 + random() * 0.05)
rr = R * 0.82
} else if (lane < 7) {
// Base ring: outward with a slight droop, tip clamped above floor.
y = H * (0.05 + random() * 0.06)
const maxDroop = Math.max(0, (y - 0.012) / (wl * 0.72))
tilt = -Math.min(0.02 + random() * 0.1, maxDroop)
rr = wallR(y) - R * 0.07
} else if (lane < 9) {
// Wall scatter over the mid-height.
y = H * (0.16 + random() * 0.66)
tilt = jitter(random, 0.25)
rr = wallR(y) - R * 0.08
} else {
// Top disc: steep enough that they PROTRUDE (>= 0.5 rad), rooted
// below the crown surface so none lies flat on it.
tilt = 0.5 + random() * 0.6
rr = R * (0.15 + random() * 0.45)
y = crownY(rr) - wl * 0.28 * Math.sin(tilt) - R * 0.02
}
wisp.rotateZ(tilt)
wisp.rotateY(a)
wisp.translate(Math.cos(a) * rr, y, -Math.sin(a) * rr * squashZ)
wispPieces.push(wisp)
}
return {
bale: { slot: 'straw' as const, geometry: mergeColoured([body]) },
wisps: wispPieces.length > 0
? { slot: 'straw' as const, geometry: mergeColoured(wispPieces) }
: undefined,
ropes: ropePieces.length > 0
? { slot: 'cloth' as const, geometry: mergeColoured(ropePieces) }
: undefined,
}
},
}, overrides)
}