/** * @contemporary-props/pavement-sign-board * * The first hinge in the kit with a REAL ORIGIN, on the simplest body that * could carry one, which is what the catalogue puts it fourteenth for. * * Every action before this one turned something small on a body that stayed * put: a handwheel, a lever, a lid. This one moves half the object. That makes * the origin the whole design problem rather than a detail of it, because a * part rotates about its own anchor and nothing else -- so the anchor has to * BE the hinge pin, and the geometry has to be built hanging from it. * * Each panel is therefore modelled in its own space with the pin at the origin * and the board hanging down to -Lp. Not centred, not sitting on the ground: * hanging. `fold` is then one number driving `rotation.x` on two parts and * nothing else, and the closed pose is the open pose with that number at zero. * * The second thing the fold has to do is keep the sign ON THE GROUND. Folding * an A-board makes it taller -- the panels stop leaning -- so an origin fixed * at the open height buries the feet as it closes. The action moves the anchor * up by `Lp cos(angle)` as it goes, which is the same trigonometry the pose is * already made of. * * Measured off the reference against an A1 panel: 900 mm of board on a 620 mm * width, 620 mm of foot spread, which puts the half angle at 19 degrees. */ import { type BufferGeometry } from 'three' import { bakeOcclusion, chamferedBoxGeometry, createKitModel, createTinter, latheGeometry, mergeColoured, smoothNormals, } from '../core/index.ts' export interface PavementSignConfig { /** Length of a panel down its slope (metres). */ readonly panel: number /** Width of a panel (metres). */ readonly width: number /** How far the sign is folded, 0 open to 1 flat. */ readonly fold: number readonly seed: number } export const pavementSignDefaults: PavementSignConfig = { panel: 0.95, // An A1 sheet is 594 wide, and the frame is what carries the extra. width: 0.62, fold: 0, seed: 31, } export type PavementSignParts = 'front' | 'back' | 'hinges' | 'stayFront' | 'stayBack' export interface PavementSignActions { /** Folds the sign. 0 open, 1 flat. */ fold(amount?: number): void } /** Half the splay, off the reference's 900 mm height over 620 mm of spread. */ const OPEN = 0.33 /** The frame's section through the panel. The stay hangs off its inner face. */ const SECTION_T = 0.024 /** Where the stay crosses, as a fraction of the panel down from the pin. */ const STAY_AT = 0.62 /** Half the stay: the open horizontal gap between one panel's face and the centre. */ const reachOf = (Lp: number): number => Lp * STAY_AT * Math.sin(OPEN) - (SECTION_T / 2) * Math.cos(OPEN) export function createModel(overrides: Partial = {}) { return createKitModel< PavementSignConfig, 'steelPainted' | 'plastic' | 'rubber' | 'stainless', PavementSignParts, PavementSignActions >({ id: 'pavement-sign-board', defaults: pavementSignDefaults, slots: ['steelPainted', 'plastic', 'rubber', 'stainless'], build: ({ config, random }) => { const tint = createTinter(random) const Lp = Math.min(1.4, Math.max(0.5, config.panel)) const W = Math.min(1, Math.max(0.35, config.width)) // The extruded section the whole frame is made of: 42 wide, 24 through. const sw = 0.042 const st = SECTION_T const halfW = W / 2 const black = tint('steelPainted', -0.3, 0.35) const white = tint('plastic', 0.16, 0.25) // Warmed off neutral, for the same reason the bench's bolts were: a small // pale fitting on a black frame reads cold unless it is given a hue. const chrome = tint('stainless', -0.02, 0.2).offsetHSL(0, -1, 0).offsetHSL(0.08, 0.08, 0) const bar = ( x: number, y: number, z: number, dx: number, dy: number, dz: number, colour = black, ): BufferGeometry => chamferedBoxGeometry( [dx, dz], [dx, dz], dy, Math.min(dx, dy, dz) * 0.18, [0, 0, 0], colour, ).translate(x, y, z) /** * One panel, hanging from the pin at its own origin. * * Stiles run the full length and the rails sit BETWEEN them, which is * how an extruded frame is actually cut and mitred; run the other way * round the corners read as lapped and the section stops looking like * one length of stock. */ const panel = (facing: number): { frame: BufferGeometry[] board: BufferGeometry[] caps: BufferGeometry[] leaves: BufferGeometry[] } => { const pieces: BufferGeometry[] = [] const caps: BufferGeometry[] = [] const board: BufferGeometry[] = [] const leaves: BufferGeometry[] = [] const stileX = halfW - sw / 2 const railTop = -sw / 2 // The stiles carry on past the bottom rail into the feet, which is the // only thing keeping the frame off wet pavement. Measured off the // reference at a twelfth of the panel. The critic asked for longer // legs and then, on the longer legs, for shorter ones; a real board // stands 70 to 80 mm off the pavement on a 900 mm panel, which is // this, and is between the two things it asked for. const footDrop = Lp * 0.09 const railBot = -Lp + footDrop + sw / 2 /* * The foot caps sit FLAT, and getting there is the picnic table's leg * problem again in a different coordinate system. * * A cap modelled square to its stile is square to a member leaning 19 * degrees, so it meets the pavement on one edge and the sign stands on * four lines. A moulded cap is level. Turning it by the panel's own * angle inside the panel's own space cancels that angle exactly, and * it is made deep enough to swallow the stile's mitred end behind it. */ const capH = Lp * 0.045 for (const side of [-1, 1]) { pieces.push(bar(side * stileX, -Lp / 2, 0, sw, Lp, st)) const cap = chamferedBoxGeometry( [sw * 1.12, st * 1.12], [sw * 1.06, st * 1.06], capH, st * 0.12, [0, 0, 0], tint('rubber', -0.04, 0.2), ) cap.rotateX(facing * OPEN) cap.translate(side * stileX, -Lp + capH / (2 * Math.cos(OPEN)), 0) caps.push(cap) } pieces.push(bar(0, railTop, 0, W - sw * 2, sw, st)) pieces.push(bar(0, railBot, 0, W - sw * 2, sw, st)) // The insert: a sheet set into the frame's inner face, so the frame // stands proud of it on the outside exactly as the reference's does. const inset = sw * 0.55 board.push(bar( 0, (railTop + railBot) / 2, facing * (st / 2 - 0.004), W - inset * 2, railTop - railBot - inset * 0.6, 0.005, white, )) /* * A hinge LEAF, and it belongs to the panel it is screwed to. * * Only the knuckle is on the axis; the two leaves turn with their own * panels, which is what a butt hinge is. Built into the fixed part * instead they stay put while the panels swing out from under them. */ for (const side of [-1, 1]) { leaves.push(bar( side * W * 0.26, -0.019, facing * (st / 2 + 0.0016), W * 0.082, 0.032, 0.003, chrome, )) } bakeOcclusion(pieces, { strength: 0.35 }) return { frame: pieces, board, caps, leaves } } /** * The hinges, and they do NOT move. * * A butt hinge's knuckle is the axis, so it sits on the pin and stays * there through the whole fold while both leaves turn under it. Making * it part of a panel would have it orbit the thing it is supposed to be * the centre of. */ /** * The stay is a REAL LINKAGE, and it took a wrong answer to get there. * * A pavement sign's stay is one bar with a pin in its middle: two links * that go straight when the sign is open and collapse between the panels * when it shuts. The first cut made each link a rigid part of its panel, * on the reasoning that they would overlap harmlessly in the gap. They do * not. With the panels vertical each link points sideways rather than * into any gap, and the folded sign measured 386 mm through a body that * should be 50 mm thick. * * So each link is its own part, hanging along -Y from a pin at its * origin, and the fold drives BOTH its position and its angle: the * anchor rides to wherever its panel's face has carried the pin, and the * link swings to point at the knuckle on the centre plane. The knuckle * is wherever a link of fixed length can reach it, which is the whole of * the arithmetic and the whole of why the thing collapses. */ const reach = reachOf(Lp) const stay = (pin: boolean): BufferGeometry => { /* * Wider and lighter than the frame, and both are about being SEEN. * * Measured, the pair spans the gap exactly: -0.180 to +0.180 with the * pin at zero, panel face to panel face. It was reported missing three * rounds running anyway, because a black bar nine millimetres thick, * in the shadow between two panels, is four pixels of a render. The * geometry never needed changing; what needed changing was whether * anything could tell. */ const link = chamferedBoxGeometry( [0.034, 0.013], [0.034, 0.013], reach, 0.003, [0, 0, 0], tint('steelPainted', -0.12, 0.3), ).translate(0, -reach / 2, 0) if (!pin) return link // The pin at the far end, on one link only, because there is one pin // and two links. Without it the pair meets end to end with nothing // marking the joint and reads as a bar that stops halfway across -- // which is exactly what the critic saw, on geometry that measured // correct to the millimetre. const boss = latheGeometry([ { y: -0.008, radius: 0.006 }, { y: 0.008, radius: 0.006 }, ], 10, [0, 0, 0], chrome, { capBottom: true, capTop: true }) boss.rotateX(Math.PI / 2) boss.translate(0, -reach, 0) return mergeColoured([link, boss]) } const hingePieces: BufferGeometry[] = [] for (const side of [-1, 1]) { const knuckle = latheGeometry([ { y: -W * 0.046, radius: 0.008 }, { y: W * 0.046, radius: 0.008 }, ], 12, [0, 0, 0], chrome, { capBottom: true, capTop: true }) knuckle.rotateZ(Math.PI / 2) knuckle.translate(side * W * 0.26, 0.004, 0) hingePieces.push(knuckle) } /* * The insert and the caps ride the panel as EXTRAS rather than as vertex * colours on the frame. * * They are different materials -- a printed sheet and a moulded foot on * an aluminium extrusion -- and a consumer who swaps `steelPainted` for * their own paint should not repaint the sign face with it. An extra * carries its own slot and follows its part through the fold, which is * exactly the relationship these three have. */ const side = (facing: number) => { const { frame, board, caps, leaves } = panel(facing) return { slot: 'steelPainted' as const, geometry: smoothNormals(mergeColoured(frame), 24), origin: [0, Lp * Math.cos(OPEN), 0] as const, extras: [ { slot: 'plastic' as const, geometry: smoothNormals(mergeColoured(board), 30) }, { slot: 'rubber' as const, geometry: smoothNormals(mergeColoured(caps), 24) }, { slot: 'stainless' as const, geometry: smoothNormals(mergeColoured(leaves), 30) }, ], } } const stayOrigin = (facing: number): readonly [number, number, number] => { const rot = -facing * OPEN return [ 0, Lp * Math.cos(OPEN) + -Lp * STAY_AT * Math.cos(rot) - ((-facing * st) / 2) * Math.sin(rot), -Lp * STAY_AT * Math.sin(rot) + ((-facing * st) / 2) * Math.cos(rot), ] as const } return { front: side(-1), back: side(1), stayFront: { slot: 'steelPainted' as const, geometry: smoothNormals(stay(true), 24), origin: stayOrigin(-1), }, stayBack: { slot: 'steelPainted' as const, geometry: smoothNormals(stay(false), 24), origin: stayOrigin(1), }, hinges: { slot: 'stainless' as const, geometry: smoothNormals(mergeColoured(hingePieces), 30), origin: [0, Lp * Math.cos(OPEN), 0] as const, }, } }, actions: ({ parts, getConfig }) => { const set = (amount: number): void => { const t = Math.min(1, Math.max(0, amount)) const angle = OPEN * (1 - t) parts.front.anchor.rotation.x = angle parts.back.anchor.rotation.x = -angle // ...and the pin rises as the panels straighten, because a folded // A-board is taller than an open one and an origin left at the open // height buries the feet. const Lp = Math.min(1.4, Math.max(0.5, getConfig().panel)) const hingeY = Lp * Math.cos(angle) for (const part of [parts.front, parts.back, parts.hinges]) { part.anchor.position.setY(hingeY) } /* * ...and then the stay follows, which is two numbers per link. * * The pin sits on the panel's inner face `STAY_AT` of the way down, so * where it ENDS UP is the panel's own rotation applied to that point. * The knuckle is on the centre plane at whatever height a link of * fixed length can still reach from there -- which is what makes the * pair go straight when open and hang when shut, with no case * analysis and no clamp. */ const reach = reachOf(Lp) for (const [part, facing] of [[parts.stayFront, -1], [parts.stayBack, 1]] as const) { const rot = -facing * angle const ly = -Lp * STAY_AT const lz = (-facing * SECTION_T) / 2 const ay = hingeY + ly * Math.cos(rot) - lz * Math.sin(rot) const az = ly * Math.sin(rot) + lz * Math.cos(rot) part.anchor.position.set(0, ay, az) // Built hanging along -Y, so the angle that points it at the knuckle // is measured from straight down. part.anchor.rotation.x = Math.atan2(az, Math.sqrt(Math.max(0, reach * reach - az * az))) } } set(getConfig().fold) return { fold: (amount = 1) => { set(amount) } } }, }, overrides) }