The head read as a lampshade pushed onto a stick, and two numbers were responsible. The spray angle is derived as atan((tip - tie) / span), which at a tip radius of 0.102 came to five degrees. Five degrees is not a besom; the reference fans out until the spread at the sweeping end is comparable to the length of the bundle itself, which is about twenty. The tip radius carries that, and the slider range moves with it. The twig lengths varied by three percent, so every twig finished within a few millimetres of its neighbours and the bundle ended in a straight cut across the bottom. That single edge is most of what made it look manufactured: no bundle of cut birch ends level, and the ragged tip is what identifies a besom on sight. Lengths now spread by about a third -- downwards only, never above the nominal span. Centring the spread on 1.0 first produced a 1.32 m broom from a declared length of 1.2, which the size guard caught and which was a real inconsistency rather than a tolerance problem. Two supporting changes. The bundle ran from the binding down for 36% of the broom against roughly half in the reference, so it is longer. And each sheaf was a 31 mm slat, which at thirty-two of them read as a fan of laths; they are narrower now and there are forty-six, keeping about the same mass in the bundle with the pieces at the right scale. One correction to the record: the head was not, as I first estimated by eye, a fifth of the broom. Measured, it was 46% of the whole and 36% below the binding. The eye was reading a diagonal reference against a vertical render. 27 models, 18021 triangles, all checks pass, 0 floating cases.
288 lines
14 KiB
TypeScript
288 lines
14 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.
|
|
*
|
|
* THIRD attempt. In the first, the bristles were individual rods and the bundle
|
|
* looked like a whisk; in the second I moved to flat sheaves, which brought
|
|
* mass, but what came out in render still was not a broom: "a lampshade pushed
|
|
* onto a handle", "a closed umbrella". The cause was single and structural —
|
|
* all the sheaves sat on ONE SINGLE RING, i.e. the bundle was a hollow cone
|
|
* SHELL. On top of that, since it gathered at a single point up top and opened
|
|
* downwards, its silhouette was a cone, whereas a besom is a slightly flared
|
|
* CYLINDER.
|
|
*
|
|
* Three changes fix this:
|
|
*
|
|
* - The bundle is built from three CONCENTRIC rings (6 / 10 / 16). The inner
|
|
* rings have less slope, so the middle fills in. The shell becomes mass.
|
|
* - The flare is no longer a hand-tuned angle: the tie radius and the tip
|
|
* radius are given, and the slope is DERIVED from the two. What determines
|
|
* the silhouette is two directly measurable numbers.
|
|
* - The radius of the bindings comes from the same source as the bundle's
|
|
* radius AT THAT HEIGHT. There used to be a separate guessed formula, and
|
|
* the binding hung in the air around the bundle.
|
|
*/
|
|
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
|
|
/** How many turns of binding. */
|
|
readonly bindings: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const strawBroomDefaults: StrawBroomConfig = {
|
|
length: 1.2,
|
|
shaftRadius: 0.018,
|
|
// Measured from the binding down, the old default left only 36% of the
|
|
// broom as bundle against roughly half in the reference -- the head looked
|
|
// stuck on the end of an over-long stick.
|
|
headLength: 0.52,
|
|
tieRadius: 0.058,
|
|
// A 20-degree spray, not a 5-degree one. Derived rather than guessed:
|
|
// atan((tip - tie) / span), which at the old 0.102 came to five degrees and
|
|
// gave a narrow cone. The reference fans out until the spread at the sweeping
|
|
// end is comparable to the length of the bundle itself.
|
|
tipRadius: 0.17,
|
|
bristles: 46,
|
|
bindings: 3,
|
|
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 sheaf. Both are tied to the scale of a twig.
|
|
occlusion: { radius: 0.055 },
|
|
mottle: { cell: 0.022 },
|
|
defaults: strawBroomDefaults,
|
|
slots: ['oak', 'straw', 'cloth'],
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
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 sheaf'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: three concentric rings -----------------------------------------
|
|
// The inner rings have little slope, the outer one has the full slope.
|
|
// That is why the inside of the bundle fills in; had I left a single ring
|
|
// it would be a shell again.
|
|
const total = Math.max(6, Math.round(config.bristles))
|
|
const core = Math.max(config.shaftRadius, tieRadius - halfWidth)
|
|
const rings = [
|
|
{ share: 0.19, radius: core * 0.34, slope: 0.3, offset: 0 },
|
|
{ share: 0.31, radius: core * 0.62, slope: 0.68, offset: Math.PI / 7 },
|
|
{ share: 0.5, radius: core * 0.92, slope: 1, offset: Math.PI / 14 },
|
|
]
|
|
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.1 * Math.cos(angle - faceAngle)
|
|
// Lengths vary by nearly a third, not by three percent.
|
|
//
|
|
// At +-0.03 every twig finished within a few millimetres of its
|
|
// neighbours and the bundle ended in a straight cut across the
|
|
// bottom. That single edge is what made the head read as a lampshade
|
|
// pushed onto a stick: no bundle of cut birch ends level, and the
|
|
// ragged, feathered tip is most of what identifies a besom in a
|
|
// photograph. Nothing is left floating by this -- every twig is held
|
|
// at the tie, and it is only the free end that varies.
|
|
// The spread runs DOWN from 1.0, never above it. Centring it on 1.0
|
|
// let the longest twigs overshoot the sweeping end and the model came
|
|
// out 1.32 m for a declared length of 1.2 -- caught by the size
|
|
// guard, and a real inconsistency, since `length` is supposed to mean
|
|
// the whole broom. The span is the longest twig; the rest fall short
|
|
// of it by up to a third.
|
|
const length = (span / Math.cos(flare)) * wear * (0.68 + random() * 0.32)
|
|
|
|
// Narrower, and more of them. At 1.7 shaft radii each sheaf was a
|
|
// 31 mm slat, and thirty-two slats read as a fan of laths rather
|
|
// than as a bundle of twigs. Trading width for count keeps roughly
|
|
// the same mass in the bundle while making the individual pieces
|
|
// read at the right scale.
|
|
const width = config.shaftRadius * (1.05 + random() * 0.24)
|
|
const depth = config.shaftRadius * (0.6 + random() * 0.14)
|
|
// The cross-section TAPERS DOWNWARDS. Previously it was exactly the
|
|
// reverse — the lower end was both wider and thicker, i.e. the bundle
|
|
// swelled towards the bottom; whereas the sweeping end wears thin
|
|
// over the years.
|
|
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
|
|
tint('straw', 0.02, 1.5),
|
|
tint('strawPale', 0.12, 1.5),
|
|
)
|
|
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 positive sign flares the bundle INWARDS, not outwards.
|
|
// This was the case in the previous two versions too: the sheaves
|
|
// crossed the axis and were flung to the far side, which is why the
|
|
// bundle came out a hollow shell. Caught by measurement, not by eye —
|
|
// the bundle's width came out 0.13 m where 0.23 m was expected.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// --- 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 < 9; i += 1) {
|
|
const angle = i * 2.399963 // golden angle: no rows form anywhere
|
|
const stub = config.shaftRadius * (1.6 + random() * 1.2)
|
|
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
|
|
tint('strawPale', 0.16, 1.4),
|
|
tint('straw', 0.04, 1.4),
|
|
)
|
|
// 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.28 + random() * 0.24)
|
|
piece.rotateY(angle)
|
|
piece.translate(
|
|
Math.sin(angle) * tieRadius * 0.8,
|
|
tieY + config.length * 0.008,
|
|
Math.cos(angle) * tieRadius * 0.8,
|
|
)
|
|
bristles.push(piece)
|
|
}
|
|
|
|
// --- Bindings --------------------------------------------------------------
|
|
const turns = Math.max(0, Math.round(config.bindings))
|
|
const bindings: BufferGeometry[] = []
|
|
for (let i = 0; i < turns; i += 1) {
|
|
const y = tieY - config.length * (0.01 + i * 0.042)
|
|
// The binding sits on the bundle's OUTER surface and bites into it a
|
|
// little. Originally the radius was computed from the ring radius, so
|
|
// the binding stayed INSIDE the sheaves and was never visible at all.
|
|
const radius = bundleRadius(y) - config.shaftRadius * 0.16
|
|
bindings.push(bandGeometry(
|
|
radius, y, config.shaftRadius * 1.15, config.shaftRadius * 0.42, 12,
|
|
tint('cloth', -0.28, 0.9), { 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 withy = arcBarGeometry(
|
|
bundleRadius(tieY) + config.shaftRadius * 0.24, config.shaftRadius * 0.32,
|
|
-0.5, 0.9, 3, [0, 0, 0], tint('cloth', -0.34, 0.7),
|
|
)
|
|
withy.rotateX(Math.PI / 2)
|
|
withy.rotateY(0.26 + Math.PI / 12)
|
|
withy.translate(0, tieY - config.length * 0.006, 0)
|
|
bindings.push(withy)
|
|
}
|
|
|
|
// --- Shaft -------------------------------------------------------------------
|
|
// Not a turned spindle but a hazel rod cut in the forest: the radius
|
|
// wavers along its length, there is a grip swell where the hand holds it,
|
|
// and the top end is whittled. 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.1
|
|
const shaftLength = half - shaftBottom
|
|
const r = config.shaftRadius
|
|
const shaftLevels: Level[] = [
|
|
{ y: -shaftLength / 2, radius: r * 0.26 },
|
|
{ y: -shaftLength / 2 + shaftLength * 0.07, radius: r * 0.82 },
|
|
{ y: -shaftLength / 2 + shaftLength * 0.16, radius: r * 1.02 },
|
|
{ y: shaftLength * 0.06, radius: r * 0.93 },
|
|
{ y: shaftLength / 2 - shaftLength * 0.07, radius: r * 1.14 }, // grip
|
|
{ y: shaftLength / 2, radius: r * 0.84 },
|
|
].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)
|
|
}
|