medieval-kit/my-registry/models/bronze-bell/model.ts

386 lines
16 KiB
TypeScript

/**
* @medieval-kit/bronze-bell
*
* Bronze bell hung from a yoke. Church tower, village square, ship's deck.
*
* The most "machine" like piece in the kit: the bell itself is easy, the real
* problem is making it WORK. Two bodies swing independently of each other —
*
* - The bell travels back and forth on the yoke axis like a damped pendulum.
* - The CLAPPER is on the same axis but LAGGING. That lag is exactly what
* rings the bell: as the bell moves one way the clapper falls behind, then
* catches up and hits the rim. If the two moved together the bell would be
* silent — and that is exactly what happened in my first attempt, where I
* made the clapper an `extras` body of the bell: it swung, nothing rang.
*
* NO sound. A model cannot make assumptions about the scene's audio system;
* whoever needs it reads the `actions.strikes()` counter and plays its own.
*/
import type { BufferGeometry } from 'three'
import {
bandGeometry,
boxGeometry,
chamferedBoxGeometry,
createKitModel,
createTinter,
flipGeometry,
jitter,
latheGeometry,
mergeColoured,
prismGeometry,
type Level,
} from '../core/index.ts'
export interface BronzeBellConfig {
/** Mouth diameter of the bell (metres). */
readonly diameter: number
/** Height of the bell itself (metres). */
readonly height: number
/** Length of the yoke rail, as a ratio of the diameter. */
readonly yoke: number
/** Angle at full swing (degrees). */
readonly swing: number
/** Damping rate. A larger value comes to rest sooner. */
readonly damping: number
readonly seed: number
}
export const bronzeBellDefaults: BronzeBellConfig = {
diameter: 0.36,
height: 0.4,
yoke: 1.35,
swing: 34,
damping: 0.55,
seed: 67,
}
export type BronzeBellParts = 'bell' | 'clapper' | 'yoke' | 'frame'
export interface BronzeBellActions {
/** Rings the bell: starts the pendulum off at a full swing. */
ring(): void
/** Stops the motion instantly. */
still(): void
/** Whether the bell is still swinging. */
isRinging(): boolean
/**
* How many times the clapper has hit the rim. A consumer that wants sound
* watches this counter: if it went up after `update()`, a strike happened.
*/
strikes(): number
}
export function createModel(overrides: Partial<BronzeBellConfig> = {}) {
// Pendulum state lives OUTSIDE the build: `configure()` must not silence it.
let angle = 0
let velocity = 0
let clapper = 0
let clapperVelocity = 0
let strikes = 0
let lastSide = 0
return createKitModel<BronzeBellConfig, 'brass' | 'iron' | 'oak', BronzeBellParts, BronzeBellActions>({
id: 'bronze-bell',
defaults: bronzeBellDefaults,
slots: ['brass', 'iron', 'oak'],
build: ({ config, random }) => {
const tint = createTinter(random)
const radius = config.diameter / 2
// Axis of rotation: the yoke rail. Everything is placed relative to it.
const pivotY = config.height * 0.62
const half = config.height / 2
// --- Bell body ----------------------------------------------------------
// The bell curve is not arbitrary: an upright shoulder up top, a concave
// waist in the middle, a skirt flaring toward the mouth and a thickening
// lip at the bottom (the sound bow). Without that lip the silhouette
// looks like a plastic funnel.
const bellProfile: Level[] = [
{ y: -half + config.height * 0.02, radius: radius },
{ y: -half + config.height * 0.07, radius: radius * 0.855 }, // sharp flare of the lip
{ y: -half + config.height * 0.15, radius: radius * 0.75 },
{ y: -half + config.height * 0.36, radius: radius * 0.665 }, // waist: almost straight
{ y: -half + config.height * 0.6, radius: radius * 0.615 },
{ y: -half + config.height * 0.79, radius: radius * 0.57 },
{ y: -half + config.height * 0.92, radius: radius * 0.49 }, // shoulder turn
{ y: half, radius: radius * 0.38 },
]
// The bell is a SHELL: an outside, an inside, and a lip joining the two
// at the mouth. Closed on top (the crown is solid), open at the mouth.
const skirt = latheGeometry(bellProfile, 12, [0, 0, 0], tint('bronze', -0.06, 0.7), {
colourTop: tint('bronze', 0.05, 0.7),
capBottom: false, // mouth OPEN: the inside of the bell must show
capTop: true,
})
// Inner surface. Its winding is reversed with `flipGeometry` — mirroring
// with `scale(-1, 1, 1)` flips the normals too, but it mirrors the
// geometry as well and left the cap facing the wrong way: that is why a
// hole showed at the top of the bell.
const innerProfile = bellProfile.map((level) => ({
y: level.y + config.height * 0.02,
radius: level.radius * 0.88,
}))
const inner = flipGeometry(latheGeometry(
innerProfile, 12, [0, 0, 0], tint('bronze', -0.24, 0.5),
{ capBottom: false, capTop: false },
))
// Mouth lip: the hoop joining the inner and outer shell. Without it a gap
// as wide as the shell is left at the rim and the bell's inside is void.
const lipY = -half + config.height * 0.02
const lip = bandGeometry(
radius * 0.995, lipY, config.height * 0.035,
radius * 0.115, 12, tint('bronze', -0.12, 0.6),
)
// Crown: the ears that fasten the bell to the yoke.
const crown: BufferGeometry[] = [skirt, inner, lip]
for (let i = 0; i < 3; i += 1) {
const a = (i / 3) * Math.PI * 2
const ear = chamferedBoxGeometry(
[radius * 0.13, radius * 0.1],
[radius * 0.1, radius * 0.08],
config.height * 0.16,
radius * 0.03,
[0, 0, 0],
tint('bronze', 0.08, 0.6),
)
ear.translate(Math.sin(a) * radius * 0.17, half + config.height * 0.05, Math.cos(a) * radius * 0.17)
crown.push(ear)
}
const bell = mergeColoured(crown)
bell.translate(0, -pivotY, 0) // bring the hinge to the origin
// --- Clapper -----------------------------------------------------------
// Hanging shaft + ball. The shaft drops down from the axis, the ball sits
// just above the line of the mouth: a real clapper strikes the bell's lip.
const drop = pivotY + half * 0.55
const stem = prismGeometry(radius * 0.035, radius * 0.028, drop, 5,
[0, -drop / 2, 0], tint('iron', -0.02, 0.7))
const ball = latheGeometry([
{ y: -radius * 0.12, radius: radius * 0.04 },
{ y: -radius * 0.06, radius: radius * 0.11 },
{ y: radius * 0.04, radius: radius * 0.115 },
{ y: radius * 0.1, radius: radius * 0.05 },
], 7, [0, -drop, 0], tint('iron', 0.05, 0.7))
const clapperGeometry = mergeColoured([stem, ball])
// --- Yoke ---------------------------------------------------------------
// DOES NOT SWING: the fixed piece the bell hangs from. It has no origin
// of its own, so it just stands in model space.
const beamLength = config.diameter * config.yoke
const beamY = pivotY + radius * 0.2
// Where the uprights stand. Computed here, before the beam, because the
// beam has to REACH them: decoupling the two (so the frame could not
// close onto the bell at a short yoke) left the rail shorter than the
// gap it spans at the bottom of the slider, and the two A-frames came
// apart into separate objects with nothing joining them.
const standX = Math.max(beamLength * 0.4, radius * 1.3)
const beamSpan = Math.max(beamLength, standX * 2.2)
// The rail is ALREADY built horizontal: the first two arguments of
// `chamferedBoxGeometry` are the X–Z footprint, the third the Y height.
// I had put a `rotateZ` here and it stood the rail upright, sticking out
// of the top of the bell like a post — the price of misremembering which
// axis the helper counts as "height".
const beam = chamferedBoxGeometry(
[beamSpan, radius * 0.24],
[beamSpan * 0.98, radius * 0.21],
radius * 0.3,
radius * 0.04,
[0, beamY, 0],
tint('oak', 0.02),
)
const yokePieces: BufferGeometry[] = [beam]
// --- Frame -------------------------------------------------------------
// Two uprights and a pair of ground sills. Without them the whole
// assembly — bell, clapper, yoke — hangs in mid-air with nothing holding
// it, which is exactly the failure the support check exists to catch.
// A bell also does not come without its frame: the frame is what lets it
// swing, so modelling one without the other is modelling half an object.
//
// Consumers who want the bare bell for their own tower hide
// `parts.frame` — that is what semantic parts are for.
// The frame stands CLEAR of the bell, in both axes.
//
// The uprights were at beamLength * 0.34, which at the default yoke is
// 0.165 against a bell radius of 0.18: half-width included, they spanned
// 0.147 to 0.183 and ran straight through the bell's mouth. Nothing in
// the check suite objects -- the support test sees one connected mass,
// which is exactly what a post driven through a bell looks like -- but a
// bell cannot swing through its own frame. The width is now the larger of
// the yoke's reach and the bell's radius plus a margin, so shortening the
// yoke slider cannot close the frame onto the bell.
//
// And the foot sat at height * 0.16 below the mouth, leaving 24 mm
// between the sill and the lip. A bell hangs clear of its sill; at that
// gap it read as resting on it.
const standFoot = -half - config.height * 0.36
const legY = (beamY + standFoot) / 2
const legHeight = beamY - standFoot
const framePieces: BufferGeometry[] = []
const braceRise = legHeight * 0.44
const braceRun = radius * 0.62
const braceLength = Math.hypot(braceRun, braceRise)
for (const side of [-1, 1]) {
// The upright reaches PAST the rail it carries, so the joint is a real
// overlap rather than two faces meeting.
framePieces.push(chamferedBoxGeometry(
[radius * 0.2, radius * 0.34],
[radius * 0.15, radius * 0.26],
// Starts INSIDE the sill, not level with it: sharing the sill's
// bottom plane put two downward faces in the same place and they
// flickered against each other.
legHeight + radius * 0.22,
radius * 0.03,
[side * standX, legY + radius * 0.19, 0],
tint('oak', -0.04),
tint('oak', 0.03),
))
// Sill: the foot that spreads the load along the ground. It runs across
// the swing, because that is the direction a swinging bell tries to
// rock its frame.
framePieces.push(chamferedBoxGeometry(
[radius * 0.26, radius * 1.5],
[radius * 0.22, radius * 1.34],
radius * 0.22,
radius * 0.03,
[side * standX, standFoot + radius * 0.11, 0],
tint('oak', -0.1),
))
// Braces: a pair per upright, in the plane of that upright's own
// A-frame, running fore and aft to the ends of its sill.
//
// They used to be single diagonals tilted about Z -- across the model,
// in the same plane as the bell -- which meant they had to be shoved
// either in front of the bell (a diagonal drawn straight over the one
// silhouette that has to read cleanly) or outboard of the uprights,
// where they connect to nothing and stick out like broken sticks.
// Neither is what a bell frame does. Real frames are two A-frames, one
// at each end of the beam, braced fore-and-aft in their own planes,
// with the bell swinging in the gap between them. Tilting about X
// instead of Z puts them there, and the bell is never in the way.
for (const dir of [-1, 1]) {
const brace = chamferedBoxGeometry(
[radius * 0.13, radius * 0.2],
[radius * 0.1, radius * 0.16],
braceLength,
radius * 0.025,
[0, 0, 0],
tint('oak', -0.07),
)
brace.rotateX(-dir * Math.atan2(braceRun, braceRise))
brace.translate(
side * standX,
standFoot + radius * 0.11 + braceRise / 2,
dir * braceRun / 2,
)
framePieces.push(brace)
}
}
/*
* Straps: the two iron ears that clamp the rail where the bell hangs
* from it. They carry it from ABOVE, which is how a real strap works, and
* when their top faces were level with the rail's the two ended up
* coplanar and flickered.
*
* They used to sit at `beamLength * 0.34`, which at the default yoke puts
* them all but directly over the uprights, at the far ends of the rail.
* The bell meets the rail at its centre. So the ironwork was at one end of
* the beam and the load at the other, and the straps held nothing: a
* critic reading the render said exactly that. They now straddle the
* hanger, which is the only place on a beam where a strap has a job.
*/
for (const side of [-1, 1]) {
yokePieces.push(boxGeometry(
[radius * 0.09, radius * 0.56, radius * 0.28],
[side * radius * 0.3, beamY - radius * 0.02, 0],
tint('iron', jitter(random, 0.04), 0.7),
))
}
return {
bell: { slot: 'brass' as const, geometry: bell, origin: [0, pivotY, 0] as const },
clapper: {
slot: 'iron' as const,
geometry: clapperGeometry,
origin: [0, pivotY, 0] as const,
},
yoke: {
slot: 'oak' as const,
geometry: mergeColoured([yokePieces[0]!]),
extras: [{ slot: 'iron' as const, geometry: mergeColoured(yokePieces.slice(1)) }],
},
frame: { slot: 'oak' as const, geometry: mergeColoured(framePieces) },
}
},
actions: ({ parts }) => {
const apply = (): void => {
parts.bell.anchor.rotation.z = angle
parts.clapper.anchor.rotation.z = clapper
}
apply()
return {
ring: () => {
// It does not always start from the SAME side: a bell rung over and
// over looked like a machine. The direction is chosen from the
// current velocity, so striking an ongoing swing reinforces it.
const direction = velocity >= 0 ? 1 : -1
velocity += direction * 3.4
},
still: () => {
angle = 0; velocity = 0; clapper = 0; clapperVelocity = 0
apply()
},
isRinging: () => Math.abs(angle) > 1e-4 || Math.abs(velocity) > 1e-4,
strikes: () => strikes,
}
},
update: (dt, { parts, getConfig }) => {
const step = Math.min(0.05, Math.max(0, dt))
if (step === 0) return
const config = getConfig()
const limit = (config.swing * Math.PI) / 180
if (Math.abs(angle) < 1e-5 && Math.abs(velocity) < 1e-5
&& Math.abs(clapper) < 1e-5 && Math.abs(clapperVelocity) < 1e-5) return
// Bell: a damped pendulum. The restoring force is proportional to the
// angle (small-angle approximation), the friction to the velocity.
velocity += -angle * 26 * step - velocity * config.damping * step
angle += velocity * step
if (Math.abs(angle) > limit) {
angle = Math.sign(angle) * limit
velocity *= -0.35 // hitting the yoke's limit
}
// Clapper: a pendulum of its own, but its mount is carried WITH the bell.
// The drag term (angle - clapper) is exactly what produces the lag.
clapperVelocity += (angle - clapper) * 34 * step - clapperVelocity * 0.9 * step
clapper += clapperVelocity * step
// Strike: when the clapper reaches the bell's inner wall. Since the wall
// turns with the bell, the limit is not ABSOLUTE but RELATIVE to it.
const reach = 0.26
const relative = clapper - angle
if (Math.abs(relative) > reach) {
const side = Math.sign(relative)
clapper = angle + side * reach
clapperVelocity *= -0.55
// No counting twice on the same side: one strike, one change of side.
if (side !== lastSide) { strikes += 1; lastSide = side }
} else if (Math.abs(relative) < reach * 0.4) {
lastSide = 0
}
parts.bell.anchor.rotation.z = angle
parts.clapper.anchor.rotation.z = clapper
},
}, overrides)
}