313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
/**
|
|
* @medieval-kit/straw-broom
|
|
*
|
|
* Besom: a hazel-rod handle with a bundle of birch twigs bound to its end by a
|
|
* withy tie. The period's broom really was this simple, and that is exactly why
|
|
* it turns up in every interior scene.
|
|
*
|
|
* FOURTH attempt. In the first, the bristles were individual rods and the
|
|
* bundle looked like a whisk; in the second, flat sheaves on one ring made a
|
|
* hollow cone shell ("a lampshade pushed onto a handle"). The third fixed the
|
|
* shell with concentric rings, but the critique still read it as "a decorative
|
|
* whisk or wheat sheaf", and the causes were mass and proportion, not
|
|
* structure:
|
|
*
|
|
* - Shaft and bundle were nearly 1:1 and the shaft was a fat dowel. A besom
|
|
* is a LONG THIN stick with a SHORT dense head: the shaft above the tie is
|
|
* now about 1.6x the bundle and its radius dropped by 40 percent, tapering
|
|
* towards the free end like a cut sapling.
|
|
* - The bundle was bright gold and you could see the background through it.
|
|
* Birch twigs are grey-brown, near the value of the handle itself, so the
|
|
* twig colour is straw pulled towards oak. The count nearly tripled, a
|
|
* fourth ring was added, and a cheap lathe FILLER CONE sits inside the
|
|
* bundle so no sight line passes through it. Fill the middle with a solid,
|
|
* not with more twigs: twigs in the core are invisible anyway.
|
|
* - The flare splayed into a teepee. The tip radius is now about a third of
|
|
* the bundle length rather than half, which also gathers the twig ends so
|
|
* the contact shadow reads as one patch instead of scattered fragments.
|
|
* - Three thin near-black rings floated at the very top. A real withy tie is
|
|
* several adjacent turns forming ONE WRAP BAND over about a quarter of the
|
|
* bundle, in pale withy, not black cord: `bindings` now means turns WITHIN
|
|
* that band, stacked contiguously below the throat.
|
|
*/
|
|
import type { BufferGeometry } from 'three'
|
|
|
|
import {
|
|
arcBarGeometry,
|
|
bandGeometry,
|
|
bendGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
roughenGeometry,
|
|
taperedBoxGeometry,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface StrawBroomConfig {
|
|
/** Total length (metres). */
|
|
readonly length: number
|
|
/** Shaft radius (metres). */
|
|
readonly shaftRadius: number
|
|
/** Length of the bundle, as a fraction of the total length. */
|
|
readonly headLength: number
|
|
/** Bundle radius at the tie (metres). */
|
|
readonly tieRadius: number
|
|
/** Bundle radius at the sweeping end (metres). The flare derives from these two. */
|
|
readonly tipRadius: number
|
|
/** Total number of twigs. */
|
|
readonly bristles: number
|
|
/** Turns of withy within the single wrap band. */
|
|
readonly bindings: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const strawBroomDefaults: StrawBroomConfig = {
|
|
length: 1.2,
|
|
// Was 0.018: a dowel. A cut hazel rod carrying a twig head runs nearer 22 mm
|
|
// across, and the thin shaft is half of what makes the head read as dense.
|
|
shaftRadius: 0.011,
|
|
// Was 0.52, which made shaft and head nearly equal. The reference carries
|
|
// roughly 1.6x as much shaft above the tie as bundle below it, and 0.38
|
|
// gives 0.74 m of shaft over a 0.46 m head.
|
|
headLength: 0.38,
|
|
tieRadius: 0.042,
|
|
// About a third of the bundle length. The old 0.17 against a 0.62 m bundle
|
|
// splayed the skirt into a teepee; the reference fans out gently and stays
|
|
// gathered, and the tighter skirt also pools the ground shadow into one
|
|
// patch under the head.
|
|
tipRadius: 0.14,
|
|
// Tripled from 46. At 46 the gaps between sheaves were wider than the
|
|
// sheaves and the background showed straight through the cone.
|
|
bristles: 130,
|
|
bindings: 5,
|
|
seed: 59,
|
|
}
|
|
|
|
export type StrawBroomParts = 'shaft' | 'bristles' | 'bindings'
|
|
|
|
export function createModel(overrides: Partial<StrawBroomConfig> = {}) {
|
|
return createKitModel<StrawBroomConfig, 'oak' | 'straw' | 'cloth', StrawBroomParts>({
|
|
id: 'straw-broom',
|
|
// The auto-derived values stay too coarse for a 1.2 m object: the ambient
|
|
// occlusion darkens the bundle like a blanket, and the mottle cell drops to
|
|
// a couple of samples per twig. Both are tied to the scale of a twig.
|
|
occlusion: { radius: 0.05 },
|
|
mottle: { cell: 0.016 },
|
|
defaults: strawBroomDefaults,
|
|
slots: ['oak', 'straw', 'cloth'],
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
/**
|
|
* Birch-twig grey-brown: straw pulled towards oak. The palette's straw is
|
|
* bright gold, and a bundle of it read as a wheat sheaf; real besom twigs
|
|
* sit near the value of the handle itself. Both tints are fresh Colors,
|
|
* so the lerp cannot alias (the shared-Color trap).
|
|
*/
|
|
const twigTint = (lift: number, spread = 1.2) =>
|
|
tint('straw', lift, spread).lerp(tint('oak', lift * 0.5, spread * 0.7), 0.45)
|
|
const half = config.length / 2
|
|
const headLength = config.length * config.headLength
|
|
const headTop = -half + headLength
|
|
|
|
// --- The bundle's shape, from a single source ----------------------------
|
|
const tieY = headTop - config.length * 0.017
|
|
const sweepY = -half + config.length * 0.025
|
|
const span = Math.max(0.02, tieY - sweepY)
|
|
const tieRadius = config.tieRadius
|
|
const tipRadius = Math.max(tieRadius * 1.05, config.tipRadius)
|
|
// `tieRadius` and `tipRadius` are the bundle's OUTER radius — what the
|
|
// user would measure looking at a broom. The rings are back-computed from
|
|
// it: outer surface = ring radius + half the twig's width.
|
|
const halfWidth = config.shaftRadius * 0.95
|
|
/** The bundle's OUTER radius at height `y`. The bindings use this too. */
|
|
const bundleRadius = (y: number): number =>
|
|
tieRadius + (tipRadius - tieRadius) * Math.min(1, Math.max(0, (tieY - y) / span))
|
|
|
|
// A broom has a FACE: held in the hand, the same side always meets the
|
|
// floor and that side wears down more. The direction is chosen once,
|
|
// driven by the seed.
|
|
const faceAngle = random() * Math.PI * 2
|
|
|
|
// --- Twigs: four concentric rings, and a filler --------------------------
|
|
// The inner rings have little slope, the outer one has the full slope.
|
|
// That is why the inside of the bundle fills in; a single ring is a shell.
|
|
const total = Math.max(8, Math.round(config.bristles))
|
|
const core = Math.max(config.shaftRadius, tieRadius - halfWidth)
|
|
const rings = [
|
|
{ share: 0.13, radius: core * 0.28, slope: 0.22, offset: 0 },
|
|
{ share: 0.2, radius: core * 0.52, slope: 0.5, offset: Math.PI / 9 },
|
|
{ share: 0.28, radius: core * 0.76, slope: 0.78, offset: Math.PI / 5 },
|
|
{ share: 0.39, radius: core, slope: 1, offset: Math.PI / 13 },
|
|
]
|
|
const fullFlare = Math.atan((tipRadius - tieRadius) / span)
|
|
|
|
const bristles: BufferGeometry[] = []
|
|
for (const ring of rings) {
|
|
const n = Math.max(3, Math.round(total * ring.share))
|
|
for (let i = 0; i < n; i += 1) {
|
|
const angle = (i / n) * Math.PI * 2 + ring.offset + jitter(random, 0.1)
|
|
const flare = fullFlare * ring.slope * (0.92 + random() * 0.16)
|
|
// The worn face is shorter: the asymmetry comes from here, not from
|
|
// flattening the bundle. (The flat fan broom is a 19th-century Shaker
|
|
// invention, an anachronism here.)
|
|
const wear = 1 + 0.07 * Math.cos(angle - faceAngle)
|
|
// The spread runs DOWN from 1.0, never above it: the span is the
|
|
// longest twig and `length` means the whole broom. The tips still
|
|
// feather — a level cut across the bottom is what made version two
|
|
// read as a lampshade — but the variation tightened from a third to
|
|
// a fifth, because at a third the skirt scattered and the critique
|
|
// saw the stray tips' shadows as loose fragments on the ground.
|
|
const length = (span / Math.cos(flare)) * wear * (0.85 + random() * 0.15)
|
|
|
|
const width = config.shaftRadius * (1.05 + random() * 0.24)
|
|
const depth = config.shaftRadius * (0.6 + random() * 0.14)
|
|
// The cross-section TAPERS DOWNWARDS: the sweeping end wears thin.
|
|
const sheaf = taperedBoxGeometry(
|
|
[width * 1.12, depth * 0.62],
|
|
[width, depth],
|
|
length,
|
|
[0, -length / 2, 0], // top end AT THE ORIGIN: the bundle hangs from the tie
|
|
twigTint(-0.03, 1.3),
|
|
twigTint(-0.09, 1.3),
|
|
)
|
|
roughenGeometry(sheaf, config.shaftRadius * 0.09, { salt: i, scaleY: 0.4 })
|
|
|
|
// THE SIGN: the sheaf extends along -Y. `rotateX(+f)` throws its end
|
|
// towards -Z, and the following `rotateY(angle)` turns -Z TOWARDS THE
|
|
// AXIS — so a NEGATIVE flare is what spreads the bundle outwards.
|
|
sheaf.rotateZ(jitter(random, 0.09))
|
|
sheaf.rotateX(-flare)
|
|
sheaf.rotateY(angle)
|
|
sheaf.translate(
|
|
Math.sin(angle) * ring.radius,
|
|
tieY + jitter(random, config.length * 0.005),
|
|
Math.cos(angle) * ring.radius,
|
|
)
|
|
bristles.push(sheaf)
|
|
}
|
|
}
|
|
|
|
// The filler: a dark lathe cone INSIDE the bundle, following the bundle's
|
|
// own profile at 85 percent. Twigs in the core are invisible, so the
|
|
// cheap way to kill every see-through sight line is a solid, not more
|
|
// twigs. It hangs from the tie like everything else and its lower end
|
|
// stops inside the thick of the bundle, hidden by the outer rings.
|
|
const fillLength = span * 0.6
|
|
const filler = latheGeometry(
|
|
[
|
|
{ y: 0, radius: tieRadius * 0.85 },
|
|
{ y: -fillLength * 0.5, radius: bundleRadius(tieY - fillLength * 0.5) * 0.85 },
|
|
{ y: -fillLength, radius: bundleRadius(tieY - fillLength) * 0.8 },
|
|
],
|
|
8, [0, tieY, 0], twigTint(-0.13, 0.7),
|
|
)
|
|
bristles.push(filler)
|
|
|
|
// --- Collar: the cut butts left above the tie ------------------------------
|
|
// It both covers where the shaft enters the bundle and is the cheapest way
|
|
// of saying "this is a bundle": short stubs tipped upwards.
|
|
for (let i = 0; i < 11; i += 1) {
|
|
const angle = i * 2.399963 // golden angle: no rows form anywhere
|
|
const stub = config.shaftRadius * (2.0 + random() * 1.5)
|
|
const piece = taperedBoxGeometry(
|
|
[config.shaftRadius * 0.62, config.shaftRadius * 0.4],
|
|
[config.shaftRadius * 0.5, config.shaftRadius * 0.34],
|
|
stub,
|
|
[0, stub / 2, 0], // centre at the LOWER end: this piece juts upwards
|
|
twigTint(0.03, 1.2),
|
|
twigTint(-0.02, 1.2),
|
|
)
|
|
// This piece extends along +Y, so the sign is the OPPOSITE of the
|
|
// sheaves': a positive value throws its end towards +Z and `rotateY`
|
|
// turns it outwards.
|
|
piece.rotateZ(jitter(random, 0.08))
|
|
piece.rotateX(0.24 + random() * 0.22)
|
|
piece.rotateY(angle)
|
|
piece.translate(
|
|
Math.sin(angle) * tieRadius * 0.72,
|
|
tieY + config.length * 0.008,
|
|
Math.cos(angle) * tieRadius * 0.72,
|
|
)
|
|
bristles.push(piece)
|
|
}
|
|
|
|
// --- Binding: one wrap band of several turns -------------------------------
|
|
// Not three thin cords at the throat: a withy tie is wound turn against
|
|
// turn until the wrap covers about a quarter of the bundle, and it is
|
|
// pale peeled withy, lighter than the twigs, not near-black cord (the old
|
|
// -0.28 lift bottomed out on the tinter's floor).
|
|
const turns = Math.max(0, Math.round(config.bindings))
|
|
const wrapLength = span * 0.19
|
|
const turnHeight = wrapLength / Math.max(1, turns)
|
|
const bindings: BufferGeometry[] = []
|
|
for (let i = 0; i < turns; i += 1) {
|
|
const y = tieY - config.length * 0.004 - turnHeight * (i + 0.5)
|
|
// Each turn sits JUST proud of the bundle's OUTER surface at ITS OWN
|
|
// height and bites into it, so the stack follows the flare. When the
|
|
// turns stood a fifth of a shaft radius proud in pale withy the wrap
|
|
// stepped outwards like a beehive skep and dominated the whole head; a
|
|
// tie is a thin skin over the twigs, not a basket around them.
|
|
const radius = bundleRadius(y) + config.shaftRadius * (0.08 + jitter(random, 0.04))
|
|
bindings.push(bandGeometry(
|
|
radius, y, turnHeight * 0.96, config.shaftRadius * 0.9, 12,
|
|
tint('cloth', -0.04, 0.8).lerp(tint('straw', -0.05, 0.8), 0.5),
|
|
{ inner: true },
|
|
))
|
|
}
|
|
if (turns > 0) {
|
|
// The tucked withy end: the one piece that shows how the tie itself is
|
|
// fastened. Offset so it does not stay parallel to the band's 30° facet.
|
|
const withyY = tieY - config.length * 0.004 - wrapLength * 0.4
|
|
const withy = arcBarGeometry(
|
|
bundleRadius(withyY) + config.shaftRadius * 0.35, config.shaftRadius * 0.5,
|
|
-0.5, 0.9, 3, [0, 0, 0],
|
|
tint('cloth', -0.07, 0.7).lerp(tint('straw', -0.08, 0.7), 0.5),
|
|
)
|
|
withy.rotateX(Math.PI / 2)
|
|
withy.rotateY(0.26 + Math.PI / 12)
|
|
withy.translate(0, withyY, 0)
|
|
bindings.push(withy)
|
|
}
|
|
|
|
// --- Shaft -------------------------------------------------------------------
|
|
// Not a turned spindle but a hazel rod cut in the forest: the radius
|
|
// wavers along its length and the rod TAPERS TOWARDS THE FREE END, the
|
|
// way a cut sapling does — the old version swelled at the top like a
|
|
// turned grip and read as a dowel. The whittled thin end is the one
|
|
// buried in the bundle. And it is slightly bent — a straight rod always
|
|
// reads as manufactured.
|
|
//
|
|
// Built AT THE ORIGIN and bent, THEN translated: bending it at its final
|
|
// coordinates would fling the whole stick away.
|
|
const shaftBottom = headTop - config.length * 0.12
|
|
const shaftLength = half - shaftBottom
|
|
const r = config.shaftRadius
|
|
const shaftLevels: Level[] = [
|
|
{ y: -shaftLength / 2, radius: r * 0.55 },
|
|
{ y: -shaftLength / 2 + shaftLength * 0.06, radius: r * 1.1 },
|
|
{ y: -shaftLength / 2 + shaftLength * 0.2, radius: r * 1.04 },
|
|
{ y: shaftLength * 0.05, radius: r * 0.97 },
|
|
{ y: shaftLength / 2 - shaftLength * 0.12, radius: r * 0.88 },
|
|
{ y: shaftLength / 2, radius: r * 0.7 },
|
|
].map((level) => ({ y: level.y, radius: level.radius * (1 + jitter(random, 0.05)) }))
|
|
|
|
const shaft = latheGeometry(shaftLevels, 6, [0, 0, 0], tint('oak', -0.05), {
|
|
colourTop: tint('oak', 0.05),
|
|
})
|
|
bendGeometry(shaft, jitter(random, 0.22) / shaftLength)
|
|
shaft.rotateY(random() * Math.PI * 2)
|
|
shaft.translate(0, shaftBottom + shaftLength / 2, 0)
|
|
|
|
return {
|
|
shaft: { slot: 'oak' as const, geometry: mergeColoured([shaft]) },
|
|
bristles: { slot: 'straw' as const, geometry: mergeColoured(bristles) },
|
|
bindings: bindings.length > 0
|
|
? { slot: 'cloth' as const, geometry: mergeColoured(bindings) }
|
|
: undefined,
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|