medieval-kit/contemporary-props/models/wheeled-waste-bin/model.ts

284 lines
11 KiB
TypeScript

/**
* @contemporary-props/wheeled-waste-bin
*
* A tapered ribbed moulding with a hinged lid on an axle, and the catalogue's
* thirty-second row: the first appearance of the wheel part. The wheel itself
* lives in core rather than here, because four later rows in three other
* domains want the same one -- the sack truck and the wheelbarrow in tools, the
* shopping trolley in retail, the task chair in the office -- and a wheel
* written inside a bin is a wheel each of them rewrites.
*
* Measured against the 240 litre bin, which is the one on every kerb: 1080 mm
* to the top of the lid, 580 across the front, 740 front to back, on 200 mm
* wheels. The body tapers about a tenth from its rim to its base, which is not
* styling -- it is draft, so the moulding leaves the tool, and it is the reason
* these things stack.
*
* The lid hinges on the axle bar across the back, so its origin is that bar and
* nothing else. It also OVERHANGS at the front, which is what makes the lip you
* lift it by, and the gussets under the rim are the moulded ribs that stop a
* thin-walled box that size from folding when it is tipped.
*/
import { type BufferGeometry } from 'three'
import {
bakeOcclusion,
chamferedBoxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
smoothNormals,
wheelGeometry,
type RuntimeContext,
} from '../core/index.ts'
export interface WasteBinConfig {
/** Height to the top of the closed lid (metres). */
readonly height: number
/** Width across the front (metres). */
readonly width: number
/** Depth, front to back (metres). */
readonly depth: number
/** How far the lid is open, 0 shut to 1 back on its hinge. */
readonly open: number
readonly seed: number
}
export const wasteBinDefaults: WasteBinConfig = {
height: 1.08,
width: 0.58,
depth: 0.74,
open: 0,
seed: 61,
}
export type WasteBinParts = 'body' | 'lid' | 'wheels' | 'tyres'
export interface WasteBinActions {
/** Swings the lid back on its hinge. 1 is fully open. */
open(amount?: number): void
/** Rolls the wheels. 1 is a full turn. */
roll(turns?: number): void
}
function applyOpen(
runtime: RuntimeContext<WasteBinConfig, WasteBinParts>,
amount: number,
): void {
// Just past square, which is where a bin lid actually falls to when it is
// thrown back: it goes over centre and rests against the body.
runtime.parts.lid.anchor.rotation.x = Math.min(1, Math.max(0, amount)) * 1.9
}
export function createModel(overrides: Partial<WasteBinConfig> = {}) {
let heldOpen = 0
let seenOpen = Number.NaN
return createKitModel<
WasteBinConfig, 'plastic' | 'rubber', WasteBinParts, WasteBinActions
>({
id: 'wheeled-waste-bin',
defaults: wasteBinDefaults,
slots: ['plastic', 'rubber'],
build: ({ config, random }) => {
const tint = createTinter(random)
const H = Math.min(1.4, Math.max(0.7, config.height))
const W = Math.min(0.9, Math.max(0.35, config.width))
const D = Math.min(1.1, Math.max(0.4, config.depth))
// Down from the palette's plastic, which is set for the pale mouldings
// most of the kit's plastic is. A municipal bin is a mid grey, and at the
// palette's own value the body came out near white and read as a fridge.
const shell = tint('plastic', -0.33 + jitter(random, 0.02))
const dark = tint('plastic', -0.46, 0.6)
const wheelR = H * 0.093
const lidH = H * 0.085
const bodyTop = H - lidH
const bodyBase = wheelR * 0.28
// A tenth of draft between rim and base, which is what lets the moulding
// leave the tool and what makes a rank of these nest.
const taper = 0.9
const bodyPieces: BufferGeometry[] = []
/* --------------------------------------------------------------- body */
bodyPieces.push(chamferedBoxGeometry(
[W * taper, D * taper], [W, D],
bodyTop - bodyBase, W * 0.075, [0, (bodyTop + bodyBase) / 2, 0], shell,
))
// The rim: a flange right round the top, which is what the lid sits on
// and what a lifter's forks catch under.
bodyPieces.push(chamferedBoxGeometry(
[W * 1.028, D * 1.028], [W * 1.02, D * 1.02],
H * 0.03, W * 0.02, [0, bodyTop - H * 0.012, 0], shell,
))
/**
* The gussets under the rim.
*
* They are the dark triangles in every photograph of one of these and
* they are structural: a thin-walled box 1.1 m tall folds at the rim when
* it is tipped, so the moulding carries a rib every 150 mm or so. Left
* off, the body reads as a bin bag holder.
*/
const ribs = Math.max(4, Math.round(W / 0.15) * 2 + Math.round(D / 0.15) * 2)
for (let i = 0; i < ribs; i += 1) {
const t = (i + 0.5) / ribs
// Walk the rim's perimeter, so the ribs sit on all four faces without
// four separate loops and without bunching at the corners.
const a = t * Math.PI * 2
const ex = Math.cos(a)
const ez = Math.sin(a)
const scale = 1 / Math.max(Math.abs(ex) / (W / 2), Math.abs(ez) / (D / 2))
const x = ex * scale
const z = ez * scale
// Short and broad at the top, tapering to nothing: a gusset, not a
// tooth. At three quarters of this length they hung below the rim in a
// row and the bin came out fringed.
// Measured off the reference at 7% of the body's height, which is
// between the two rounds that argued about it: at 7.5% they hung below
// the rim like a fringe, at 4.2% the critic called them shallow, and
// neither reading was taken from the photograph.
const rib = chamferedBoxGeometry(
[W * 0.06, W * 0.08], [W * 0.03, W * 0.01],
H * 0.06, W * 0.008, [0, 0, 0], dark,
)
rib.translate(x * 0.985, bodyTop - H * 0.052, z * 0.985)
bodyPieces.push(rib)
}
/* ---------------------------------------------------------------- lid */
/**
* A domed lid that OVERHANGS at the front.
*
* Flush with the body it is a flat cap and there is nothing to lift it
* by; the reference's stands proud all round and further at the front,
* where the lip is. The dome is two steps rather than a curve because a
* blow-moulded lid is exactly that: a flat crown with a shoulder.
*/
const hingeZ = D * 0.5
const lidPieces: BufferGeometry[] = []
lidPieces.push(chamferedBoxGeometry(
[W * 1.05, D * 1.03], [W * 1.02, D * 1.0],
lidH * 0.55, W * 0.03, [0, bodyTop + lidH * 0.275, -D * 0.01], shell,
))
lidPieces.push(chamferedBoxGeometry(
[W * 0.94, D * 0.9], [W * 0.8, D * 0.76],
lidH * 0.5, W * 0.035, [0, bodyTop + lidH * 0.72, -D * 0.02], shell,
))
// The two shoulders running front to back, which every blow-moulded lid
// has and which are the only thing breaking a half square metre of flat
// grey. They are stiffeners, so they run the long way.
for (const side of [-1, 1]) {
lidPieces.push(chamferedBoxGeometry(
[W * 0.2, D * 0.72], [W * 0.15, D * 0.66],
lidH * 0.22, W * 0.03,
[side * W * 0.24, bodyTop + lidH * 0.96, -D * 0.03], shell,
))
}
// The lip at the front, which is the handle.
lidPieces.push(chamferedBoxGeometry(
[W * 0.5, D * 0.05], [W * 0.44, D * 0.035],
lidH * 0.34, W * 0.012,
[0, bodyTop + lidH * 0.2, -D * 0.5 - D * 0.012], dark,
))
/* -------------------------------------------------- axle and the wheels */
/**
* The wheels come from core, and they come as two geometries.
*
* A wheel is a tyre and a hub in two different materials, and this kit
* gives one slot per part -- so the helper hands back both and the model
* puts each in its own part. Merged into one, a consumer who recoloured
* the bin's plastic would get plastic tyres.
*/
const wheelX = W * 0.5 - wheelR * 0.42
const wheelZ = D * 0.5 - wheelR * 0.85
const tyrePieces: BufferGeometry[] = []
const hubPieces: BufferGeometry[] = []
// Set from the wheel's own contact radius rather than from its nominal
// one: see `Wheel.contact`. At the radius the bin hovered a millimetre.
let wheelY = wheelR
for (const side of [-1, 1]) {
const { tyre, hub, contact } = wheelGeometry({
radius: wheelR,
width: wheelR * 0.42,
spokes: 5,
tyre: tint('rubber', jitter(random, 0.02)),
// As dark as the tyre nearly. A pale hub on a black tyre reads as a
// flat disc stuck to the wheel rather than as a recess in it, which
// is what the spokes are there to show.
hub: tint('plastic', -0.52, 0.4),
})
wheelY = contact
tyre.translate(side * wheelX, wheelY, wheelZ)
hub.translate(side * wheelX, wheelY, wheelZ)
tyrePieces.push(tyre)
hubPieces.push(hub)
}
// The axle between them, and the two stub brackets the body carries it on.
bodyPieces.push(latheGeometry([
{ y: -wheelX, radius: wheelR * 0.13 },
{ y: wheelX, radius: wheelR * 0.13 },
], 10, [0, 0, 0], dark, { capBottom: false, capTop: false })
.rotateZ(Math.PI / 2)
.translate(0, wheelY, wheelZ))
// The hinge bar across the back, which the lid turns on and the handle
// a bin is wheeled by.
bodyPieces.push(latheGeometry([
{ y: -W * 0.46, radius: H * 0.014 },
{ y: W * 0.46, radius: H * 0.014 },
], 12, [0, 0, 0], dark, { capBottom: true, capTop: true })
.rotateZ(Math.PI / 2)
// Proud of the lid at the back rather than under it: the hinge bar is
// also the handle a bin is wheeled by, and a hinge nobody can see is a
// hinge the model does not demonstrate.
.translate(0, bodyTop + H * 0.042, hingeZ + H * 0.012))
bakeOcclusion(bodyPieces, { strength: 0.4 })
const hinge = [0, bodyTop + H * 0.042, hingeZ + H * 0.012] as const
return {
body: { slot: 'plastic' as const, geometry: smoothNormals(mergeColoured(bodyPieces), 38) },
lid: {
slot: 'plastic' as const,
geometry: smoothNormals(
mergeColoured(lidPieces).translate(-hinge[0], -hinge[1], -hinge[2]), 38,
),
origin: hinge,
},
wheels: { slot: 'plastic' as const, geometry: smoothNormals(mergeColoured(hubPieces), 40) },
tyres: { slot: 'rubber' as const, geometry: smoothNormals(mergeColoured(tyrePieces), 40) },
}
},
actions: (runtime) => {
heldOpen = runtime.getConfig().open
seenOpen = heldOpen
applyOpen(runtime, heldOpen)
return {
open: (amount = 1) => { heldOpen = amount; applyOpen(runtime, amount) },
roll: (turns = 0.25) => {
// The wheels turn about their own axle, which is where both wheel
// parts already sit, so one rotation on each is the whole action.
for (const name of ['wheels', 'tyres'] as const) {
runtime.parts[name].anchor.rotation.x = -turns * Math.PI * 2
}
},
}
},
update: (_dt, runtime) => {
const wanted = runtime.getConfig().open
if (wanted !== seenOpen) { seenOpen = wanted; heldOpen = wanted }
applyOpen(runtime, heldOpen)
},
}, overrides)
}