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:
parent
3f57165d06
commit
9b4ce16c59
82 changed files with 9725 additions and 5064 deletions
|
|
@ -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]) },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue