Three faults, one of them mine and one that had been there from the start. The braces were the change this session declined and that reached the repo anyway, in 715d6af: moved outboard of the uprights, where they connect to nothing and read as broken sticks. Reverting them was not enough, because the version they replaced was also wrong -- a single diagonal tilted about Z, in the same plane as the bell, which had to be shoved in front of it and drew a line straight across the one silhouette in this model that has to read cleanly. Neither is what a bell frame does. A real frame is 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. Tilting about X instead of Z puts them there and the bell is never in the way. The uprights stood at beamLength * 0.34, which at the default yoke is 0.165 against a bell radius of 0.18. With their half-width they spanned 0.147 to 0.183 and ran through the mouth of the bell. Nothing objected: the support test sees one connected mass, which is precisely what a post driven through a bell looks like. They now stand at the larger of the yoke's reach and the bell's radius plus a margin, so a short yoke cannot close the frame onto the bell -- and the beam's span is derived from that rather than from the yoke alone, because decoupling the two first left the rail too short to reach the uprights at the bottom of the slider and the two A-frames came apart into separate objects. The foot sat height * 0.16 below the mouth, leaving 24 mm between sill and lip. A bell hangs clear of its sill; at that gap it read as resting on one. Colour last. The bell was painted from the `brass` entry and rendered at hue 39 / saturation 0.56 / value 0.56, against a reference at 33 / 0.34 / 0.33. That is not a subtle disagreement and, unlike the hay bale, the measurement can be trusted here: a bale photographs desaturated because it is thousands of straws shadowing each other, while a cast bell is a smooth surface, so the number is the material rather than the texture. A separate `bronze` palette entry, derived by inverting the renderer's own gain, brings it to 30 / 0.31 / 0.43. It is a palette entry and not a slot -- the mesh still resolves through `brass` -- so the coin pouch and the book's corner fittings, where fresh yellow bronze is right, are untouched. 27 models, 18457 triangles, all checks pass, 0 floating cases, build clean.
376 lines
16 KiB
TypeScript
376 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|