/** * @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, 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 = {}) { 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) }