medieval-kit/my-registry/models/tavern-sign/model.ts
CRT a531931069 Hang the tavern sign along its arm instead of across it
The board hung the wrong way round. Its width ran in X while the bracket
projected in Z, so the sign stuck out sideways from the end of the arm --
which is not how an inn sign hangs. The arm comes out from the post and the
board hangs UNDER it, in line with it, so it faces the road both ways and
can be read walking past. Everything that positions the board, its battens,
its lugs and its chains moves from one axis to the other, and it swings
about the arm's own axis now rather than across it.

Turning it the right way also removed a piece. The cross-bar existed
because two chains set either side of a centre-line arm passed it on both
sides and touched nothing; with the board in line, the chains hang from two
points ALONG the arm and the arm is the cross-bar. The note explaining why
that piece had to exist is worth keeping in the history: it was a correct
fix to a problem that only existed because of the orientation.

28 models, 20 667 triangles, all checks pass, 0 floating cases, GLB
round-trip clean, Conformant, build clean.
2026-08-23 13:30:04 +07:00

335 lines
15 KiB
TypeScript

/**
* @medieval-kit/tavern-sign
*
* A wooden board swinging from the end of a forged iron bracket fixed to a wall.
*
* Since literacy was rare, a period sign carried a PICTURE, not TEXT: a garland
* meant the vintner, a boot the cobbler, a mortar the apothecary. So the model
* gives the board itself, not the device on it — the consumer attaches whatever
* they want to `parts.board.anchor`. This is exactly what the protocol's idea
* of semantic parts is good for.
*
* The swing is a different pendulum from the bell's: here the restoring force
* is not gravity but the friction of two rings. So a sign at rest always hangs
* STRAIGHT, but once pushed it oscillates for a long time. Put next to the
* bell's hard, fast damping, the difference between the two reads immediately.
*/
import type { BufferGeometry } from 'three'
import {
arcBarGeometry,
bandGeometry,
boxGeometry,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
mergeColoured,
taperedBoxGeometry,
} from '../core/index.ts'
export interface TavernSignConfig {
/** Board width (metres). */
readonly width: number
/** Board height (metres). */
readonly height: number
/** How far the bracket projects from the wall (metres). */
readonly reach: number
/** Height of the post the arm is bolted to (metres). */
readonly postHeight: number
/** Length of the hanging chain (metres). */
readonly drop: number
/** Number of planks. */
readonly plankCount: number
/** How fast the swing damps out. */
readonly damping: number
readonly seed: number
}
export const tavernSignDefaults: TavernSignConfig = {
width: 0.72,
height: 0.52,
reach: 0.62,
postHeight: 2.15,
// Long enough to see the chain. At 0.12 the board hung almost against the
// bracket and the five links that connect them were a smudge; the chain is
// half of what makes a hanging sign read as hanging.
drop: 0.28,
plankCount: 3,
damping: 0.42,
seed: 73,
}
// The chains are NOT a separate part: they have to swing together with the
// board, so they live as its `extras` body.
export type TavernSignParts = 'bracket' | 'board' | 'post'
export interface TavernSignActions {
/** Pushes the sign: wind, or someone coming out of the door. */
push(strength?: number): void
still(): void
/** Current swing angle (radians). */
lean(): number
}
export function createModel(overrides: Partial<TavernSignConfig> = {}) {
let angle = 0
let velocity = 0
return createKitModel<TavernSignConfig, 'oak' | 'iron', TavernSignParts, TavernSignActions>({
id: 'tavern-sign',
defaults: tavernSignDefaults,
slots: ['oak', 'iron'],
build: ({ config, random }) => {
const tint = createTinter(random)
const bar = config.reach * 0.035
// Axis of rotation: the line where the chains leave the bracket. The
// board and the chains are written RELATIVE to this point.
const pivotY = config.height * 0.5 + config.drop
const armY = pivotY
// Where the two chains meet the arm, measured ALONG it.
const hangA = config.reach * 0.34
const hangB = config.reach * 0.92
// --- Bracket -----------------------------------------------------------
// Sits against the wall at Z zero and reaches out along +Z.
// --- Post ------------------------------------------------------------
// The bracket used to be bolted to a wall the model did not contain, so
// the whole sign floated in the air. A standing signpost is
// period-correct — plenty of inn signs were on posts rather than walls —
// and it is the version that can actually be dropped into a scene,
// because it holds itself up.
// A signpost is a structural timber, not a stake. At reach*0.1 it came
// out 62 mm square carrying a 0.72 m board two metres up -- thinner than
// the board is thick, and part of the same across-the-kit habit of
// under-sizing timber that the bench, the table and the fence all share.
const postWidth = config.reach * 0.19
const postTop = pivotY + config.reach * 0.1
const postBase = postTop - config.postHeight
const timber: BufferGeometry[] = [chamferedBoxGeometry(
[postWidth, postWidth],
[postWidth * 0.82, postWidth * 0.82],
config.postHeight,
postWidth * 0.12,
[0, (postTop + postBase) / 2, 0],
tint('oak', -0.06),
tint('oak', 0.04),
)]
// Soil mound at the foot, the same device the fence uses: it explains how
// the post stays upright and hides where it meets the ground.
const soil = tint('oak', -0.2)
soil.offsetHSL(0, -0.28, 0)
timber.push(taperedBoxGeometry(
[postWidth * 3.2, postWidth * 3.2],
[postWidth * 1.6, postWidth * 1.6],
postWidth * 1.1,
[0, postBase + postWidth * 0.4, 0],
soil,
))
const iron: BufferGeometry[] = []
iron.push(boxGeometry(
[bar * 4.4, config.height * 0.9, bar * 1.6],
[0, armY - config.height * 0.1, bar * 0.4],
tint('iron', -0.05, 0.7),
))
// The horizontal arm.
iron.push(boxGeometry(
[bar * 1.5, bar * 1.7, config.reach],
[0, armY + bar * 0.5, config.reach / 2],
tint('iron', 0.02, 0.7),
))
// Brace: the curved support tying the arm back to the wall. Without it
// the arm looks like it is hanging in the air and the eye's question of
// "what is holding this up" goes unanswered.
// A real quarter-arc, not a sheared box.
//
// This and the curl below were both `boxGeometry` put through
// `bendGeometry`, and a box has two levels in Y. `bendGeometry` cannot
// bend two levels -- it shears them -- so neither piece was ever a
// curve. The sheared curl is what pushed the bracket's top to 0.579
// while the post it is bolted to ends at 0.442: an arm standing above
// its own post, which is why the sign read as broken rather than as
// ironwork.
//
// `arcBarGeometry` sweeps a real arc. Its ends are placed rather than
// corrected: one inside the post, one inside the arm.
const braceRadius = config.reach * 0.45
const brace = arcBarGeometry(
braceRadius, bar * 1.2, -Math.PI / 2, 0, 7, [0, 0, 0],
tint('iron', -0.02, 0.7),
)
// Built in XY; -90 degrees about Y carries +X into +Z, standing the arc
// in the plane the bracket occupies.
brace.rotateY(-Math.PI / 2)
brace.translate(0, armY + bar * 0.5, bar)
iron.push(brace)
// The curl at the end of the arm: the signature of forged iron.
const curlRadius = config.reach * 0.11
const curl = arcBarGeometry(
curlRadius, bar * 0.9, -2.6, 1.9, 9, [0, 0, 0],
tint('iron', 0.06, 0.7),
)
curl.rotateY(-Math.PI / 2)
// Hung under the end of the arm, where a smith finishes the bar.
curl.translate(0, armY + bar * 0.5 - curlRadius, config.reach - bar)
iron.push(curl)
// No cross-bar. The chains hang from the ARM.
//
// The board used to hang across the arm rather than along it -- its width
// ran in X while the arm projected in Z -- so the sign stuck out sideways
// from the end of the bracket. That is not how an inn sign hangs: the arm
// comes out from the post and the board hangs UNDER it, in line with it,
// so it faces the road on both sides and can be read walking past.
//
// Once the board is turned the right way its two chains hang from two
// points along the arm, and the arm is the cross-bar. The extra piece
// that used to be needed to reach chains set either side of a
// centre-line bar is not needed at all.
// --- Chains ----------------------------------------------------------------
// They have to swing TOGETHER with the board, hence the board's `extras`
// body. Were they a separate part, the chain would stay bolt upright
// while the board swung.
//
// The link COUNT is derived from the drop, not fixed. It used to be three
// links whose radius scaled with `config.drop`, which is scale-invariant
// in the worst way: the spacing between links was always 0.5·drop while
// their diameter was always 0.32·drop, so the chain never actually
// interlocked at any setting. At the default drop the gap was small
// enough that nothing caught it; at the top of the slider the board came
// off the bracket completely.
//
// Now the link is sized from the iron stock and enough of them are made
// to overlap across whatever drop is asked for.
// The COUNT is fixed and the RADIUS is derived, not the other way round.
// Deriving the count from `drop` guaranteed the links overlap, but it also
// made the triangle count depend on a continuous slider — which meant the
// showcase could not morph this model and had to fall back to visibly
// stepped rebuilds. Fixing the count and growing the links instead keeps
// both properties: always interlocked, always the same topology.
const linkCount = 5
const linkRadius = Math.max(bar * 0.9, (config.drop / (linkCount - 1)) * 0.62)
const links: BufferGeometry[] = []
for (const side of [-1, 1]) {
const count = linkCount
for (let i = 0; i < count; i += 1) {
// The first link sits INSIDE the arm and the last inside the board's
// ear, so the chain is a real connection rather than two things at
// roughly the same height. Spacing the links evenly across the drop
// (the old `(i + 0.5) / count`) left the top link short of the arm by
// drop/6, which detached the entire board.
const y = -config.drop * (i / Math.max(1, count - 1))
const ring = bandGeometry(linkRadius, 0, bar * 0.6, bar * 0.35, 6,
tint('iron', jitter(random, 0.05), 0.7), { inner: true })
// Each link is indexed a little differently about its own axis.
//
// The links are six-sided and they interlock, so consecutive ones
// share space; with every link built at the same phase their facets
// came out parallel and, where they overlapped, coplanar. Lengthening
// the drop made this visible because the links grew with it -- radius
// 0.0195 to 0.0434 -- and began to overlap in earnest. The offset is
// deterministic rather than random so the seeded stream is untouched,
// and a real chain does not index its links either.
ring.rotateY(i * 0.37 + side * 0.19)
// Successive links must pass through at right angles — that is what a
// chain is.
ring.rotateX(i % 2 === 0 ? Math.PI / 2 : 0)
ring.rotateZ(i % 2 === 0 ? 0 : Math.PI / 2)
ring.translate(0, y, side < 0 ? hangA - config.reach * 0.62 : hangB - config.reach * 0.62)
links.push(ring)
}
}
// --- Board -------------------------------------------------------------------
const planks = Math.max(1, Math.round(config.plankCount))
const plankHeight = config.height / planks
const board: BufferGeometry[] = []
for (let i = 0; i < planks; i += 1) {
const y = -config.drop - config.height + plankHeight * (i + 0.5)
board.push(chamferedBoxGeometry(
[config.height * 0.055, config.width],
[config.height * 0.05, config.width * 0.997],
plankHeight * 0.94,
config.height * 0.012,
[0, y, 0],
tint('oak', jitter(random, 0.05)),
))
}
// The two battens on the back: what holds the planks together. They go
// INTO the planks so that no two faces end up coplanar.
for (const side of [-1, 1]) {
board.push(boxGeometry(
[config.height * 0.045, config.height * 0.94, config.width * 0.07],
[-config.height * 0.045, -config.drop - config.height / 2, side * config.width * 0.36],
tint('oak', -0.09),
))
}
// The two iron lugs joining the board to the chain.
for (const side of [-1, 1]) {
links.push(boxGeometry(
[bar * 1.4, config.drop * 0.4, bar * 1.2],
[0, -config.drop - config.drop * 0.06, side < 0 ? hangA - config.reach * 0.62 : hangB - config.reach * 0.62],
tint('iron', 0.04, 0.7),
))
}
return {
post: { slot: 'oak' as const, geometry: mergeColoured(timber) },
bracket: { slot: 'iron' as const, geometry: mergeColoured(iron) },
board: {
slot: 'oak' as const,
geometry: mergeColoured(board),
// The origin is the CROSS-BAR, not the post.
//
// Everything in this part is authored hanging from the bar at
// z = reach * 0.86, but the origin sat at z = 0 on the post's centre
// line -- half a metre away. At rest that is invisible; the moment
// the swing action runs, the whole assembly rotates about a point it
// does not hang from, and the tops of the chains sweep off the bar
// they are supposed to be looped through. The support audit caught it
// as a detached board the instant the sheared curl stopped
// accidentally filling the gap.
//
// Moving the origin means the geometry above is written relative to
// the bar, which is why every `config.reach * 0.86` in this part is
// now zero. Same place in the world, correct axis to turn about.
origin: [0, pivotY, config.reach * 0.62] as const,
extras: [{ slot: 'iron' as const, geometry: mergeColoured(links) }],
},
}
},
actions: ({ parts }) => {
parts.board.anchor.rotation.z = angle
return {
push: (strength = 1) => {
// Reinforces the existing motion instead of resetting it: successive
// pushes should accumulate the way a real wind does.
velocity += (velocity >= 0 ? 1 : -1) * 1.6 * strength
},
still: () => { angle = 0; velocity = 0; parts.board.anchor.rotation.z = 0 },
lean: () => angle,
}
},
update: (dt, { parts, getConfig }) => {
const step = Math.min(0.05, Math.max(0, dt))
if (step === 0) return
if (Math.abs(angle) < 1e-5 && Math.abs(velocity) < 1e-5) return
// A SOFTER pendulum than the bell's: weak restoring force, little damping.
// This is what the long, lazy swing of a heavy board looks like.
velocity += -angle * 11 * step - velocity * getConfig().damping * step
angle += velocity * step
// Limit: the board must stop before it hits the arm.
const limit = 0.55
if (Math.abs(angle) > limit) {
angle = Math.sign(angle) * limit
velocity *= -0.4
}
parts.board.anchor.rotation.z = angle
},
}, overrides)
}