/** * @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 = {}) { // 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({ 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) } } // Bearings: the two iron ears carrying the rail. They carry it from ABOVE // — that is how a real bearing works, and when their top faces were level // with the rail's the two ended up coplanar and flickered. for (const side of [-1, 1]) { yokePieces.push(boxGeometry( [radius * 0.09, radius * 0.56, radius * 0.28], [side * beamLength * 0.34, 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) }