Against a photograph this was the worst model in the kit: an amorphous pale lump with spikes in it, where the reference is plainly a drum of straw with three cords round it. Three numbers were doing the damage. The body is a seven-sided lathe -- too few sides to read as round on its own -- and it was then displaced by 0.045 of the height, which is 19 mm against a radius of 210 mm. A tenth of the radius is not texture, it is deformation, and it left no axis to recognise. Meanwhile the ends were pulled in by a third, turning the silhouette into a cinched sack rather than a bale with the broad flat ends the cord holds it square against. Thirteen sides, a sixth of the displacement and a third of the end fade, and the form comes back. The stray stalks are what should say "loose straw", and they can only do that against a body that holds its shape -- so they are shorter too, since at 0.30 of the height they read as spikes driven in rather than stalks working loose. The cords were worse, in the quiet way. The ring radius was written as `0.5 * cinch(x) + cord * 0.35`, which adds four millimetres to a normalised 0.5: the body is built in radius-1 space and squashed afterwards, so anything radial has to be in those units. The hoop stood 0.0044 proud before the squash and 1.85 mm after it. Three cords have been modelled, merged, exported and invisible in every render this model has ever produced. They are now expressed in the body's own units and sit in the groove they pull, which is what a tie does. 27 models, 18257 triangles, all checks pass, 0 floating cases.
210 lines
8.8 KiB
TypeScript
210 lines
8.8 KiB
TypeScript
/**
|
|
* @medieval-kit/hay-bale
|
|
*
|
|
* A bound bundle of straw.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
import type { BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bandGeometry,
|
|
boxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
roughenGeometry,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface HayBaleConfig {
|
|
/** Length (metres). */
|
|
readonly length: number
|
|
/** Height (metres). */
|
|
readonly height: number
|
|
/** Depth (metres). */
|
|
readonly depth: number
|
|
/** How many cord ties. */
|
|
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: 0.88,
|
|
height: 0.42,
|
|
depth: 0.46,
|
|
ropeCount: 3,
|
|
wisps: 34,
|
|
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))
|
|
const halfLength = config.length / 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 => {
|
|
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))
|
|
}
|
|
return tightest
|
|
}
|
|
|
|
// --- Body -----------------------------------------------------------
|
|
const rings = 11
|
|
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)) }
|
|
})
|
|
|
|
// 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),
|
|
})
|
|
// 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 })
|
|
|
|
// --- 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)
|
|
}
|
|
|
|
// --- 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.
|
|
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)
|
|
ropePieces.push(ring)
|
|
}
|
|
|
|
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)
|
|
}
|