Bring the anvil's horn back to its reference proportion, rebuild the book's metal furniture so no two overlapping faces share a plane, and release 0.2.0.

This commit is contained in:
CRTkafa 2026-09-01 03:00:37 +07:00
parent 3f57165d06
commit 9b4ce16c59
82 changed files with 9725 additions and 5064 deletions

View file

@ -1,47 +1,76 @@
/**
* @medieval-kit/hay-bale
*
* A bound bundle of straw.
* 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 the second.
* is that bundle, stood upright the way the reference shows it.
*
* The SECOND attempt. The first was built from slice after slice of boxes and
* one look at the render made the call obvious: it looked like A PALE WOODEN
* CHEST. There were two separate mistakes, both about form, not colour:
* The FOURTH attempt. The dead ends so far:
*
* - The cross-section was rectangular. Sharp corner + flat face = joinery. A
* bound bundle's section is round, because the cord that pulls it rounds it.
* - The surfaces were perfectly flat. Straw is nowhere flat.
* - 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).
*
* So the body is now a lathe body — a cylinder that narrows where the cord
* cinches it and is then broken up with `roughenGeometry`. Those two changes
* made the model recognisable without the colour changing at all.
* 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 {
bandGeometry,
arcBarGeometry,
boxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
mottleGeometry,
roughenGeometry,
type Level,
} from '../core/index.ts'
export interface HayBaleConfig {
/** Length (metres). */
/** Footprint diameter along X (metres). */
readonly length: number
/** Height (metres). */
/** Height (metres). The bale stands on its flat end. */
readonly height: number
/** Depth (metres). */
/** Footprint diameter along Z (metres). */
readonly depth: number
/** How many cord ties. */
/** How many rope ties around the wall. */
readonly ropeCount: number
/** Number of loose stalks sticking out of the surface. */
readonly wisps: number
@ -51,11 +80,11 @@ export interface HayBaleConfig {
}
export const hayBaleDefaults: HayBaleConfig = {
length: 0.88,
height: 0.42,
depth: 0.46,
length: 1.0,
height: 0.74,
depth: 1.0,
ropeCount: 3,
wisps: 34,
wisps: 40,
rough: 1,
seed: 47,
}
@ -70,131 +99,172 @@ export function createModel(overrides: Partial<HayBaleConfig> = {}) {
build: ({ config, random }) => {
const tint = createTinter(random)
const ropes = Math.max(0, Math.round(config.ropeCount))
const halfLength = config.length / 2
// 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)
// The body is built first as a lathe body of RADIUS 1, because it is then
// squashed into the real width/depth ratio. That way the amount the cord
// pulls in is a single number — no need to work it out per axis.
const ropeXs = Array.from({ length: ropes }, (_, i) =>
ropes === 1 ? 0 : (i / (ropes - 1) - 0.5) * config.length * 0.54)
const cinch = (x: number): number => {
// 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 rx of ropeXs) {
const distance = Math.abs(x - rx) / (config.length * 0.19)
if (distance < 1) tightest = Math.min(tightest, 1 - 0.13 * (1 - distance * distance))
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 = 11
const rings = 16
const levels: Level[] = Array.from({ length: rings }, (_, i) => {
const t = i / (rings - 1)
const x = -halfLength + config.length * t
// The ends round off: the first and last hoop are clearly narrower, or
// else the bundle looks like a pipe cut off at both ends.
// 0.12, not 0.34. A bound bale has broad, nearly flat ends -- the
// cord holds the straw square against them. Pulling them in by a third
// turned the silhouette into a cinched sack, and combined with the
// roughening below there was no cylinder left to recognise.
const endFade = 1 - Math.pow(Math.abs(t - 0.5) * 2, 6) * 0.12
return { y: x, radius: 0.5 * cinch(x) * endFade * (1 + jitter(random, 0.05)) }
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 })
// Thirteen segments, not seven. The body carries the only strong shape
// in the model and a seven-sided prism does not read as round.
const body = latheGeometry(levels, 13, [0, 0, 0], tint('straw', -0.06, 1.6), {
colourTop: tint('strawPale', 0.02, 1.6),
// 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),
})
// Built upright and then laid down: the lathe helper works around the Y
// axis, whereas the bundle runs along X.
body.rotateZ(Math.PI / 2)
body.scale(1, config.height, config.depth)
// The displacement was 0.045 of the height -- 19 mm against a radius of
// 210 mm, nearly a tenth. Straw is not flat, but a tenth of the radius is
// not texture, it is deformation: the bale came out as an amorphous lump
// with no axis. The stray stalks are what should carry the impression of
// loose straw, and they can only do that against a body that holds its
// shape.
roughenGeometry(body, config.height * 0.016 * config.rough, { salt: 11 })
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 })
// --- Stray stalks ----------------------------------------------------
// The loose stalks on the surface and the stem ends spraying out of the
// two ends. The second matters: in a bound bundle the CUT ends of the
// stalks are always at the two ends, and that is the only sign that makes
// the bundle read as "cut plant".
const wispPieces: BufferGeometry[] = []
const wispCount = Math.max(0, Math.round(config.wisps))
const thickness = config.height * 0.016
for (let i = 0; i < wispCount; i += 1) {
const fromEnd = i % 3 === 0
// Shorter on the sides. At up to 0.30 of the height these read as
// spikes driven into the bale rather than as stalks working loose.
const length = config.height * (fromEnd ? 0.16 + random() * 0.2 : 0.06 + random() * 0.09)
const wisp = boxGeometry(
[length, thickness * (0.6 + random() * 0.9), thickness],
[length * 0.3, 0, 0], // root behind the origin: buried in the body
tint('strawPale', 0.05, 1.4),
)
if (fromEnd) {
// End stalks: outward along the X axis, scattering a little.
const side = i % 6 === 0 ? 1 : -1
const angle = random() * Math.PI * 2
const radius = 0.5 * (0.15 + random() * 0.8)
wisp.rotateZ(jitter(random, 0.4))
wisp.rotateX(jitter(random, 0.4))
if (side < 0) wisp.rotateY(Math.PI)
wisp.translate(
side * halfLength * (0.86 + random() * 0.1),
Math.sin(angle) * radius * config.height,
Math.cos(angle) * radius * config.depth,
)
} else {
// Surface stalks: outward from the side surface of the body.
const x = (random() - 0.5) * config.length * 0.88
const angle = random() * Math.PI * 2
const shrink = cinch(x)
wisp.rotateZ(jitter(random, 0.9))
wisp.rotateY(angle + Math.PI / 2)
wisp.translate(
x,
Math.sin(angle) * 0.47 * config.height * shrink,
Math.cos(angle) * 0.47 * config.depth * shrink,
)
}
wispPieces.push(wisp)
// 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))
}
// --- Cords -----------------------------------------------------------
// Since the body is round, the cord is now built from a real HOOP rather
// than from four bars. It goes through the same squashing transform, so
// it sits exactly on the bundle's cross-section.
// --- 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 = config.height * 0.045
for (const x of ropeXs) {
// Free-standing hoop: it needs its inner face too, or it is not solid.
// Radius and thickness in NORMALISED units, width in metres.
//
// The ring is built in the same radius-1 space as the body and squashed
// by the same transform, so anything radial has to be expressed the way
// the body is. The old line added `cord * 0.35` -- 4 mm in metres -- to
// a normalised 0.5, which left the hoop standing 0.0044 proud before
// the squash and 1.85 mm after it. The cords were modelled, merged,
// exported and invisible in every render.
//
// The cord sits in the groove it pulls: its outer face comes back out
// to just under where the bale's surface would be without the cinch,
// which is what a tie does.
const outer = 0.5 * (cinch(x) + 0.09)
const ring = bandGeometry(outer, 0, cord * 1.8, 0.5 * 0.06, 9,
tint('cloth', -0.07), { inner: true })
ring.rotateZ(Math.PI / 2)
ring.scale(1, config.height, config.depth)
ring.translate(x, 0, 0)
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]) },